Sorting and Filtering a QTableView with QSortFilterProxyModel

Learn how to add interactive sorting and filtering to your PyQt/PySide table views without touching your underlying data
Heads up! You've already completed this tutorial.

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.

python
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:

python
from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel

Then update the MainWindow class:

python
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:

  1. Create the QSortFilterProxyModel.
  2. Tell it which source model to wrap with setSourceModel().
  3. 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.

Sortable QTableView with proxy model — clicking headers sorts the data.

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.

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

Filtering the table by typing in the search box — only matching rows are shown.

Let's look at what's happening:

  • setFilterKeyColumn(2) tells the proxy to check column 2 ("City") when deciding which rows to show.
  • setFilterFixedString performs a plain substring match. If the filter string appears anywhere in the cell value, the row is shown.
  • We connected the textChanged signal from the QLineEdit directly to setFilterFixedString on 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:

python
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:

python
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:

python
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:

python
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:

python
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:

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

python
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:

python
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:

python
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:

python
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:

python
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()

Complete example with sorting and dual filters — city text search and minimum age.

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:

  • QSortFilterProxyModel sits 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() and setFilterFixedString() for quick text filtering. Set the column to -1 to search all columns.
  • Always use mapToSource() when you need to convert a proxy index back to the source model's coordinate system.
  • Emit layoutAboutToBeChanged and layoutChanged when replacing data in the source model to avoid crashes.
  • Subclass QSortFilterProxyModel and override lessThan() for custom sorting or filterAcceptsRow() 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.

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak

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!

More info Get the book

Martin Fitzpatrick

Sorting and Filtering a QTableView with QSortFilterProxyModel 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.