Build a Desktop App with PySide6 #5: Model/View — Connecting Data to Lists and Tables
So far the todo list has pushed strings straight into QListWidget with addItem. That approach is easy to start with, but you soon hit a wall. The moment you want each todo to carry a due date and a completion flag, the fact that the data is trapped inside the widget becomes the problem. Showing the same data on another screen means copying it in twice, and sorting or searching means digging through widget items by hand. Qt solves this with the separation of model and view.
It runs in seven parts.
- #1 What PySide6 is — desktop apps with Qt and Python
- #2 Widgets and layouts — assembling the screen
- #3 Signals and slots — the core of event handling
- #4 Qt Designer and UI files — draw the screen, then load it
- #5 Model/View — connecting data to lists and tables ← this post
- #6 Threads and timers — keeping the UI responsive
- #7 Packaging and distribution — building executables with PyInstaller
In this post we build the model/view mental model, implement a todo table model by subclassing QAbstractTableModel, and then attach sorting and a search filter through a proxy model.
The model/view architecture — separating data from display #
Qt’s model/view is the Qt variant of the well-known MVC pattern. The roles split along two axes.
- The model — holds the data and answers questions like “how many rows and columns?” and “what is the value of this cell?”. It knows nothing about the screen.
- The view — asks the model questions and paints the answers. It owns no data.
The payoff of this separation is clear. Connect the same model to a list view and a table view at once and there is still a single copy of the data, and when the model’s data changes every connected view refreshes together. Sorting and filtering can also happen in an intermediate layer without touching the original data.
Item widgets whose names end in Widget, like QListWidget, are actually convenience types that glue a model and a view into one body. They are easy to start with, but the data ends up trapped in the widget, so moving to model/view once your data grows structure is the standard path in Qt.
The smallest model/view — QStringListModel #
Let us start with a ready-made model that works without subclassing. For a list of strings there is QStringListModel.
from PySide6.QtCore import QStringListModel
from PySide6.QtWidgets import QListView
model = QStringListModel(["Buy milk", "Read the PySide6 post"])
view = QListView()
view.setModel(model)The screen looks the same as QListWidget, but the structure differs. The model holds the data, and the view was merely connected with setModel. Change the data with model.setStringList and the view follows immediately. A todo needs a due date and a completion flag besides its title, though, so a string list is not enough. Time to build a multi-column table model ourselves.
Subclassing QAbstractTableModel — the todo table model #
For a tabular custom model, subclass QAbstractTableModel and answer at least three methods: how many rows (rowCount), how many columns (columnCount), and what each cell holds (data).
from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex
class TodoModel(QAbstractTableModel):
HEADERS = ["Title", "Due", "Status"]
def __init__(self, todos=None):
super().__init__()
self._todos = todos or []
def rowCount(self, parent=QModelIndex()):
return len(self._todos)
def columnCount(self, parent=QModelIndex()):
return len(self.HEADERS)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
todo = self._todos[index.row()]
if role == Qt.ItemDataRole.DisplayRole:
if index.column() == 0:
return todo["title"]
if index.column() == 1:
return todo["due"]
if index.column() == 2:
return "Done" if todo["done"] else "In progress"
return None
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.HEADERS[section]
return NoneThe second argument to data, the role, is the gateway to understanding model/view. When the view paints a single cell it asks several times: what text to display (DisplayRole), what background color (BackgroundRole), how to align the text (TextAlignmentRole). The model only needs to return values for the roles it wants to answer. The implementation above answers only the display text and returns None for the rest, leaving the view’s default behavior in charge.
Connecting a view works the same as with the list.
from PySide6.QtWidgets import QTableView
model = TodoModel([
{"title": "Buy milk", "due": "2026-08-10", "done": False},
{"title": "Send the draft", "due": "2026-08-12", "done": True},
])
view = QTableView()
view.setModel(model)Change notification — the model calls the view #
The most common mistake in a custom model is editing the internal list and stopping there. Run only self._todos.append(...) and the data grows but the view never hears about it. The model must call designated notification methods around each change; that is what refreshes every connected view.
def add_todo(self, title, due):
row = len(self._todos)
self.beginInsertRows(QModelIndex(), row, row)
self._todos.append({"title": title, "due": due, "done": False})
self.endInsertRows()
def toggle_done(self, row):
self._todos[row]["done"] = not self._todos[row]["done"]
index = self.index(row, 2)
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole])Wrap row insertion in beginInsertRows and endInsertRows, and when the value of an existing cell changes, announce the changed range with the dataChanged signal. The rule reduces to one sentence: code that modifies the data must live inside a model method, and that method is responsible for the notification. Once outside code starts touching _todos directly, you get bugs where the screen and the data drift apart.
Sorting and search — QSortFilterProxyModel #
You do not need to modify the model for sorting and filtering. Qt provides an intermediate model that slots between the model and the view.
from PySide6.QtCore import QSortFilterProxyModel
from PySide6.QtWidgets import QLineEdit
proxy = QSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
proxy.setFilterKeyColumn(0) # filter on the title column
view.setModel(proxy) # connect the proxy to the view
view.setSortingEnabled(True) # sort by clicking the header
search = QLineEdit(placeholderText="Search")
search.textChanged.connect(proxy.setFilterFixedString)The structure is the point. What the view sees is not the source model but the proxy, and the proxy shows the view only the sorted or filtered result of the source. The order of the original data never changes. Because the search box’s textChanged signal is wired straight into the proxy’s setFilterFixedString slot, the table filters live as you type. When the signal-slot connections from #3 meet a ready-made slot, the result is a single line of code like this.
Selection handling — which row was picked #
The gateway to finding out which row the user picked is the view’s selectionModel.
view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
def on_row_changed(current, previous):
source_index = proxy.mapToSource(current) # proxy → source coordinates
print("Selected row:", source_index.row())
view.selectionModel().currentRowChanged.connect(on_row_changed)One caution applies. Since the view is connected to the proxy, selection indexes are also in proxy coordinates. With sorting or filtering active, the third row on screen is not the third row of the source. To modify the original data you must first convert the coordinates with mapToSource. The most common bug in apps that use a proxy is skipping this conversion.
setData and flags methods on the model. That goes beyond this post, but the same principle applies — “the view asks, the model answers” — and edit requests are likewise delegated to the model, so extending it later is straightforward.Wrapping up #
Four key points from this post.
- The model owns the data and answers questions; the view paints. Widget-type convenience widgets glue the two together, so move to model/view once your data grows structure.
- A custom table model subclasses
QAbstractTableModeland answersrowCount,columnCount, anddata, responding only to the roles it cares about. - Data changes must happen inside model methods together with notification (
beginInsertRows,dataChanged) for views to follow. - Solve sorting and search by inserting a
QSortFilterProxyModel, and convert selection coordinates back to the source withmapToSource.
As the table accumulates data, time-consuming work like saving files or syncing over the network follows. Run that work directly inside a button click handler and the whole window freezes. The next post, “Build a Desktop App with PySide6 #6: Threads and Timers — Keeping the UI Responsive”, tackles exactly that problem.