Paging Large Qt Models Through QSortFilterProxyModel Without Losing Your Place
Paginating a filtered Qt model breaks when you cache source indices. Page in proxy coordinates, clamp on structural signals, and decide where filtering lives.
01 Jul 2025, 20:25 UTC

You have 50,000 rows in a database-backed model, and the view stutters every time someone types in the filter box. The usual advice is "just use QSortFilterProxyModel," which handles filtering and sorting fine — but the moment you add pagination on top, page two shows duplicates, page three skips rows, and a model reset silently throws the user back to page one. The fix is understanding what the proxy actually guarantees, and building paging logic that respects it.
The thesis: paginate against the proxy's row indices, not the source model's, and re-derive the current page whenever the filter or source changes instead of caching source indices.
What the proxy actually gives you
QSortFilterProxyModel sits between your source model and the view. It re-implements rowCount(), index(), and data() so the view only ever sees the filtered, sorted subset. The source model is untouched — which is exactly why pagination belongs at the proxy layer. If you page by source rows, a filter that hides half the data will produce pages full of invisible rows.
The proxy also forwards the source's signals (modelReset, rowsInserted, and so on), remapped to proxy coordinates. That forwarding is your hook for keeping page state honest.
A minimal paging wrapper
Instead of subclassing, a small controller object works well. It owns the proxy, exposes nextPage()/prevPage(), and clamps the visible window:
class PagedProxy : public QObject
{
Q_OBJECT
public:
PagedProxy(QAbstractItemModel *source, int pageSize, QObject *parent = nullptr)
: QObject(parent), m_pageSize(pageSize)
{
m_proxy.setSourceModel(source);
m_proxy.setFilterCaseSensitivity(Qt::CaseInsensitive);
// Any structural change can invalidate the current page.
connect(&m_proxy, &QAbstractItemModel::modelReset, this, [this] {
m_page = qMin(m_page, pageCount() - 1);
emit pageChanged(m_page);
});
connect(&m_proxy, &QAbstractItemModel::rowsInserted, this, [this] {
emit pageCountChanged(pageCount());
});
}
int pageCount() const {
return qMax(1, (m_proxy.rowCount() + m_pageSize - 1) / m_pageSize);
}
// The view shows a slice: rows [page*size, (page+1)*size) of the proxy.
QModelIndex viewIndex(int rowOnPage, int column) const {
return m_proxy.index(m_page * m_pageSize + rowOnPage, column);
}
private:
QSortFilterProxyModel m_proxy;
int m_page = 0;
int m_pageSize;
};Note what this does not do: it never stores source-model indices or QPersistentModelIndex across filter changes. After setFilterRegularExpression() or invalidateFilter(), the proxy's row ordering is recomputed, and any cached mapping is garbage.
Lazy loading with fetchMore
If the source model itself is lazy (it implements canFetchMore()/fetchMore() to pull batches from a database), the proxy forwards those calls too. A pragmatic pattern is to trigger fetching when the user approaches the last loaded page:
void PagedProxy::goToPage(int page)
{
int target = (page + 1) * m_pageSize;
while (m_proxy.rowCount() < target && m_proxy.canFetchMore())
m_proxy.fetchMore(QModelIndex());
m_page = qMin(page, pageCount() - 1);
emit pageChanged(m_page);
}Run this in your GUI thread — Qt models are not thread-safe, and both the proxy and view assume the main thread. The while loop is safe because fetchMore() on a well-behaved source model is synchronous and bounded; if your source fetches asynchronously, gate the page change on a signal instead.
The trade-off: filtering fights laziness
Here is the uncomfortable part. A filter can only match rows the source has already loaded. If your source model lazily loads 500 of 50,000 rows, a filter regex evaluates against those 500 — the other 49,500 are invisible until fetched. You have three realistic options:
- Filter in the query, not the proxy. Push the search term into the SQL
WHEREclause and reset the source model. The proxy then only handles sorting. This scales, but costs a round trip per keystroke (debounce it). - Load everything, filter in the proxy. Simple and correct, but memory-bound; fine up to tens of thousands of small rows.
- Hybrid: proxy-filter for instant feedback on loaded rows, with an explicit "search all" action that re-queries the source.
The second caveat from the field: invalidateFilter() re-evaluates every row, and on large fully-loaded models with a complex regex this is noticeably expensive. Prefer setFilterRegularExpression() (which invalidates only when the pattern actually changes) and debounce text input to 200–300 ms.
Version and verification notes
Proxy internals — caching and invalidation granularity — have shifted between Qt 5 and Qt 6, and the exact behavior of fetchMore() through the proxy is worth confirming against your specific Qt version rather than assuming parity. A quick self-check: populate a QStandardItemModel with 1,000 numbered rows, set a filter matching roughly every tenth row, page to the last page, then delete rows in the source. Verify the page count shrinks, the current page clamps instead of going blank, and no row number appears twice across adjacent pages. If any of those fail, your paging logic is caching something it shouldn't.
The actionable takeaway: page in proxy coordinates, clamp on every structural signal, and decide early whether filtering lives in the proxy or the query — because that decision, not the paging math, is what determines whether your view stays fast at 50,000 rows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.