Filter and Sort a Large Qt Table Without Rebuilding It: QSortFilterProxyModel in Practice
Rebuilding a Qt model on every keystroke drops selection and stutters. Put QSortFilterProxyModel between model and view instead — setup, signal contract, and trade-offs.
06 Oct 2026, 04:52 UTC

You have a QAbstractTableModel — Qt's base class for table-shaped data — holding a few thousand rows, and a search box that should narrow the table as the user types. The tempting fix is to clear the model and refill it with matching rows on every keystroke. It works, and it feels awful: selection vanishes, the scroll bar jumps to the top, and each keystroke pays for re-emitting the entire table.
The better shape: leave the source model alone and put a QSortFilterProxyModel between it and the view. The proxy presents a filtered, sorted view of the same rows; the source model stays the single source of truth and its rows keep their identity, so selection and scroll position survive filtering. Here is the setup, the contract your model must honor, and where the proxy stops being the right tool.
What the proxy actually does
Qt's model/view design separates data (the model) from display (the view). A proxy model is itself a model that wraps another one: the view talks to the proxy, and the proxy decides which source rows to expose and in what order. QSortFilterProxyModel re-evaluates filterAcceptsRow() for each row and orders the survivors with lessThan(), which compares items using the role set by setSortRole().
Two details matter for responsiveness. invalidateFilter() forces a full re-evaluation and rebuilds the proxy's row mapping — the sledgehammer. invalidateRowsFilter() (added in Qt 5.10; confirm against your version's docs) re-runs only row filtering. Changing the filter via setFilterFixedString() triggers re-evaluation on its own, so you rarely invalidate manually.
The classic bug is indices. A QModelIndex is a temporary handle to a cell — row, column, and the model it belongs to. Indices from the view or a selection model belong to the proxy. Call mapToSource() before touching source data and mapFromSource() to point the view back at a source row.
A worked example: a file table that sorts sizes numerically
Scenario: a table of files with name and size columns, backed by a custom model. Two things go wrong in the naive version: typing resets the view, and clicking the Size header sorts 1000 before 999, because by default the proxy compares displayed strings lexically. This wiring goes in your main window constructor, after the widgets exist:
proxy_ = new QSortFilterProxyModel(this);
proxy_->setSourceModel(model_); // your QAbstractTableModel subclass
proxy_->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxy_->setFilterKeyColumn(Column::Name); // however you enumerate columns
proxy_->setSortRole(Qt::UserRole);
connect(filterEdit_, &QLineEdit::textChanged,
proxy_, &QSortFilterProxyModel::setFilterFixedString);
tableView_->setModel(proxy_);
tableView_->setSortingEnabled(true); // header clicks now sortThe sort fix: store the raw byte count in Qt::UserRole — the first role reserved for application data — keep display strings in Qt::DisplayRole, and compare the numeric role for that column:
bool FileProxy::lessThan(const QModelIndex &left,
const QModelIndex &right) const
{
if (left.column() == Column::Size) {
const qint64 l = left.data(Qt::UserRole).toLongLong();
const qint64 r = right.data(Qt::UserRole).toLongLong();
return l < r;
}
return QSortFilterProxyModel::lessThan(left, right);
}When the user picks a row, remember the selection model hands you proxy indices:
const QModelIndex proxyIdx = tableView_->selectionModel()->currentIndex();
const QModelIndex srcIdx = proxy_->mapToSource(proxyIdx);
if (srcIdx.isValid())
showDetails(model_->fileAt(srcIdx.row()));What to expect: typing filters rows without the scroll position jumping, still-matching rows keep their selection, and the Size header orders 999 before 1000. If any of that fails, suspect index mapping or the contract below. On large models, route textChanged through a single-shot QTimer of a couple hundred milliseconds so intermediate keystrokes do not each trigger a full filter pass.
The contract your source model must honor
The proxy mirrors the source model, so it stays correct only if the source emits the right signals: beginInsertRows()/endInsertRows() and beginRemoveRows()/endRemoveRows() around structural changes, dataChanged() with the roles that actually changed, and layoutChanged() when row order changes wholesale. Skip these and the proxy's mapping desynchronizes — symptoms look like blank rows, stale cells, or view crashes unrelated to your change.
Do not hunt these by hand. Attach QAbstractItemModelTester from the Qt Test module to your model in a unit test (present in recent Qt 5 and Qt 6 releases; confirm for yours). It mutates the model and flags contract violations automatically. It is a test tool — do not ship it in production code.
When the proxy stops paying off
Every proxy layer adds per-row filter evaluation and index translation, and chained proxies multiply that cost. Two situations argue for filtering elsewhere:
- Data too big for memory. If rows live in SQLite or behind a service, push the filter into the query — a WHERE clause returns only matching rows, and the model never holds the rest.
- Profiling says the filter dominates. Wrap
setFilterFixedString()inQElapsedTimermeasurements on your own data and row counts; if a keystroke's filter time is visible, debounce the input or move filtering into the source model.
One hard limit: QSortFilterProxyModel is not designed for a source model mutated from another thread. Keep mutations on the thread that owns the model — normally the GUI thread — or marshal changes into it explicitly.
Rule of thumb: interactive filtering over in-memory data in the thousands of rows is exactly what the proxy is for. Once data is remote, huge, or expensive to test per row, filter in SQL or in the source model — the signal contract stays the same; you just drop the proxy.
Check your result in three steps
- Wire the proxy as shown and confirm typing no longer resets scroll position, and that a selected row which still matches stays selected.
- Run the source model under
QAbstractItemModelTesterin a unit test before trusting it in the UI. - Time filter changes with
QElapsedTimerat realistic row counts on your hardware; if the numbers bother you, debounce first, then consider moving the filter down a layer.
The class names here are stable across Qt 5.15 and Qt 6, but details shift between versions — verify every call against the documentation for the Qt you actually ship.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.