Qt Model/View: Displaying Large Datasets Without Duplicating Memory
Loading 100,000 records into a QListWidget copies every item into widget memory. A custom QAbstractListModel exposes your existing container directly to the view, keeping one source of truth and avoiding duplication.
20 Oct 2025, 22:55 UTC

The item-widget memory trap
When you need to show a large result set—say, 100,000 rows from a database query—the fastest path in Qt is often a QListWidget or QTableWidget. You loop over your data, call addItem(), and the UI appears. The problem is that these convenience classes are item-based: every row becomes a QListWidgetItem or QTableWidgetItem object owned by the widget. Your data now exists twice: once in your original container and once inside the widget's internal item list. For large datasets, that duplication is expensive in both memory and construction time.
Separating storage from presentation
Qt's Model/View architecture fixes this by splitting responsibilities. The model owns or references the data. The view handles rendering and scrolling. They communicate through QModelIndex, a lightweight handle that identifies a row, column, and parent—not the data itself. Because the view never stores the payload, you can attach several views to the same model without multiplying memory usage.
The base class for a non-hierarchical model is QAbstractListModel (or QAbstractTableModel for grids). At minimum you implement rowCount() and data(). The view calls rowCount() once to learn the extent of the dataset, then calls data() only for the rows currently visible on screen.
A minimal custom model
Suppose your application already holds records in a QVector<QString>. You can expose that vector directly without copying it into widget items.
#include <QAbstractListModel>
#include <QVector>
#include <QString>
class StringListModel : public QAbstractListModel {
Q_OBJECT
public:
explicit StringListModel(QObject *parent = nullptr)
: QAbstractListModel(parent) {}
int rowCount(const QModelIndex &parent = QModelIndex()) const override {
if (parent.isValid())
return 0;
return static_cast<int>(m_data.size());
}
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override {
if (!index.isValid() || index.row() >= m_data.size())
return QVariant();
if (role == Qt::DisplayRole)
return m_data.at(index.row());
return QVariant();
}
void append(const QString &value) {
beginInsertRows(QModelIndex(), m_data.size(), m_data.size());
m_data.append(value);
endInsertRows();
}
private:
QVector<QString> m_data;
};
Attach it to a view like this:
auto *view = new QListView;
auto *model = new StringListModel;
view->setModel(model);
model->append("first row");
model->append("second row");
Run this in any Qt application using the widgets module. No special permissions are required. The expected check is that the two strings appear in the list. If they do not, confirm that rowCount() returns a non-zero value and that data() returns a valid QVariant for Qt::DisplayRole.
Why this scales
The view allocates delegate objects only for visible rows. If your window shows 20 items at a time, the view creates roughly 20 delegates regardless of whether the model holds 1,000 or 1,000,000 rows. The model's data() method is the hot path: it should return cached or trivially computed values. Heavy database lookups or string parsing inside data() will cause repaint lag.
Keeping the view in sync
A custom model is a contract. When you change the underlying container directly—for example, m_data[0] = "updated"—the view has no way to know. You must emit dataChanged() for in-place edits, and wrap structural changes in beginInsertRows()/endInsertRows() or beginRemoveRows()/endRemoveRows(). These signals tell attached views to invalidate the affected indices and re-query the model. Skipping them leaves the UI showing stale data.
Sorting and filtering without a second copy
For search or column sorting, insert a QSortFilterProxyModel between the source model and the view. The proxy does not duplicate your records; it maintains an internal mapping of source indices to sorted or filtered positions.
auto *proxy = new QSortFilterProxyModel;
proxy->setSourceModel(model);
proxy->setFilterFixedString("query");
view->setModel(proxy);
Now the view asks the proxy for data, and the proxy translates that request back to the original model. Your QVector<QString> remains the single source of truth.
Trade-offs and limitations
| Aspect | Item widgets | Model/View |
|---|---|---|
| Memory profile | Scales with row count; data copied into items | Flat for data; only visible delegates allocate |
| Multiple views | Each widget owns separate items | Views share one model instance |
| Boilerplate | Low; call addItem() | Moderate; implement virtual methods |
| Update safety | Widget handles repaint automatically | Developer must emit correct signals |
The main limitation is complexity. A read-only list model is straightforward, but hierarchical models (trees) require careful management of parent/child relationships via createIndex() and parent(). Incorrect index handling in tree models can cause crashes. Additionally, if your data lives in a worker thread, the model must emit signals from the GUI thread; direct mutation from another thread is unsafe. The core QAbstractItemModel API has remained stable across Qt 5 and Qt 6, but verify against your specific version documentation, particularly if you rely on newer convenience overloads.
Practical verification
To confirm that duplication is gone, load 100,000 strings into both a QListWidget and your custom model. Observe process memory before and after population. With the item widget, memory grows with the item count. With the custom model, growth should be limited to the QVector itself and the small number of active delegates. As a second check, attach a second QListView to the same model; total memory should not double, because both views reference the same underlying data.
Start with QAbstractListModel for linear data and QAbstractTableModel for grids. Keep data() cheap, emit signals religiously, and use QSortFilterProxyModel for search. The result is a UI that stays responsive and a memory profile that reflects your actual dataset, not the widget's internal bookkeeping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.