QTableWidget: How to remove subsequent rows on new row insert?

Managing chess move history in a QTableWidget with row insertion and deletion
Heads up! You've already completed this tutorial.

If you're building a UI where you track input, yet want to allow users to step back through a history and branch off in a new direction — you'll eventually need to handle a specific problem: when the user goes back and makes a new entry, everything that came after that point needs to be removed.

In this tutorial, we'll work through how to manage this with a QTableWidget in PyQt6. We'll build a working chess move list where White and Black moves are displayed in two columns, and selecting a previous row then adding a new move clears all subsequent rows.

The problem

Imagine a chess move table with two columns: one for White's moves and one for Black's. Each row represents a full turn (White's move, then Black's move). As the game progresses, the table fills up with move history.

Now the user clicks on a row partway up the table — they want to explore a different line. When a new move is added at that point, all the rows below the selected row should be removed. The new move takes their place.

There are two cases to handle:

  • White's turn: Insert a new row after the selected one, place the move in column 0, and clear everything below.
  • Black's turn: Replace (or fill in) the move in column 1 of the selected row, and clear everything below.

Setting up the table

Let's start by building a simple QTableWidget with two columns and a button to simulate adding moves. We'll create a small Move class and a generator that produces alternating White and Black moves with random chess-like notation.

python
from PyQt6.QtWidgets import (
    QTableWidget, QVBoxLayout, QWidget, QPushButton,
    QTableWidgetItem, QMainWindow, QApplication,
)
from random import choice

WHITE = 0
BLACK = 1


class Move:
    def __init__(self, turn, move):
        self.turn = turn
        self.move = move

    def __str__(self):
        return f"{self.turn} {self.move}"


def chess_moves():
    """Generator producing chess moves, alternating White and Black."""
    turn = WHITE
    while True:
        move_str = choice("KQRBN ") + choice("abcdefgh") + choice("12345678")
        yield Move(turn, move_str)

        if turn == WHITE:
            turn = BLACK
        else:
            turn = WHITE


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.moves = chess_moves()

        self.moves_widget = QTableWidget()
        self.moves_widget.setColumnCount(2)
        self.moves_widget.setHorizontalHeaderLabels(["White", "Black"])

        self.btn = QPushButton("Move")
        self.btn.pressed.connect(self.add_chess_move)

        layout = QVBoxLayout()
        layout.addWidget(self.moves_widget)
        layout.addWidget(self.btn)

        w = QWidget()
        w.setLayout(layout)
        self.setCentralWidget(w)

    def add_chess_move(self):
        move = next(self.moves)
        item = QTableWidgetItem(move.move)

        row = self.moves_widget.rowCount() - 1

        if move.turn == WHITE:
            row += 1
            self.moves_widget.setRowCount(row + 1)

        self.moves_widget.setItem(row, move.turn, item)
        self.moves_widget.scrollToBottom()


app = QApplication([])
w = MainWindow()
w.show()
app.exec()

Run this and click the Move button a few times. You'll see White and Black moves filling in alternately across two columns. So far, so good — but selecting a row and clicking Move still just appends to the bottom. We need to add the deletion logic.

Removing subsequent rows with setRowCount()

When the user selects a row and then triggers a new move, we need to chop off everything below that row. The most straightforward way to do this is with setRowCount().

If the selected row is row 3 (zero-indexed), and you want to keep only rows 0 through 3, you set the row count to 4:

python
self.moves_widget.setRowCount(row + 1)

The + 1 is because row indices are zero-based but setRowCount() expects a count. Row 0 means one row, row 3 means you want four rows total.

This single call removes every row after the specified count. You could also loop with removeRow(), but setRowCount() is cleaner and does the job in one step.

Handling the two branches

With the deletion logic in mind, the add_chess_move method needs two separate code paths:

  1. A row is selected — truncate the table, then place the move at the right spot.
  2. No row is selected — append the move to the end, as before.

Within the "row is selected" path, there's a further distinction:

  • If it's White's turn, place the White move on the selected row and clear any existing Black move on that row (since we're starting a new line from here).
  • If it's Black's turn, place the Black move on the selected row alongside the existing White move.

Here's how that looks in practice:

python
def add_chess_move(self):
    move = next(self.moves)
    item = QTableWidgetItem(move.move)

    selected = self.moves_widget.selectedIndexes()
    if selected:
        # A row is selected — truncate everything after it.
        row = selected[0].row()
        self.moves_widget.setRowCount(row + 1)

        # Clear the selection so the next move appends normally.
        self.moves_widget.clearSelection()

        # Place the move in the correct column.
        self.moves_widget.setItem(row, move.turn, item)

        if move.turn == WHITE:
            # We're overwriting this row from White's move onward,
            # so remove any existing Black move.
            self.moves_widget.takeItem(row, BLACK)

    else:
        # No selection — append as usual.
        row = self.moves_widget.rowCount() - 1

        if move.turn == WHITE:
            row += 1
            self.moves_widget.setRowCount(row + 1)

        self.moves_widget.setItem(row, move.turn, item)

    self.moves_widget.scrollToBottom()

After truncating and placing the move, we call self.moves_widget.clearSelection(). This is easy to overlook, but without it the next move would also trigger the "selected" branch and delete rows again unintentionally.

The takeItem() call removes the Black move cell content when White overwrites a row. This keeps things tidy — you don't want a stale Black move sitting next to a brand-new White move from a different line.

Complete working example

Here's the full application with both branches wired up. Click Move a bunch of times to populate the table, then click on an earlier row and click Move again to see the subsequent rows disappear.

python
from PyQt6.QtWidgets import (
    QTableWidget, QVBoxLayout, QWidget, QPushButton,
    QTableWidgetItem, QMainWindow, QApplication,
)
from random import choice

WHITE = 0
BLACK = 1


class Move:
    def __init__(self, turn, move):
        self.turn = turn
        self.move = move

    def __str__(self):
        return f"{self.turn} {self.move}"


def chess_moves():
    """Generator producing alternating White and Black chess moves."""
    turn = WHITE
    while True:
        move_str = choice("KQRBN ") + choice("abcdefgh") + choice("12345678")
        yield Move(turn, move_str)

        if turn == WHITE:
            turn = BLACK
        else:
            turn = WHITE


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.moves = chess_moves()

        self.moves_widget = QTableWidget()
        self.moves_widget.setColumnCount(2)
        self.moves_widget.setHorizontalHeaderLabels(["White", "Black"])

        self.btn = QPushButton("Move")
        self.btn.pressed.connect(self.add_chess_move)

        layout = QVBoxLayout()
        layout.addWidget(self.moves_widget)
        layout.addWidget(self.btn)

        w = QWidget()
        w.setLayout(layout)
        self.setCentralWidget(w)

    def add_chess_move(self):
        move = next(self.moves)
        item = QTableWidgetItem(move.move)

        selected = self.moves_widget.selectedIndexes()
        if selected:
            # A row is selected — truncate the table to this row.
            row = selected[0].row()
            self.moves_widget.setRowCount(row + 1)

            # Clear the selection so the next move appends normally.
            self.moves_widget.clearSelection()

            # Place the new move in the correct column.
            self.moves_widget.setItem(row, move.turn, item)

            if move.turn == WHITE:
                # Clear the Black column — we're starting fresh from here.
                self.moves_widget.takeItem(row, BLACK)

        else:
            # No selection — append to the end of the table.
            row = self.moves_widget.rowCount() - 1

            if move.turn == WHITE:
                # White always starts a new row.
                row += 1
                self.moves_widget.setRowCount(row + 1)

            self.moves_widget.setItem(row, move.turn, item)

        self.moves_widget.scrollToBottom()


app = QApplication([])
w = MainWindow()
w.show()
app.exec()

Adapting this for a real chess application

In a real chess GUI using the python-chess library, you'd replace the random move generator with actual board moves. The slot might look something like this:

python
@pyqtSlot(chess.Move)
def on_new_move_made(self, move):
    row = self.moves_widget.rowCount()
    column = 0 if settings.board.turn == chess.WHITE else 1

    san_move = settings.board.san(move)
    new_move = QTableWidgetItem(san_move)

    selected_move = self.moves_widget.selectedIndexes()

    if selected_move:
        row = selected_move[0].row()

        if settings.board.turn == chess.WHITE:
            # Truncate to the selected row, then add a fresh row.
            self.moves_widget.setRowCount(row + 1)
            self.moves_widget.insertRow(row + 1)
            row += 1
        elif settings.board.turn == chess.BLACK:
            # Truncate to the selected row — Black fills in column 1.
            self.moves_widget.setRowCount(row + 1)

    else:
        if settings.board.turn == chess.WHITE:
            self.moves_widget.setRowCount(row + 1)
        elif settings.board.turn == chess.BLACK:
            row -= 1

    self.moves_widget.setItem(row, column, new_move)
    self.moves_widget.clearSelection()
    self.moves_widget.scrollToBottom()

The structure is the same: check for a selection, truncate if needed, then place the move. The clearSelection() call at the end ensures the table returns to its normal append behavior on the following move.

An alternative approach: Model Views

If you find yourself doing a lot of list manipulation — inserting, deleting, slicing — you might want to look into Qt's Model/View architecture using QTableView with a custom model. With that approach, your move list is a plain Python list, and operations like "delete everything after index 5" become a simple slice:

python
self.move_list = self.move_list[:selected_index + 1]

The view updates automatically when the model changes. For a chess application with potentially complex move trees or undo/redo support, this separation of data and display can save you a lot of headaches down the road.

But for a straightforward move list, QTableWidget with setRowCount() and takeItem() works well and keeps things simple.

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak

Packaging Python Applications with PyInstaller by Martin Fitzpatrick

This step-by-step guide walks you through packaging your own Python applications from simple examples to complete installers and signed executables.

More info Get the book

Martin Fitzpatrick

QTableWidget: How to remove subsequent rows on new row insert? was written by Martin Fitzpatrick.

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt. Martin founded PythonGUIs to provide easy to follow GUI programming tutorials to the Python community. He has written a number of popular Python books on the subject.