If you've already built a QTableView with a custom model, you might be wondering how to let users sort columns by clicking headers or filter rows based on search input. The good news is that Qt provides a ready-made tool for this: QSortFilterProxyModel. It sits between your model and your view, rearranging and filtering the data without modifying the original source. Your data stays untouched — the proxy just changes how it's presented.
In this tutorial, we'll start with a simple table model and progressively add sorting, filtering, and then tackle some of the common pitfalls — like working with proxy indexes correctly and avoiding crashes when updating data.
The starting point: a simple table model
Let's begin with a basic QTableView displaying a list-of-lists. This is the same pattern used in the model/view architecture tutorial.
import sys
from PyQt6.QtCore import Qt, QAbstractTableModel
from PyQt6.QtWidgets import QApplication, QMainWindow, QTableView
class TableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data = data
self._headers = ["Name", "Age", "City"]
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
return self._data[index.row()][index.column()]
def rowCount(self, index):
return len(self._data)
def columnCount(self, index):
return len(self._data[0])
def headerData(self, section, orientation, role):
if role == Qt.ItemDataRole.DisplayRole:
if orientation == Qt.Orientation.Horizontal:
return self._headers[section]
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.table = QTableView()
data = [
["Alice", 25, "New York"],
["Bob", 30, "Denver"],
["Charlie", 35, "Austin"],
["Diana", 28, "Denver"],
["Eve", 22, "Austin"],
]
self.model = TableModel(data)
self.table.setModel(self.model)
self.setCentralWidget(self.table)
self.setWindowTitle("QTableView — No Sorting Yet")
self.resize(500, 300)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
Run this and you'll see a plain table. Clicking the column headers does nothing — sorting is off by default. Let's change that.
Adding sorting with QSortFilterProxyModel
To add sorting, we insert a QSortFilterProxyModel between our TableModel and the QTableView. The proxy model wraps the source model and provides sorted (and later, filtered) access to the same data.
Here's what changes in the MainWindow.__init__ method:
from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel
Then update the MainWindow class:
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.table = QTableView()
data = [
["Alice", 25, "New York"],
["Bob", 30, "Denver"],
["Charlie", 35, "Austin"],
["Diana", 28, "Denver"],
["Eve", 22, "Austin"],
]
self.model = TableModel(data)
self.proxy_model = QSortFilterProxyModel()
self.proxy_model.setSourceModel(self.model)
self.table.setModel(self.proxy_model)
self.table.setSortingEnabled(True)
self.setCentralWidget(self.table)
self.setWindowTitle("QTableView — Sortable!")
self.resize(500, 300)
We've added the following steps:
- Create the
QSortFilterProxyModel. - Tell it which source model to wrap with
setSourceModel(). - Give the proxy model to the view (not the source model), and call
setSortingEnabled(True)on the view.
Now when you click a column header, the rows reorder. Click again to reverse the sort direction. The little arrow indicator on the header shows you which column is currently sorted and in which direction.

Notice that the underlying data list hasn't changed at all. The proxy model handles everything by remapping indexes.
Adding filtering
Filtering works through the same proxy model. You tell the proxy which column to look at and what pattern to match, and it hides rows that don't match.
Let's add a QLineEdit that filters rows as you type. We'll filter on the "City" column (column index 2). If you're interested in a more complete search bar implementation, see the widget search bar tutorial.
import sys
from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QTableView,
QVBoxLayout, QWidget, QLineEdit, QLabel,
)
class TableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data = data
self._headers = ["Name", "Age", "City"]
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
return self._data[index.row()][index.column()]
def rowCount(self, index):
return len(self._data)
def columnCount(self, index):
return len(self._data[0])
def headerData(self, section, orientation, role):
if role == Qt.ItemDataRole.DisplayRole:
if orientation == Qt.Orientation.Horizontal:
return self._headers[section]
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
data = [
["Alice", 25, "New York"],
["Bob", 30, "Denver"],
["Charlie", 35, "Austin"],
["Diana", 28, "Denver"],
["Eve", 22, "Austin"],
]
self.model = TableModel(data)
self.proxy_model = QSortFilterProxyModel()
self.proxy_model.setSourceModel(self.model)
self.proxy_model.setFilterCaseSensitivity(
Qt.CaseSensitivity.CaseInsensitive
)
self.proxy_model.setFilterKeyColumn(2) # Filter on "City" column
self.table = QTableView()
self.table.setModel(self.proxy_model)
self.table.setSortingEnabled(True)
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Filter by city...")
self.search_input.textChanged.connect(
self.proxy_model.setFilterFixedString
)
layout = QVBoxLayout()
layout.addWidget(QLabel("Search:"))
layout.addWidget(self.search_input)
layout.addWidget(self.table)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
self.setWindowTitle("QTableView — Sort & Filter")
self.resize(500, 400)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
Type "Denver" into the search box and the table instantly filters to show only the rows where the City column matches. Type "aus" and you'll see the Austin rows (because we set case-insensitive matching).

Let's look at what's happening:
setFilterKeyColumn(2)tells the proxy to check column 2 ("City") when deciding which rows to show.setFilterFixedStringperforms a plain substring match. If the filter string appears anywhere in the cell value, the row is shown.- We connected the
textChangedsignal from theQLineEditdirectly tosetFilterFixedStringon the proxy model. Every time the user types, the filter updates automatically.
Filtering across all columns
If you want to search across every column instead of just one, set the filter key column to -1:
self.proxy_model.setFilterKeyColumn(-1) # Search all columns
Now typing "25" will match Alice's row (age 25), and typing "Den" will still match the Denver rows.
Other filter modes
setFilterFixedString is the simplest option — it does a plain text substring match. The proxy model also supports more powerful matching:
- Wildcard matching using
setFilterWildcard()— supports*and?patterns, like"D*ver". - Regular expression matching using
setFilterRegularExpression()— supports full regex patterns for complex matching needs.
For most interactive search boxes, setFilterFixedString with case-insensitive matching is exactly what you need.
Working with proxy indexes correctly
When a proxy model is active, the indexes your view reports are proxy indexes, not source model indexes. If you click on a row in the filtered/sorted view, the QModelIndex you receive refers to the proxy model's row numbering, which may not match the original data.
This matters when you need to do something with the underlying data — like reading values from the source model or modifying a specific row.
Consider this slot connected to the table's clicked signal:
self.table.clicked.connect(self.cell_clicked)
def cell_clicked(self, proxy_index):
# This gives the row number in the proxy (filtered/sorted) view
print(f"Proxy row: {proxy_index.row()}, column: {proxy_index.column()}")
# To get the corresponding row in the SOURCE model:
source_index = self.proxy_model.mapToSource(proxy_index)
print(f"Source row: {source_index.row()}, column: {source_index.column()}")
The method mapToSource() translates a proxy index back to the source model's coordinate system. There's also mapFromSource() for going the other direction — converting a source index into the proxy's index, which is useful if you need to select or highlight a specific row in the view programmatically.
If you forget this step and pass proxy indexes directly to your source model, you'll end up reading or modifying the wrong row. When filtering is active, the mismatch becomes obvious because the proxy's row 0 might correspond to row 3 in the source.
Reading data through the proxy
When you want to read data from a clicked row, you have two options:
def cell_clicked(self, proxy_index):
row = proxy_index.row()
# Option 1: Read through the proxy model (uses proxy indexes)
name = self.proxy_model.data(
self.proxy_model.index(row, 0),
Qt.ItemDataRole.DisplayRole,
)
# Option 2: Map to source and read from source model
source_index = self.proxy_model.mapToSource(proxy_index)
name = self.model.data(
self.model.index(source_index.row(), 0),
Qt.ItemDataRole.DisplayRole,
)
print(f"Clicked on: {name}")
Both options give you the same result. Option 1 is often simpler since you're already working with proxy indexes from the view. Option 2 is necessary when you need to interact directly with the source model — for example, to modify data or get the "real" row position in your data structure.
Avoiding crashes when updating the source model
If you're updating the source model's data while a proxy model and view are connected, you need to properly notify Qt's model/view framework about the changes. Without these notifications, you can get segmentation faults or corrupted displays.
The pattern for updating data looks like this inside your QAbstractTableModel subclass:
def update_data(self, new_data):
self.layoutAboutToBeChanged.emit()
self._data = new_data
self.layoutChanged.emit()
The layoutAboutToBeChanged signal tells the proxy model (and the view) that the structure of the data is about to change. After you've made your changes, layoutChanged tells everything to refresh. Skipping the layoutAboutToBeChanged signal is a common cause of crashes — the proxy model needs that heads-up to properly invalidate its internal mapping of indexes.
For smaller changes, like updating a single cell, you can use dataChanged instead:
def set_value(self, row, col, value):
self._data[row][col] = value
index = self.index(row, col)
self.dataChanged.emit(index, index)
And if you're adding or removing rows, use beginInsertRows/endInsertRows or beginRemoveRows/endRemoveRows:
def add_row(self, row_data):
row_position = len(self._data)
self.beginInsertRows(self.index(row_position, 0).parent(), row_position, row_position)
self._data.append(row_data)
self.endInsertRows()
Getting these signals right is what keeps the proxy model, the view, and your data all in sync.
Custom sorting behavior
The default sorting provided by QSortFilterProxyModel uses Qt.ItemDataRole.DisplayRole and works well for simple string and number comparisons. But sometimes you need more control — for example, sorting a column of dates that are stored as strings, or sorting with a custom priority order.
To customize sorting, subclass QSortFilterProxyModel and override the lessThan method. This method receives two QModelIndex objects (from the source model) and should return True if the left value should come before the right value.
class CustomProxyModel(QSortFilterProxyModel):
def lessThan(self, left, right):
left_data = self.sourceModel().data(left, Qt.ItemDataRole.DisplayRole)
right_data = self.sourceModel().data(right, Qt.ItemDataRole.DisplayRole)
# Example: sort numerically if both values are numbers
try:
return float(left_data) < float(right_data)
except (ValueError, TypeError):
# Fall back to string comparison
return str(left_data).lower() < str(right_data).lower()
Then use CustomProxyModel instead of QSortFilterProxyModel:
self.proxy_model = CustomProxyModel()
self.proxy_model.setSourceModel(self.model)
This is useful when your table has mixed data types or when the display representation doesn't sort the way you'd expect (like date strings in "MM/DD/YYYY" format).
Custom filtering behavior
Similarly, you can customize filtering by subclassing QSortFilterProxyModel and overriding filterAcceptsRow. This method is called for every row in the source model, and it should return True if the row should be visible.
Here's an example that filters to show only rows where the "Age" column (column 1) is above a certain threshold:
class AgeFilterProxyModel(QSortFilterProxyModel):
def __init__(self):
super().__init__()
self._min_age = 0
def set_min_age(self, age):
self._min_age = age
self.invalidateFilter() # Re-apply the filter
def filterAcceptsRow(self, source_row, source_parent):
index = self.sourceModel().index(source_row, 1, source_parent)
age = self.sourceModel().data(index, Qt.ItemDataRole.DisplayRole)
try:
return int(age) >= self._min_age
except (ValueError, TypeError):
return True
After changing your filter criteria, call invalidateFilter() to tell the proxy model to re-evaluate which rows should be shown.
You can combine this with the text-based column filtering too. If you override filterAcceptsRow, you have full control — you can check multiple columns, apply complex logic, or combine several filter conditions:
def filterAcceptsRow(self, source_row, source_parent):
model = self.sourceModel()
# Check age filter
age_index = model.index(source_row, 1, source_parent)
age = model.data(age_index, Qt.ItemDataRole.DisplayRole)
if int(age) < self._min_age:
return False
# Check text filter on city column
city_index = model.index(source_row, 2, source_parent)
city = model.data(city_index, Qt.ItemDataRole.DisplayRole)
if self.filterRegularExpression().pattern():
if not self.filterRegularExpression().match(city).hasMatch():
return False
return True
Putting it all together
Here's a complete example that combines sorting, text filtering, and a numeric age filter using a custom proxy model:
import sys
from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QTableView,
QVBoxLayout, QHBoxLayout, QWidget,
QLineEdit, QLabel, QSpinBox,
)
class TableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data = data
self._headers = ["Name", "Age", "City"]
def data(self, index, role):
if role == Qt.ItemDataRole.DisplayRole:
return self._data[index.row()][index.column()]
def rowCount(self, index):
return len(self._data)
def columnCount(self, index):
return len(self._data[0])
def headerData(self, section, orientation, role):
if role == Qt.ItemDataRole.DisplayRole:
if orientation == Qt.Orientation.Horizontal:
return self._headers[section]
class CustomFilterProxyModel(QSortFilterProxyModel):
def __init__(self):
super().__init__()
self._min_age = 0
self._city_filter = ""
def set_min_age(self, age):
self._min_age = age
self.invalidateFilter()
def set_city_filter(self, text):
self._city_filter = text.lower()
self.invalidateFilter()
def filterAcceptsRow(self, source_row, source_parent):
model = self.sourceModel()
# Age filter (column 1)
age_index = model.index(source_row, 1, source_parent)
age = model.data(age_index, Qt.ItemDataRole.DisplayRole)
try:
if int(age) < self._min_age:
return False
except (ValueError, TypeError):
pass
# City text filter (column 2)
if self._city_filter:
city_index = model.index(source_row, 2, source_parent)
city = model.data(city_index, Qt.ItemDataRole.DisplayRole)
if self._city_filter not in str(city).lower():
return False
return True
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
data = [
["Alice", 25, "New York"],
["Bob", 30, "Denver"],
["Charlie", 35, "Austin"],
["Diana", 28, "Denver"],
["Eve", 22, "Austin"],
["Frank", 40, "New York"],
["Grace", 19, "Denver"],
]
self.model = TableModel(data)
self.proxy_model = CustomFilterProxyModel()
self.proxy_model.setSourceModel(self.model)
self.table = QTableView()
self.table.setModel(self.proxy_model)
self.table.setSortingEnabled(True)
self.table.clicked.connect(self.cell_clicked)
# City filter
self.city_input = QLineEdit()
self.city_input.setPlaceholderText("Filter by city...")
self.city_input.textChanged.connect(self.proxy_model.set_city_filter)
# Age filter
self.age_spin = QSpinBox()
self.age_spin.setRange(0, 100)
self.age_spin.setPrefix("Min age: ")
self.age_spin.valueChanged.connect(self.proxy_model.set_min_age)
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("City:"))
filter_layout.addWidget(self.city_input)
filter_layout.addWidget(self.age_spin)
layout = QVBoxLayout()
layout.addLayout(filter_layout)
layout.addWidget(self.table)
self.status_label = QLabel("Click a row to see details")
layout.addWidget(self.status_label)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
self.setWindowTitle("QTableView — Sort & Custom Filter")
self.resize(500, 400)
def cell_clicked(self, proxy_index):
source_index = self.proxy_model.mapToSource(proxy_index)
row = proxy_index.row()
name = self.proxy_model.data(
self.proxy_model.index(row, 0),
Qt.ItemDataRole.DisplayRole,
)
age = self.proxy_model.data(
self.proxy_model.index(row, 1),
Qt.ItemDataRole.DisplayRole,
)
city = self.proxy_model.data(
self.proxy_model.index(row, 2),
Qt.ItemDataRole.DisplayRole,
)
self.status_label.setText(
f"Selected: {name}, age {age}, from {city} "
f"(source row: {source_index.row()})"
)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

Try playing with this example:
- Click column headers to sort by name, age, or city.
- Type in the city filter to narrow down results.
- Adjust the minimum age spinner to hide younger entries.
- Click on rows and notice how the source row number differs from the visible row number.
Summary
Here's a quick recap of everything we covered:
QSortFilterProxyModelsits between your model and your view. It sorts and filters data without modifying the source.- Call
setSortingEnabled(True)on the view to let users sort by clicking column headers. - Use
setFilterKeyColumn()andsetFilterFixedString()for quick text filtering. Set the column to-1to search all columns. - Always use
mapToSource()when you need to convert a proxy index back to the source model's coordinate system. - Emit
layoutAboutToBeChangedandlayoutChangedwhen replacing data in the source model to avoid crashes. - Subclass
QSortFilterProxyModeland overridelessThan()for custom sorting orfilterAcceptsRow()for custom filtering. - Call
invalidateFilter()after changing filter criteria in a custom proxy model.
The proxy model pattern is one of the nicer parts of Qt's model/view architecture. Once you have it set up, you get flexible data presentation without ever duplicating or restructuring your underlying data. To explore model/view further, see how to display numpy and pandas data in a QTableView or learn about signals, slots, and events that power these interactions.
Create GUI Applications with Python & Qt5 by Martin Fitzpatrick
(PySide2 Edition) The hands-on guide to making apps with Python — Over 15,000 copies sold!