Designing a Qt Model/View UI That Survives Threads, Edits, and Growth
An architecture note for Qt model/view UIs: keep the model on the GUI thread, reach it from workers only via queued signals, and treat begin/end insert pairing as a hard invariant.
29 Jan 2026, 04:19 UTC

The problem this design solves
You have a backend service producing tabular data, and a Qt Widgets application that must display it, let users sort, filter, and edit it, and refresh automatically as data changes — all without freezing the GUI thread. The tempting shortcut is to poke a QTableWidget from wherever the data arrives. That works in a demo and corrupts state in production. The model/view framework exists precisely to enforce the separation you're about to need.
The takeaway: put all data behind a QAbstractItemModel subclass that lives entirely on the GUI thread, let worker threads reach it only through queued signals, and treat beginInsertRows/endInsertRows pairing as a hard invariant. Everything else in this note follows from those three decisions.
Requirements
- Display rows from a backend service in a table.
- Support sorting and filtering without duplicating the data.
- Allow in-place editing with validation and failure feedback.
- Update the view automatically when the backend pushes changes.
- Never block the GUI thread on I/O or long computations.
The smallest suitable design
Three components, each with one job:
- Model. A
QAbstractTableModelsubclass owns the data (aQVector<RowData>is enough to start). ImplementrowCount(),columnCount(),data(),setData(),flags(), andheaderData(). UseQStandardItemModelinstead only if you don't need custom edit logic — you give up control over storage and validation. - Proxy for sort/filter. A
QSortFilterProxyModelsits between your model and the view. OverridefilterAcceptsRow()for filtering; sorting is free. Never sort the underlying model — that breaks index stability for anything else holdingQPersistentModelIndex. - View. A
QTableViewwithsetModel(proxy). The view subscribes todataChanged,layoutChanged, and the insert/remove signals itself; you never repaint manually.
Backend data arrives on a worker thread (network reply, parser, poller). The worker emits a plain signal carrying the new row data by value; the model has a slot connected with Qt::QueuedConnection. Because the model lives on the GUI thread, the slot executes there, and all mutation happens in one thread.
// In the worker (lives on its own QThread)
emit rowUpdated(updatedRow); // RowData passed by value
// In the model's owner, connected at setup time:
connect(worker, &Worker::rowUpdated,
model, &MyTableModel::applyRowUpdate,
Qt::QueuedConnection);
// In the model — always runs on the GUI thread
void MyTableModel::applyRowUpdate(const RowData& row)
{
int r = findRow(row.id);
if (r < 0) {
int pos = m_rows.size();
beginInsertRows(QModelIndex(), pos, pos);
m_rows.append(row);
endInsertRows();
} else {
m_rows[r] = row;
emit dataChanged(index(r, 0), index(r, columnCount() - 1));
}
}Register RowData with qRegisterMetaType<RowData>("RowData") before connecting, or the queued connection fails at runtime with a console warning and silently never delivers.
Trust and data boundaries
The rule is absolute: the model is touched only by the GUI thread. Qt's item views, delegates, and the model itself are not thread-safe, and the documentation does not promise otherwise. Concretely:
- Worker threads never call
setData(),insertRows(), or hold a pointer into the model's storage. They send copies through queued signals orQMetaObject::invokeMethod(model, ..., Qt::QueuedConnection). - Do not hand out raw pointers to internal containers. If a worker needs a snapshot, return a value copy — Qt's containers are implicitly shared (copy-on-write), so copying a
QVectoris cheap until someone mutates it. - Editing flows the other way: the delegate calls
setData(), the model validates, returnsfalseon rejection, and only emitsdataChangedon success. Check that return value in any custom delegate; a silentfalseis how "the edit didn't stick" bugs happen.
Operational checks
- Debug assertions. Build with
QT_DEBUG(a qmakeCONFIG+=debugor CMakeDebugbuild does this). Qt asserts on mismatchedbegin/endpairs and out-of-range indexes in debug builds — these are the failures you want during development, not after release. - Index validation. Guard every
data()andsetData()withif (!index.isValid() || index.row() >= m_rows.size()) return ...;. Views and proxies can and do pass invalid indexes. - ModelTester. KDAB's
QAbstractItemModelTester(also shipped in Qt's test sources) attaches to your model and continuously checks signal/index consistency. Attach it in your unit test; it catches most structural bugs within seconds. - Thread test. Write a test that spawns a worker emitting updates through the queued connection, spin the event loop, then verify row contents and that the view's
model()->rowCount()matches. Run it under AddressSanitizer or valgrind to catch cross-thread accesses you didn't notice.
Failure modes and what causes them
| Symptom | Usual cause |
|---|---|
| Crash or corrupted rows after an update | beginInsertRows/endInsertRows mismatched, or mutation without any begin/end wrapper |
| Deadlock at startup under load | Worker uses Qt::BlockingQueuedConnection while the GUI thread is blocked waiting on the worker |
| View shows stale or blank cells | dataChanged emitted with the wrong index range, or not emitted at all |
Crash inside data() during sorting | Underlying model mutated without going through the proxy's expected signals; use layoutAboutToBeChanged/layoutChanged for wholesale reordering |
| Edits silently lost | setData() returning false (validation) or flags() missing Qt::ItemIsEditable |
When this design stops being sufficient
Change the design when one of these becomes true, not before:
- Dataset exceeds comfortable memory. Switch to lazy fetching:
canFetchMore()/fetchMore()load chunks as the view scrolls. The thread-boundary rules stay identical; only the storage changes. - High-frequency push updates. If the backend pushes hundreds of updates per second, emitting
dataChangedper row will saturate repaints. Batch updates in the model slot (accumulate into a dirty-range list, flush on a zero-durationQTimer) or insert a throttling proxy. - Multiple views with different shapes. Add more proxies rather than more models — one source of truth, many presentations.
Verifying the result
Run the debug build, exercise insert, remove, sort, filter, and edit from the view, and confirm zero assertion output on the console. Then run the threaded unit test under AddressSanitizer. If both are clean and the model tester reports nothing, the invariants that actually matter — single-threaded mutation, paired begin/end calls, validated indexes — are holding. Assumptions here target Qt 5.15 and Qt 6.x behavior; the queued-connection and threading rules are the same in both, but verify API details against your exact version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.