<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title>Python GUIs - pagination</title><link href="https://www.pythonguis.com/" rel="alternate"/><link href="https://www.pythonguis.com/feeds/pagination.tag.atom.xml" rel="self"/><id>https://www.pythonguis.com/</id><updated>2021-05-28T09:00:00+00:00</updated><subtitle>Create GUI applications with Python and Qt</subtitle><entry><title>Adding Pagination to QTableView in PyQt6 — How to display large datasets page by page with navigation controls</title><link href="https://www.pythonguis.com/faq/tableview-with-pagination-and-edit-save-button-every-row/" rel="alternate"/><published>2021-05-28T09:00:00+00:00</published><updated>2021-05-28T09:00:00+00:00</updated><author><name>Martin Fitzpatrick</name></author><id>tag:www.pythonguis.com,2021-05-28:/faq/tableview-with-pagination-and-edit-save-button-every-row/</id><summary type="html">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?</summary><content type="html">
            &lt;blockquote&gt;
&lt;p&gt;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?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id="the-approach"&gt;The Approach&lt;/h2&gt;
&lt;p&gt;Qt's &lt;a href="https://www.pythonguis.com/tutorials/pyqt6-modelview-architecture/"&gt;model/view architecture&lt;/a&gt; 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 &lt;strong&gt;proxy model&lt;/strong&gt; that filters which rows are visible based on the current page. This means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The underlying data model holds everything.&lt;/li&gt;
&lt;li&gt;A proxy model sits between the data model and the view, only exposing rows for the current page.&lt;/li&gt;
&lt;li&gt;Navigation buttons let the user move between pages.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Let's start by building each piece.&lt;/p&gt;
&lt;h2 id="a-simple-data-model"&gt;A Simple Data Model&lt;/h2&gt;
&lt;p&gt;First, let's create a basic table model that holds our data. We'll use &lt;code&gt;QAbstractTableModel&lt;/code&gt; and populate it with some sample data.&lt;/p&gt;
&lt;div class="code-block"&gt;
&lt;span class="code-block-language code-block-python"&gt;python&lt;/span&gt;
&lt;pre&gt;&lt;code class="python"&gt;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
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;Notice that &lt;code&gt;flags()&lt;/code&gt; does &lt;em&gt;not&lt;/em&gt; include &lt;code&gt;ItemIsEditable&lt;/code&gt;. We don't want the cells to be directly editable by double-clicking &amp;mdash; instead, we'll control editing through per-row buttons.&lt;/p&gt;
&lt;h2 id="the-pagination-proxy-model"&gt;The Pagination Proxy Model&lt;/h2&gt;
&lt;p&gt;Now we need a proxy model that only shows a slice of the data &amp;mdash; one "page" at a time. We'll subclass &lt;code&gt;QSortFilterProxyModel&lt;/code&gt; and override &lt;code&gt;filterAcceptsRow&lt;/code&gt; to accept only rows that fall within the current page range. If you're new to sort and filter proxy models, see &lt;a href="https://www.pythonguis.com/tutorials/pyqt6-modelview-sort-filter-tables/"&gt;how to sort and filter tables in PyQt6&lt;/a&gt; for a detailed introduction.&lt;/p&gt;
&lt;div class="code-block"&gt;
&lt;span class="code-block-language code-block-python"&gt;python&lt;/span&gt;
&lt;pre&gt;&lt;code class="python"&gt;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 &amp;lt;= source_row &amp;lt; end
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;When we call &lt;code&gt;set_page()&lt;/code&gt;, the proxy model invalidates its filter, which causes the view to refresh and show only the rows for the requested page.&lt;/p&gt;
&lt;h2 id="adding-per-row-edit-and-save-buttons"&gt;Adding Per-Row Edit and Save Buttons&lt;/h2&gt;
&lt;p&gt;To put an "Edit" or "Save" button in each row, we'll use a &lt;strong&gt;delegate&lt;/strong&gt;. 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.&lt;/p&gt;
&lt;p&gt;The idea is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Each row starts in "view" mode with an "Edit" button.&lt;/li&gt;
&lt;li&gt;Clicking "Edit" makes that row's cells editable and changes the button to "Save".&lt;/li&gt;
&lt;li&gt;Clicking "Save" commits the changes and returns to view mode.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We'll track which rows are being edited using a set, and use a custom delegate to paint the buttons and handle clicks.&lt;/p&gt;
&lt;div class="code-block"&gt;
&lt;span class="code-block-language code-block-python"&gt;python&lt;/span&gt;
&lt;pre&gt;&lt;code class="python"&gt;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()
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;The delegate draws a button using Qt's style system, and intercepts mouse clicks to toggle the edit state.&lt;/p&gt;
&lt;h2 id="putting-it-all-together"&gt;Putting It All Together&lt;/h2&gt;
&lt;p&gt;Now let's build the main window that combines the model, proxy, delegate, and navigation controls. We're using &lt;a href="https://www.pythonguis.com/tutorials/pyqt6-layouts/"&gt;layouts&lt;/a&gt; to arrange the table and navigation buttons within the window.&lt;/p&gt;
&lt;div class="code-block"&gt;
&lt;span class="code-block-language code-block-python"&gt;python&lt;/span&gt;
&lt;pre&gt;&lt;code class="python"&gt;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() &amp;lt; 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 &amp;lt; 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 &amp;lt;= source_row &amp;lt; 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() &amp;lt; 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("&amp;larr; Previous")
        self.next_button = QPushButton("Next &amp;rarr;")
        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 &amp;gt; 0:
            self.proxy_model.set_page(current - 1)
            self.update_page_label()

    def go_next(self):
        current = self.proxy_model.current_page
        if current &amp;lt; 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 &amp;gt; 1)
        self.next_button.setEnabled(current &amp;lt; total)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id="how-it-works"&gt;How It Works&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Pagination&lt;/strong&gt; is handled entirely by the &lt;code&gt;PaginationProxyModel&lt;/code&gt;. It sits between your data model and the &lt;code&gt;QTableView&lt;/code&gt;, filtering rows based on the current page number. When the user clicks "Next" or "Previous", we call &lt;code&gt;set_page()&lt;/code&gt; on the proxy, which invalidates the filter and causes the view to refresh with the new set of rows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Edit/Save per row&lt;/strong&gt; works through a combination of the custom delegate (&lt;code&gt;EditSaveDelegate&lt;/code&gt;) and a custom table view (&lt;code&gt;PaginatedTableView&lt;/code&gt;). 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.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;print()&lt;/code&gt; statement in &lt;code&gt;toggle_edit_row&lt;/code&gt; is a placeholder &amp;mdash; in a real application, you'd replace that with your actual save logic (writing to a database, sending an API request, etc.).&lt;/p&gt;
&lt;p&gt;For more on working with table views and data models &amp;mdash; including displaying data from NumPy arrays and Pandas DataFrames &amp;mdash; see the &lt;a href="https://www.pythonguis.com/tutorials/pyqt6-qtableview-modelviews-numpy-pandas/"&gt;QTableView with NumPy and Pandas&lt;/a&gt; tutorial.&lt;/p&gt;
            &lt;p&gt;For an in-depth guide to building Python GUIs with PyQt6 see my book, &lt;a href="https://www.mfitzp.com/pyqt6-book/"&gt;Create GUI Applications with Python &amp; Qt6.&lt;/a&gt;&lt;/p&gt;
            </content><category term="pyqt6"/><category term="qtableview"/><category term="model-view"/><category term="pagination"/><category term="python"/><category term="qt"/><category term="qt6"/></entry></feed>