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.
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.
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.
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.
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.
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.