Adding Pagination to QTableView in PyQt6

How to display large datasets page by page with navigation controls
Heads up! You've already completed this tutorial.

I've seen many QTableView examples but none of them include pagination. How do you handle large datasets in a table? And how do you add edit/save functionality per row?

Most QTableView examples keep things simple by loading all the data at once. That works fine for small datasets, but when you're dealing with hundreds or thousands of rows, displaying everything at once can feel overwhelming for users (and can slow things down). Pagination solves this by splitting your data into pages and showing one page at a time, with controls to move between them.

In this tutorial, we'll build a paginated QTableView from scratch using PyQt6. We'll also add an "Edit/Save" button to each row so users can modify and confirm changes inline.

The Approach

Qt's model/view architecture gives us a clean way to implement pagination. Rather than loading different data for each page, we'll keep all the data in our model and use a proxy model that filters which rows are visible based on the current page. This means:

  • The underlying data model holds everything.
  • A proxy model sits between the data model and the view, only exposing rows for the current page.
  • Navigation buttons let the user move between pages.

Let's start by building each piece.

A Simple Data Model

First, let's create a basic table model that holds our data. We'll use QAbstractTableModel and populate it with some sample data.

python
from PyQt6.QtCore import Qt, QAbstractTableModel


class PaginatedTableModel(QAbstractTableModel):
    def __init__(self, data, headers):
        super().__init__()
        self._data = data
        self._headers = headers

    def rowCount(self, parent=None):
        return len(self._data)

    def columnCount(self, parent=None):
        return len(self._headers)

    def data(self, index, role=Qt.ItemDataRole.DisplayRole):
        if not index.isValid():
            return None
        if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
            return self._data[index.row()][index.column()]
        return None

    def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
        if index.isValid() and role == Qt.ItemDataRole.EditRole:
            self._data[index.row()][index.column()] = value
            self.dataChanged.emit(index, index, [role])
            return True
        return False

    def flags(self, index):
        return (
            Qt.ItemFlag.ItemIsSelectable
            | Qt.ItemFlag.ItemIsEnabled
        )

    def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
        if role == Qt.ItemDataRole.DisplayRole:
            if orientation == Qt.Orientation.Horizontal:
                return self._headers[section]
            else:
                return str(section + 1)
        return None

Notice that flags() does not include ItemIsEditable. We don't want the cells to be directly editable by double-clicking — instead, we'll control editing through per-row buttons.

The Pagination Proxy Model

Now we need a proxy model that only shows a slice of the data — one "page" at a time. We'll subclass QSortFilterProxyModel and override filterAcceptsRow to accept only rows that fall within the current page range. If you're new to sort and filter proxy models, see how to sort and filter tables in PyQt6 for a detailed introduction.

python
from PyQt6.QtCore import QSortFilterProxyModel


class PaginationProxyModel(QSortFilterProxyModel):
    def __init__(self, page_size=10):
        super().__init__()
        self._page_size = page_size
        self._current_page = 0

    @property
    def page_size(self):
        return self._page_size

    @property
    def current_page(self):
        return self._current_page

    @property
    def total_pages(self):
        source_rows = self.sourceModel().rowCount()
        return max(1, (source_rows + self._page_size - 1) // self._page_size)

    def set_page(self, page):
        page = max(0, min(page, self.total_pages - 1))
        self._current_page = page
        self.invalidateFilter()

    def filterAcceptsRow(self, source_row, source_parent):
        start = self._current_page * self._page_size
        end = start + self._page_size
        return start <= source_row < end

When we call set_page(), the proxy model invalidates its filter, which causes the view to refresh and show only the rows for the requested page.

Adding Per-Row Edit and Save Buttons

To put an "Edit" or "Save" button in each row, we'll use a delegate. A delegate lets you customize how individual cells are rendered and interacted with. We'll place a button in the last column of each row.

The idea is:

  • Each row starts in "view" mode with an "Edit" button.
  • Clicking "Edit" makes that row's cells editable and changes the button to "Save".
  • Clicking "Save" commits the changes and returns to view mode.

We'll track which rows are being edited using a set, and use a custom delegate to paint the buttons and handle clicks.

python
from PyQt6.QtWidgets import QStyledItemDelegate, QStyleOptionButton, QStyle, QApplication
from PyQt6.QtCore import QRect, QEvent, pyqtSignal, QObject


class EditSaveDelegate(QStyledItemDelegate):
    def __init__(self, parent=None):
        super().__init__(parent)

    def paint(self, painter, option, index):
        if index.column() == index.model().columnCount() - 1:
            button_option = QStyleOptionButton()
            button_option.rect = option.rect.adjusted(4, 4, -4, -4)
            button_option.state = QStyle.StateFlag.State_Enabled

            # Determine button text based on edit state
            view = self.parent()
            source_row = self._get_source_row(index)
            if source_row in view.editing_rows:
                button_option.text = "Save"
            else:
                button_option.text = "Edit"

            QApplication.style().drawControl(
                QStyle.ControlElement.CE_PushButton, button_option, painter
            )
        else:
            super().paint(painter, option, index)

    def editorEvent(self, event, model, option, index):
        if index.column() == index.model().columnCount() - 1:
            if (
                event.type() == QEvent.Type.MouseButtonRelease
                and option.rect.adjusted(4, 4, -4, -4).contains(
                    event.pos().toPoint()
                    if hasattr(event.pos(), "toPoint")
                    else event.pos()
                )
            ):
                view = self.parent()
                source_row = self._get_source_row(index)
                view.toggle_edit_row(source_row)
                return True
        return super().editorEvent(event, model, option, index)

    def _get_source_row(self, index):
        """Map the proxy index back to the source model row."""
        model = index.model()
        if hasattr(model, "mapToSource"):
            source_index = model.mapToSource(index)
            return source_index.row()
        return index.row()

The delegate draws a button using Qt's style system, and intercepts mouse clicks to toggle the edit state.

Putting It All Together

Now let's build the main window that combines the model, proxy, delegate, and navigation controls. We're using layouts to arrange the table and navigation buttons within the window.

python
import sys
from PyQt6.QtWidgets import (
    QApplication,
    QMainWindow,
    QTableView,
    QVBoxLayout,
    QHBoxLayout,
    QWidget,
    QPushButton,
    QLabel,
    QStyledItemDelegate,
    QStyleOptionButton,
    QStyle,
    QLineEdit,
)
from PyQt6.QtCore import (
    Qt,
    QAbstractTableModel,
    QSortFilterProxyModel,
    QEvent,
)


class PaginatedTableModel(QAbstractTableModel):
    def __init__(self, data, headers):
        super().__init__()
        self._data = data
        self._headers = headers

    def rowCount(self, parent=None):
        return len(self._data)

    def columnCount(self, parent=None):
        # +1 for the Edit/Save button column
        return len(self._headers) + 1

    def data(self, index, role=Qt.ItemDataRole.DisplayRole):
        if not index.isValid():
            return None
        if index.column() == self.columnCount() - 1:
            return None  # Button column has no data
        if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
            return self._data[index.row()][index.column()]
        return None

    def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
        if (
            index.isValid()
            and role == Qt.ItemDataRole.EditRole
            and index.column() < len(self._headers)
        ):
            self._data[index.row()][index.column()] = value
            self.dataChanged.emit(index, index, [role])
            return True
        return False

    def flags(self, index):
        base_flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled
        return base_flags

    def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
        if role == Qt.ItemDataRole.DisplayRole:
            if orientation == Qt.Orientation.Horizontal:
                if section < len(self._headers):
                    return self._headers[section]
                return "Action"
            return str(section + 1)
        return None


class PaginationProxyModel(QSortFilterProxyModel):
    def __init__(self, page_size=10):
        super().__init__()
        self._page_size = page_size
        self._current_page = 0

    @property
    def page_size(self):
        return self._page_size

    @property
    def current_page(self):
        return self._current_page

    @property
    def total_pages(self):
        source_rows = self.sourceModel().rowCount()
        return max(1, (source_rows + self._page_size - 1) // self._page_size)

    def set_page(self, page):
        page = max(0, min(page, self.total_pages - 1))
        self._current_page = page
        self.invalidateFilter()

    def filterAcceptsRow(self, source_row, source_parent):
        start = self._current_page * self._page_size
        end = start + self._page_size
        return start <= source_row < end


class EditSaveDelegate(QStyledItemDelegate):
    def __init__(self, parent=None):
        super().__init__(parent)

    def paint(self, painter, option, index):
        if index.column() == index.model().columnCount() - 1:
            button_option = QStyleOptionButton()
            button_option.rect = option.rect.adjusted(4, 4, -4, -4)
            button_option.state = QStyle.StateFlag.State_Enabled

            view = self.parent()
            source_row = self._get_source_row(index)
            if source_row in view.editing_rows:
                button_option.text = "Save"
            else:
                button_option.text = "Edit"

            QApplication.style().drawControl(
                QStyle.ControlElement.CE_PushButton, button_option, painter
            )
        else:
            super().paint(painter, option, index)

    def createEditor(self, parent, option, index):
        """Create a line edit for editable cells."""
        if index.column() < index.model().columnCount() - 1:
            source_row = self._get_source_row(index)
            view = self.parent()
            if source_row in view.editing_rows:
                editor = QLineEdit(parent)
                return editor
        return None

    def setEditorData(self, editor, index):
        value = index.data(Qt.ItemDataRole.DisplayRole)
        if isinstance(editor, QLineEdit) and value is not None:
            editor.setText(str(value))

    def setModelData(self, editor, model, index):
        if isinstance(editor, QLineEdit):
            model.setData(index, editor.text(), Qt.ItemDataRole.EditRole)

    def editorEvent(self, event, model, option, index):
        if index.column() == model.columnCount() - 1:
            if event.type() == QEvent.Type.MouseButtonRelease:
                button_rect = option.rect.adjusted(4, 4, -4, -4)
                pos = event.position().toPoint()
                if button_rect.contains(pos):
                    view = self.parent()
                    source_row = self._get_source_row(index)
                    view.toggle_edit_row(source_row)
                    return True
        return super().editorEvent(event, model, option, index)

    def _get_source_row(self, index):
        model = index.model()
        if hasattr(model, "mapToSource"):
            source_index = model.mapToSource(index)
            return source_index.row()
        return index.row()


class PaginatedTableView(QTableView):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.editing_rows = set()

    def toggle_edit_row(self, source_row):
        if source_row in self.editing_rows:
            # Save mode: close any open editors and commit data
            self.editing_rows.discard(source_row)
            self._close_editors_for_source_row(source_row)
            print(f"Row {source_row} saved.")
        else:
            # Enter edit mode: open editors for this row
            self.editing_rows.add(source_row)
            self._open_editors_for_source_row(source_row)

        # Refresh the view to update button text
        self.viewport().update()

    def _open_editors_for_source_row(self, source_row):
        proxy_model = self.model()
        col_count = proxy_model.columnCount() - 1  # Exclude button column
        for row in range(proxy_model.rowCount()):
            proxy_index = proxy_model.index(row, 0)
            sr = proxy_model.mapToSource(proxy_index).row()
            if sr == source_row:
                for col in range(col_count):
                    idx = proxy_model.index(row, col)
                    self.openPersistentEditor(idx)
                break

    def _close_editors_for_source_row(self, source_row):
        proxy_model = self.model()
        col_count = proxy_model.columnCount() - 1
        for row in range(proxy_model.rowCount()):
            proxy_index = proxy_model.index(row, 0)
            sr = proxy_model.mapToSource(proxy_index).row()
            if sr == source_row:
                for col in range(col_count):
                    idx = proxy_model.index(row, col)
                    self.closePersistentEditor(idx)
                break


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Paginated Table with Edit/Save")
        self.setMinimumSize(700, 500)

        # Generate sample data: 55 rows
        headers = ["ID", "Name", "Email", "Status"]
        data = []
        for i in range(55):
            data.append([
                str(i + 1),
                f"User {i + 1}",
                f"user{i + 1}@example.com",
                "Active" if i % 3 != 0 else "Inactive",
            ])

        # Create the source model
        self.source_model = PaginatedTableModel(data, headers)

        # Create the pagination proxy
        self.page_size = 10
        self.proxy_model = PaginationProxyModel(page_size=self.page_size)
        self.proxy_model.setSourceModel(self.source_model)

        # Create the table view
        self.table_view = PaginatedTableView()
        self.table_view.setModel(self.proxy_model)

        # Set the delegate
        delegate = EditSaveDelegate(self.table_view)
        self.table_view.setItemDelegate(delegate)

        # Stretch columns to fill space
        header = self.table_view.horizontalHeader()
        from PyQt6.QtWidgets import QHeaderView
        header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)

        # Navigation controls
        self.prev_button = QPushButton("← Previous")
        self.next_button = QPushButton("Next →")
        self.page_label = QLabel()

        self.prev_button.clicked.connect(self.go_prev)
        self.next_button.clicked.connect(self.go_next)

        nav_layout = QHBoxLayout()
        nav_layout.addWidget(self.prev_button)
        nav_layout.addStretch()
        nav_layout.addWidget(self.page_label)
        nav_layout.addStretch()
        nav_layout.addWidget(self.next_button)

        # Main layout
        layout = QVBoxLayout()
        layout.addWidget(self.table_view)
        layout.addLayout(nav_layout)

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

        self.update_page_label()

    def go_prev(self):
        current = self.proxy_model.current_page
        if current > 0:
            self.proxy_model.set_page(current - 1)
            self.update_page_label()

    def go_next(self):
        current = self.proxy_model.current_page
        if current < self.proxy_model.total_pages - 1:
            self.proxy_model.set_page(current + 1)
            self.update_page_label()

    def update_page_label(self):
        current = self.proxy_model.current_page + 1
        total = self.proxy_model.total_pages
        self.page_label.setText(f"Page {current} of {total}")
        self.prev_button.setEnabled(current > 1)
        self.next_button.setEnabled(current < total)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())

When you run this, you'll see a table showing 10 rows at a time with "Previous" and "Next" buttons at the bottom. Each row has an "Edit" button in the last column.

How It Works

Pagination is handled entirely by the PaginationProxyModel. It sits between your data model and the QTableView, filtering rows based on the current page number. When the user clicks "Next" or "Previous", we call set_page() on the proxy, which invalidates the filter and causes the view to refresh with the new set of rows.

Edit/Save per row works through a combination of the custom delegate (EditSaveDelegate) and a custom table view (PaginatedTableView). The view keeps track of which source rows are in edit mode using a simple set. When you click "Edit", the delegate detects the click and tells the view to open persistent editors on every cell in that row. The button text changes to "Save". When you click "Save", the editors are closed and the data is committed back to the model.

The print() statement in toggle_edit_row is a placeholder — in a real application, you'd replace that with your actual save logic (writing to a database, sending an API request, etc.).

For more on working with table views and data models — including displaying data from NumPy arrays and Pandas DataFrames — see the QTableView with NumPy and Pandas tutorial.

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

Adding Pagination to QTableView in PyQt6 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.