Navigating QSqlTableModel and QTableView in very large databases

Working with large datasets in Qt's SQL table views, handling pagination, filtering, and record navigation
Heads up! You've already completed this tutorial.

When you're working with databases containing hundreds of thousands of rows, Qt's QSqlTableModel and QTableView do a remarkable job of keeping things responsive. Qt uses lazy loading — fetching data in chunks (typically 256 rows at a time) rather than pulling everything into memory at once. This is great for performance, but it introduces some real challenges when you need to jump to a specific record, toggle between filtered and unfiltered views, or remember where a user left off between sessions.

In this tutorial, we'll work through these challenges step by step and build a practical dual-view interface that lets users filter a large dataset, find records of interest, and immediately see the surrounding context — all without losing their place.

Understanding Qt's lazy loading behavior

When you connect a QSqlTableModel to a QTableView, Qt doesn't load the entire database table into memory. Instead, it fetches rows in batches. You can observe this yourself — if you call model.rowCount() right after setting up the model, you'll get a number like 256, even if your table has 600,000 rows.

This lazy loading is what makes Qt able to handle very large tables without grinding to a halt. But it also means that methods like model.match(), view.selectRow(), and proxy model filtering only operate on the rows that have already been fetched.

So if you try model.match(model.index(0, 0), Qt.DisplayRole, "some_value"), it will only search through the currently loaded rows. If the record you're looking for is row 50,000, it simply won't be found.

Forcing all rows to load with fetchMore()

Your first instinct might be to force the model to load everything. You can do this with a loop:

python
while model.canFetchMore():
    model.fetchMore()

This works, and after it completes, model.rowCount() will return the true total. However, for a table with 600,000+ rows, this can be painfully slow and memory-intensive. It defeats the whole purpose of lazy loading.

For small-to-medium datasets (a few thousand rows), this approach is perfectly fine. For very large datasets, you'll want a different strategy.

Using setFilter() for server-side filtering

The most efficient way to find specific records in a large database is to let the database do the work. QSqlTableModel.setFilter() translates directly into a SQL WHERE clause, which means the database engine handles the filtering — not Python, and not Qt's in-memory model.

PyQt6 Crash Course by Martin Fitzpatrick — The important parts of PyQt6 in bite-size chunks

See the course

python
# Jump to a specific record by its primary key
model.setFilter("UID = 43000")
model.select()

This is fast regardless of table size, because the database uses its indexes to locate the record. When you're done and want to show all records again:

python
model.setFilter("")
model.select()

The catch is that after removing the filter, the view resets to the beginning of the table.

Jumping to a specific record in a large table

The solution is to use setFilter() to position the model near the record you want, then scroll to it. Here's a function that does this:

python
def jump_to_uid(model, view, uid, uid_column=0):
    """
    Jump to a specific UID in a large table by using
    setFilter to load records starting from that point.
    """
    # Use a filter that loads records starting at our target
    model.setFilter(f"UID >= {uid}")
    model.select()

    # The target record is now the first (or near-first) row.
    # Find it with match() — it will be in the loaded batch.
    start_index = model.index(0, uid_column)
    matches = model.match(start_index, Qt.DisplayRole, uid, 1, Qt.MatchExactly)

    if matches:
        view.setCurrentIndex(matches[0])
        view.scrollTo(matches[0], QAbstractItemView.PositionAtCenter)

This works because setFilter("UID >= 43000") tells the database to return rows starting from UID 43,000. The first batch of 256 rows loaded will include your target, so match() can find it.

The downside is that the user can't scroll backward past the filter boundary. If they page up, they'll hit the beginning of the filtered result set (UID 43,000) rather than seeing earlier records.

Building a dual-view interface

A more robust approach — and one that works well in practice — is to use two table views side by side. One view shows filtered results (the search hits), and the other shows the unfiltered table positioned at whatever record is selected in the filtered view. This gives users the best of both worlds: efficient searching and full context.

If you're new to the Model/View architecture in Qt, you may want to review the PyQt6 ModelView Architecture tutorial first, as it covers the fundamentals of how models and views interact.

Let's build this step by step.

Setting up the database

First, let's create a sample database to work with. In a real application, you'd connect to your existing database — but for this tutorial we'll generate some test data:

python
import sys
import random

from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout,
    QHBoxLayout, QSplitter, QTableView, QLineEdit,
    QLabel, QPushButton, QAbstractItemView, QHeaderView,
)
from PyQt6.QtSql import QSqlDatabase, QSqlQuery, QSqlTableModel


def create_sample_database(path="large_sample.db", n_rows=10000):
    """Create a sample SQLite database with test data."""
    db = QSqlDatabase.addDatabase("QSQLITE")
    db.setDatabaseName(path)
    if not db.open():
        print("Failed to open database")
        return False

    query = QSqlQuery()
    query.exec(
        """
        CREATE TABLE IF NOT EXISTS transcripts (
            UID INTEGER PRIMARY KEY,
            speaker TEXT,
            phrase TEXT,
            video_id TEXT,
            timestamp_sec INTEGER
        )
        """
    )

    # Check if data already exists
    query.exec("SELECT COUNT(*) FROM transcripts")
    query.next()
    if query.value(0) > 0:
        return True

    speakers = ["Alice", "Bob", "Charlie", "Diana", "Eve"]
    sample_phrases = [
        "I'm gonna go to the store",
        "You know what I mean",
        "That's really interesting actually",
        "So basically what happened was",
        "I was like no way",
        "They were talking about it",
        "We should probably figure that out",
        "It's kinda hard to explain",
        "Do you wanna come with us",
        "I dunno maybe tomorrow",
    ]

    query.exec("BEGIN TRANSACTION")
    for uid in range(1, n_rows + 1):
        speaker = random.choice(speakers)
        phrase = random.choice(sample_phrases)
        video_id = f"vid_{random.randint(1, 500):04d}"
        timestamp = random.randint(0, 3600)
        query.exec(
            f"INSERT INTO transcripts VALUES ("
            f"{uid}, '{speaker}', '{phrase}', "
            f"'{video_id}', {timestamp})"
        )
    query.exec("COMMIT")

    return True

This creates a SQLite database with 10,000 rows. You can increase n_rows to test with larger datasets — the techniques we're using scale well because the database does the heavy lifting.

Creating the dual-view window

Now let's build the main window with two table views: a search view on the left for filtered results, and a context view on the right that shows the full unfiltered table positioned at the selected record.

python
class DualViewModel(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Transcript Browser")
        self.setGeometry(100, 100, 1400, 700)

        # --- Search model (filtered) ---
        self.search_model = QSqlTableModel()
        self.search_model.setTable("transcripts")
        self.search_model.setEditStrategy(QSqlTableModel.OnManualSubmit)
        self.search_model.select()

        # --- Context model (positioned, not filtered) ---
        self.context_model = QSqlTableModel()
        self.context_model.setTable("transcripts")
        self.context_model.setEditStrategy(QSqlTableModel.OnManualSubmit)
        self.context_model.select()

        self._build_ui()

    def _build_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QVBoxLayout(central)

        # --- Top bar: search controls ---
        controls = QHBoxLayout()

        controls.addWidget(QLabel("Filter phrase:"))
        self.filter_input = QLineEdit()
        self.filter_input.setPlaceholderText(
            "e.g. gonna"
        )
        controls.addWidget(self.filter_input)

        self.filter_btn = QPushButton("Apply Filter")
        self.filter_btn.clicked.connect(self.apply_filter)
        controls.addWidget(self.filter_btn)

        self.clear_btn = QPushButton("Clear Filter")
        self.clear_btn.clicked.connect(self.clear_filter)
        controls.addWidget(self.clear_btn)

        controls.addWidget(QLabel("  Go to UID:"))
        self.goto_input = QLineEdit()
        self.goto_input.setFixedWidth(100)
        controls.addWidget(self.goto_input)

        self.goto_btn = QPushButton("Go")
        self.goto_btn.clicked.connect(self.goto_uid)
        controls.addWidget(self.goto_btn)

        main_layout.addLayout(controls)

        # --- Status bar ---
        self.status_label = QLabel("Ready")
        main_layout.addWidget(self.status_label)

        # --- Splitter with two table views ---
        splitter = QSplitter(Qt.Horizontal)

        # Left: search results (filtered)
        left_panel = QWidget()
        left_layout = QVBoxLayout(left_panel)
        left_layout.setContentsMargins(0, 0, 0, 0)
        left_layout.addWidget(QLabel("Search Results (filtered)"))

        self.search_view = QTableView()
        self.search_view.setModel(self.search_model)
        self.search_view.setSelectionBehavior(
            QAbstractItemView.SelectRows
        )
        self.search_view.setSelectionMode(
            QAbstractItemView.SingleSelection
        )
        self.search_view.horizontalHeader().setStretchLastSection(True)
        self.search_view.setAlternatingRowColors(True)
        left_layout.addWidget(self.search_view)
        splitter.addWidget(left_panel)

        # Right: context view (positioned near selected record)
        right_panel = QWidget()
        right_layout = QVBoxLayout(right_panel)
        right_layout.setContentsMargins(0, 0, 0, 0)
        right_layout.addWidget(QLabel("Context (unfiltered)"))

        self.context_view = QTableView()
        self.context_view.setModel(self.context_model)
        self.context_view.setSelectionBehavior(
            QAbstractItemView.SelectRows
        )
        self.context_view.setSelectionMode(
            QAbstractItemView.SingleSelection
        )
        self.context_view.horizontalHeader().setStretchLastSection(True)
        self.context_view.setAlternatingRowColors(True)
        right_layout.addWidget(self.context_view)
        splitter.addWidget(right_panel)

        splitter.setSizes([700, 700])
        main_layout.addWidget(splitter)

        # Connect selection changes in search view
        self.search_view.selectionModel().currentRowChanged.connect(
            self.on_search_selection_changed
        )

At this point we have two models connected to the same database table, and two views showing them. The search model will be filtered; the context model will be repositioned using setFilter() with a range query whenever the user selects a search result.

Implementing the filter and navigation methods

Now let's add the methods that make everything work:

python
    def apply_filter(self):
        """Filter the search view to show only matching phrases."""
        text = self.filter_input.text().strip()
        if not text:
            self.clear_filter()
            return

        # Use SQL LIKE for phrase matching
        self.search_model.setFilter(
            f"phrase LIKE '%{text}%'"
        )
        self.search_model.select()

        # Fetch enough rows to get a count
        while self.search_model.canFetchMore():
            self.search_model.fetchMore()

        count = self.search_model.rowCount()
        self.status_label.setText(
            f"Filter active: {count} matching records"
        )

    def clear_filter(self):
        """Remove the filter from the search view."""
        self.search_model.setFilter("")
        self.search_model.select()
        self.status_label.setText("Filter cleared")

    def on_search_selection_changed(self, current, previous):
        """
        When the user selects a row in the search view,
        position the context view at the same record.
        """
        if not current.isValid():
            return

        # Get the UID from the selected row (column 0 is UID)
        uid_index = self.search_model.index(current.row(), 0)
        uid = self.search_model.data(uid_index)

        if uid is not None:
            self._show_context_around(uid)

    def _show_context_around(self, uid, context_rows=100):
        """
        Position the context view so that the given UID
        is visible with surrounding records for context.
        """
        # Load records around the target UID
        start_uid = max(1, uid - context_rows)
        self.context_model.setFilter(
            f"UID >= {start_uid}"
        )
        self.context_model.select()

        # Fetch rows until we have enough to show context
        # or until we've loaded past our target
        target_row = None
        while True:
            for row in range(self.context_model.rowCount()):
                index = self.context_model.index(row, 0)
                if self.context_model.data(index) == uid:
                    target_row = row
                    break

            if target_row is not None:
                break

            if self.context_model.canFetchMore():
                self.context_model.fetchMore()
            else:
                break

        if target_row is not None:
            target_index = self.context_model.index(target_row, 0)
            self.context_view.setCurrentIndex(target_index)
            self.context_view.scrollTo(
                target_index, QAbstractItemView.PositionAtCenter
            )
            self.status_label.setText(
                f"Showing context around UID {uid}"
            )

    def goto_uid(self):
        """Jump both views to a specific UID."""
        try:
            uid = int(self.goto_input.text().strip())
        except ValueError:
            self.status_label.setText("Please enter a valid UID number")
            return

        self._show_context_around(uid)

        # Also try to select it in the search view if visible
        for row in range(self.search_model.rowCount()):
            index = self.search_model.index(row, 0)
            if self.search_model.data(index) == uid:
                self.search_view.setCurrentIndex(index)
                self.search_view.scrollTo(
                    index, QAbstractItemView.PositionAtCenter
                )
                break

The _show_context_around method is where the real work happens. Instead of trying to load the entire table and scroll to row 43,000, it tells the database: "Give me records starting from UID 42,900." The target record will be near the top of the result set, so match() or a simple loop can find it quickly. The user sees the target record centered in the context view, with surrounding records visible above and below.

Saving and restoring the user's position

Since users may spend days working through a dataset, saving their place between sessions is important. We can use Qt's QSettings for this — if you're unfamiliar with QSettings, see the QSettings guide for PyQt6:

python
    from PyQt6.QtCore import QSettings

    def save_position(self):
        """Save the current position to settings."""
        settings = QSettings("MyApp", "TranscriptBrowser")

        # Save the currently selected UID in the search view
        current = self.search_view.currentIndex()
        if current.isValid():
            uid_index = self.search_model.index(current.row(), 0)
            uid = self.search_model.data(uid_index)
            settings.setValue("last_uid", uid)

        # Save the current filter
        settings.setValue(
            "last_filter", self.filter_input.text()
        )

    def restore_position(self):
        """Restore the saved position from settings."""
        settings = QSettings("MyApp", "TranscriptBrowser")

        # Restore filter
        last_filter = settings.value("last_filter", "")
        if last_filter:
            self.filter_input.setText(last_filter)
            self.apply_filter()

        # Restore position
        last_uid = settings.value("last_uid")
        if last_uid is not None:
            self._show_context_around(int(last_uid))

    def closeEvent(self, event):
        """Save position when the window is closed."""
        self.save_position()
        super().closeEvent(event)

Call self.restore_position() at the end of __init__ to pick up where the user left off.

Handling scrolling beyond the filter boundary

One remaining issue: when the context model is filtered with UID >= 42900, the user can scroll forward through the data, but they can't scroll backward past UID 42,900. To handle this, you can watch for the scrollbar hitting its minimum value and reload with a lower starting UID:

python
    def _setup_scroll_handling(self):
        """Detect when user scrolls to the top and load earlier records."""
        scrollbar = self.context_view.verticalScrollBar()
        scrollbar.valueChanged.connect(self._on_context_scroll)

    def _on_context_scroll(self, value):
        scrollbar = self.context_view.verticalScrollBar()
        if value == scrollbar.minimum():
            # User has scrolled to the top — load earlier records
            first_index = self.context_model.index(0, 0)
            first_uid = self.context_model.data(first_index)
            if first_uid and first_uid > 1:
                new_start = max(1, first_uid - 256)
                self.context_model.setFilter(
                    f"UID >= {new_start}"
                )
                self.context_model.select()
                # Scroll back to where we were
                self._scroll_to_uid_in_context(first_uid)

    def _scroll_to_uid_in_context(self, uid):
        """Find and scroll to a UID in the context model."""
        while True:
            for row in range(self.context_model.rowCount()):
                index = self.context_model.index(row, 0)
                if self.context_model.data(index) == uid:
                    self.context_view.scrollTo(
                        index, QAbstractItemView.PositionAtTop
                    )
                    return
            if self.context_model.canFetchMore():
                self.context_model.fetchMore()
            else:
                return

Call self._setup_scroll_handling() at the end of _build_ui() to activate this behavior.

Complete working example

Here's the full application, combining everything we've covered. You can copy this, run it, and experiment with a 10,000-row sample database. Increase n_rows in create_sample_database to test with larger datasets.

python
import sys
import random

from PyQt6.QtCore import Qt, QSettings
from PyQt6.QtWidgets import (
    QApplication,
    QMainWindow,
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QSplitter,
    QTableView,
    QLineEdit,
    QLabel,
    QPushButton,
    QAbstractItemView,
)
from PyQt6.QtSql import QSqlDatabase, QSqlQuery, QSqlTableModel


def create_sample_database(path="large_sample.db", n_rows=10000):
    """Create a sample SQLite database with test data."""
    db = QSqlDatabase.addDatabase("QSQLITE")
    db.setDatabaseName(path)
    if not db.open():
        print("Failed to open database")
        return False

    query = QSqlQuery()
    query.exec(
        """
        CREATE TABLE IF NOT EXISTS transcripts (
            UID INTEGER PRIMARY KEY,
            speaker TEXT,
            phrase TEXT,
            video_id TEXT,
            timestamp_sec INTEGER
        )
        """
    )

    query.exec("SELECT COUNT(*) FROM transcripts")
    query.next()
    if query.value(0) > 0:
        return True

    speakers = ["Alice", "Bob", "Charlie", "Diana", "Eve"]
    sample_phrases = [
        "I'm gonna go to the store",
        "You know what I mean",
        "That's really interesting actually",
        "So basically what happened was",
        "I was like no way",
        "They were talking about it",
        "We should probably figure that out",
        "It's kinda hard to explain",
        "Do you wanna come with us",
        "I dunno maybe tomorrow",
    ]

    query.exec("BEGIN TRANSACTION")
    for uid in range(1, n_rows + 1):
        speaker = random.choice(speakers)
        phrase = random.choice(sample_phrases)
        video_id = f"vid_{random.randint(1, 500):04d}"
        timestamp = random.randint(0, 3600)
        query.exec(
            f"INSERT INTO transcripts VALUES ("
            f"{uid}, '{speaker}', '{phrase}', "
            f"'{video_id}', {timestamp})"
        )
    query.exec("COMMIT")

    return True


class DualViewModel(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Transcript Browser")
        self.setGeometry(100, 100, 1400, 700)

        # Search model (filtered)
        self.search_model = QSqlTableModel()
        self.search_model.setTable("transcripts")
        self.search_model.setEditStrategy(
            QSqlTableModel.OnManualSubmit
        )
        self.search_model.select()

        # Context model (repositioned via setFilter range)
        self.context_model = QSqlTableModel()
        self.context_model.setTable("transcripts")
        self.context_model.setEditStrategy(
            QSqlTableModel.OnManualSubmit
        )
        self.context_model.select()

        self._build_ui()
        self.restore_position()

    def _build_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QVBoxLayout(central)

        # Top bar: search controls
        controls = QHBoxLayout()

        controls.addWidget(QLabel("Filter phrase:"))
        self.filter_input = QLineEdit()
        self.filter_input.setPlaceholderText("e.g. gonna")
        self.filter_input.returnPressed.connect(self.apply_filter)
        controls.addWidget(self.filter_input)

        self.filter_btn = QPushButton("Apply Filter")
        self.filter_btn.clicked.connect(self.apply_filter)
        controls.addWidget(self.filter_btn)

        self.clear_btn = QPushButton("Clear Filter")
        self.clear_btn.clicked.connect(self.clear_filter)
        controls.addWidget(self.clear_btn)

        controls.addWidget(QLabel("  Go to UID:"))
        self.goto_input = QLineEdit()
        self.goto_input.setFixedWidth(100)
        self.goto_input.returnPressed.connect(self.goto_uid)
        controls.addWidget(self.goto_input)

        self.goto_btn = QPushButton("Go")
        self.goto_btn.clicked.connect(self.goto_uid)
        controls.addWidget(self.goto_btn)

        main_layout.addLayout(controls)

        # Status label
        self.status_label = QLabel("Ready")
        main_layout.addWidget(self.status_label)

        # Splitter with two table views
        splitter = QSplitter(Qt.Horizontal)

        # Left: search results
        left_panel = QWidget()
        left_layout = QVBoxLayout(left_panel)
        left_layout.setContentsMargins(0, 0, 0, 0)
        left_layout.addWidget(
            QLabel("Search Results (filtered)")
        )

        self.search_view = QTableView()
        self.search_view.setModel(self.search_model)
        self.search_view.setSelectionBehavior(
            QAbstractItemView.SelectRows
        )
        self.search_view.setSelectionMode(
            QAbstractItemView.SingleSelection
        )
        self.search_view.horizontalHeader().setStretchLastSection(
            True
        )
        self.search_view.setAlternatingRowColors(True)
        left_layout.addWidget(self.search_view)
        splitter.addWidget(left_panel)

        # Right: context view
        right_panel = QWidget()
        right_layout = QVBoxLayout(right_panel)
        right_layout.setContentsMargins(0, 0, 0, 0)
        right_layout.addWidget(QLabel("Context (full table)"))

        self.context_view = QTableView()
        self.context_view.setModel(self.context_model)
        self.context_view.setSelectionBehavior(
            QAbstractItemView.SelectRows
        )
        self.context_view.setSelectionMode(
            QAbstractItemView.SingleSelection
        )
        self.context_view.horizontalHeader().setStretchLastSection(
            True
        )
        self.context_view.setAlternatingRowColors(True)
        right_layout.addWidget(self.context_view)
        splitter.addWidget(right_panel)

        splitter.setSizes([700, 700])
        main_layout.addWidget(splitter)

        # Connect selection changes in search view
        self.search_view.selectionModel().currentRowChanged.connect(
            self.on_search_selection_changed
        )

        # Handle scrolling to top in context view
        scrollbar = self.context_view.verticalScrollBar()
        scrollbar.valueChanged.connect(self._on_context_scroll)

    def apply_filter(self):
        """Filter the search view to show matching phrases."""
        text = self.filter_input.text().strip()
        if not text:
            self.clear_filter()
            return

        self.search_model.setFilter(
            f"phrase LIKE '%{text}%'"
        )
        self.search_model.select()

        # Fetch all to get an accurate count
        while self.search_model.canFetchMore():
            self.search_model.fetchMore()

        count = self.search_model.rowCount()
        self.status_label.setText(
            f"Filter active: {count} matching records"
        )

    def clear_filter(self):
        """Remove the filter from the search view."""
        self.search_model.setFilter("")
        self.search_model.select()
        self.status_label.setText("Filter cleared")

    def on_search_selection_changed(self, current, previous):
        """When a search result is selected, show context."""
        if not current.isValid():
            return

        uid_index = self.search_model.index(current.row(), 0)
        uid = self.search_model.data(uid_index)

        if uid is not None:
            self._show_context_around(int(uid))

    def _show_context_around(self, uid, context_rows=100):
        """
        Position the context view so the given UID is visible
        with surrounding records.
        """
        start_uid = max(1, uid - context_rows)
        self.context_model.setFilter(f"UID >= {start_uid}")
        self.context_model.select()

        # Fetch rows until we find our target
        target_row = None
        while True:
            for row in range(self.context_model.rowCount()):
                index = self.context_model.index(row, 0)
                if self.context_model.data(index) == uid:
                    target_row = row
                    break

            if target_row is not None:
                break

            if self.context_model.canFetchMore():
                self.context_model.fetchMore()
            else:
                break

        if target_row is not None:
            target_index = self.context_model.index(
                target_row, 0
            )
            self.context_view.setCurrentIndex(target_index)
            self.context_view.scrollTo(
                target_index,
                QAbstractItemView.PositionAtCenter,
            )
            self.status_label.setText(
                f"Showing context around UID {uid}"
            )
        else:
            self.status_label.setText(
                f"UID {uid} not found in database"
            )

    def _on_context_scroll(self, value):
        """Load earlier records when user scrolls to the top."""
        scrollbar = self.context_view.verticalScrollBar()
        if value == scrollbar.minimum():
            first_index = self.context_model.index(0, 0)
            first_uid = self.context_model.data(first_index)
            if first_uid and int(first_uid) > 1:
                new_start = max(1, int(first_uid) - 256)
                self.context_model.setFilter(
                    f"UID >= {new_start}"
                )
                self.context_model.select()
                self._scroll_to_uid_in_context(int(first_uid))

    def _scroll_to_uid_in_context(self, uid):
        """Find and scroll to a UID in the context model."""
        while True:
            for row in range(self.context_model.rowCount()):
                index = self.context_model.index(row, 0)
                if self.context_model.data(index) == uid:
                    self.context_view.scrollTo(
                        index,
                        QAbstractItemView.PositionAtTop,
                    )
                    return
            if self.context_model.canFetchMore():
                self.context_model.fetchMore()
            else:
                return

    def goto_uid(self):
        """Jump the context view to a specific UID."""
        try:
            uid = int(self.goto_input.text().strip())
        except ValueError:
            self.status_label.setText(
                "Please enter a valid UID number"
            )
            return

        self._show_context_around(uid)

    def save_position(self):
        """Save the current position to settings."""
        settings = QSettings("MyApp", "TranscriptBrowser")

        current = self.search_view.currentIndex()
        if current.isValid():
            uid_index = self.search_model.index(
                current.row(), 0
            )
            uid = self.search_model.data(uid_index)
            settings.setValue("last_uid", uid)

        settings.setValue(
            "last_filter", self.filter_input.text()
        )

    def restore_position(self):
        """Restore the saved position from settings."""
        settings = QSettings("MyApp", "TranscriptBrowser")

        last_filter = settings.value("last_filter", "")
        if last_filter:
            self.filter_input.setText(last_filter)
            self.apply_filter()

        last_uid = settings.value("last_uid")
        if last_uid is not None:
            self._show_context_around(int(last_uid))

    def closeEvent(self, event):
        """Save position when the window closes."""
        self.save_position()
        super().closeEvent(event)


if __name__ == "__main__":
    app = QApplication(sys.argv)

    if not create_sample_database():
        sys.exit(1)

    window = DualViewModel()
    window.show()
    sys.exit(app.exec())

When you run this, a sample database is created (if it doesn't already exist), and you'll see the dual-view interface. Try typing "gonna" in the filter box and clicking Apply Filter. Then click on different search results — the context view on the right will jump to show the surrounding records. You can also type a UID number and click Go to jump directly.

Summary of the approach

When working with very large tables through QSqlTableModel:

  • Use setFilter() instead of proxy models for filtering large datasets. It pushes the work to the database, which is dramatically faster than filtering in Python. For sorting and filtering smaller in-memory datasets, see our guide on sort & filter tables in PyQt6.
  • Use setFilter() with range queries (like UID >= N) to position the view near a specific record, rather than trying to load and scroll through everything.
  • Use two models and two views when you need both filtered results and full context visible simultaneously. Each model can have its own independent filter.
  • Watch the scrollbar to detect when users scroll past the loaded boundary, and reload with an adjusted filter range.
  • Save and restore position using QSettings so users don't lose their place between sessions.

This pattern — letting the database handle the hard work while using Qt's models purely as a presentation layer — scales comfortably to tables with hundreds of thousands of rows. For more on displaying tabular data with numpy and pandas, see our QTableView with numpy and pandas tutorial.

Over 15,000 developers have bought Create GUI Applications with Python & Qt!
Create GUI Applications with Python & Qt6
Get the book

Downloadable ebook (PDF, ePub) & Complete Source code

[[ discount.discount_pc ]]% OFF for the next [[ discount.duration ]] [[discount.description ]] with the code [[ discount.coupon_code ]]

Purchasing Power Parity

Developers in [[ country ]] get [[ discount.discount_pc ]]% OFF on all books & courses with code [[ discount.coupon_code ]]
Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak
Martin Fitzpatrick

Navigating QSqlTableModel and QTableView in very large databases was written by Martin Fitzpatrick.

Martin Fitzpatrick is the creator of Python GUIs, and has been developing Python/Qt applications for the past 12+ years. He has written a number of popular Python books and provides Python software development & consulting for teams and startups.