The ModelView Architecture in PyQt6

Qt's MVC-like interface for displaying data in views

PyQt6 Tutorial PyQt6 ModelViews and Databases

Heads up! You've already completed this tutorial.

As you start to build more complex applications with PyQt6, you'll likely come across issues keeping widgets in sync with your data.

Data stored in widgets (e.g. a simple QListWidget) is not readily available to manipulate from Python — changes require you to get an item, get the data, and then set it back. The default solution to this is to keep an external data representation in Python, and then either duplicate updates to both the data and the widget, or simply rewrite the whole widget from the data. This can get ugly quickly and result in a lot of boilerplate just for fiddling the data.

Thankfully, Qt has a solution for this — ModelView widgets. ModelViews are a powerful alternative to standard display widgets, which use a regular model interface to interact with data sources, from simple data structures to external databases. This isolates your data, allowing it to be kept in any structure you like, while the view takes care of presentation and updates.

This tutorial introduces the key aspects of Qt's ModelView architecture and uses it to build a simple desktop Todo application in PyQt6.

The Model View Controller Pattern

Model–View–Controller (MVC) is an architectural pattern used for developing user interfaces. It divides an application into three interconnected parts that separate the internal representation of data from how information is presented to and accepted from the user.

The MVC design pattern decouples three major components:

  • Model: holds the data structure that the app will be working with.
  • View: is any representation of information as shown to the user, whether graphical or tabular. Multiple views of the same data model are allowed.
  • Controller: accepts input from the user, transforming it into commands for the model or view.

In Qt land, the distinction between the View and Controller gets a little murky. Qt accepts input events from the user via the operating system and delegates these to the widgets (Controller) for handling. However, widgets also handle presenting the current state to the user, putting them squarely in the View.

PyQt/PySide 1:1 Coaching with Martin Fitzpatrick — Get one on one help with your Python GUI projects. Working together with you I'll identify issues and suggest fixes, from bugs and usability to architecture and maintainability.

Book Now 60 mins ($195)

In Qt-speak, rather than agonizing over where to draw the line, the View and Controller are merged together, creating a Model-ViewController architecture—called ModelView for simplicity's sake. In this architecture, the distinction between the data and how it is presented is preserved.

Qt's ModelView Architecture

The Model acts as the interface between the data and the ViewController. It holds the data (or a reference to it) and presents it through a standardized API, which Views then consume and present to the user. Multiple Views can use the same data, presenting it in completely different ways.

You can use any data store for your models, including a Python list or dictionary, or a database via SQLAlchemy — it's entirely up to you.

The two parts are essentially responsible for:

  1. The model stores the data, or a reference to it, and returns individual or ranges of records, as well as associated metadata or display instructions.
  2. The view requests data from the model and displays what is returned on the widget.

There is an in-depth discussion of Qt's ModelView architecture in the documentation.

A ModelView Demo: A Todo List App

To demonstrate how to use the ModelView architecture in practice, we'll create a simple Todo list app. This will consist of a QListView widget for the list of items, a QLineEdit widget to enter new items, and a set of buttons to add, delete, or mark items as done.

Creating the Todo App's UI

We laid out a basic UI using Qt Creator and saved it as mainwindow.ui:

A Todo App's UI in Qt Creator A Todo App's UI in Qt Creator

Once you have the mainwindow.ui in place, you can create a todo.py file and put the following code in it:

python
from PyQt6 import uic
from PyQt6.QtWidgets import QApplication, QMainWindow

MainWindowUI, _ = uic.loadUiType("mainwindow.ui")

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = MainWindowUI()
        self.ui.setupUi(self)

app = QApplication([])
window = MainWindow()
window.show()
app.exec()

The running app is shown below:

The running Todo GUI (nothing works yet) The running Todo GUI (nothing works yet)

You can download the Todo application's source code.

The widgets available in the interface were given the IDs shown in the table below:

objectName Type Description
todoView QListView Hold the list of current todos
todoEdit QLineEdit Take the text input for creating a new todo item
addButton QPushButton Create the new todo, adding it to the todos list
deleteButton QPushButton Delete the current selected todo, removing it from the todos list
completeButton QPushButton Mark the current selected todo as done or complete

We'll use these identifiers to hook up the application logic later.

Setting Up the Model

We define our custom model by subclassing from a base implementation, allowing us to focus on the parts unique to our model. Qt provides a number of different base models, including models for lists, trees, and tables (ideal for spreadsheets).

For our example, we will display the result in a QListView widget. The matching base model for this is QAbstractListModel. The outline definition for our model is shown below:

python
class TodoModel(QAbstractListModel):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.todos = self.load()
        self.tick = QImage("tick.png")

    def _load(self):
        try:
            with open("data.db", mode="r", encoding="utf-8") as todo_db:
                return json.load(todo_db)
        except FileNotFoundError:
            return []

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            _, text = self.todos[index.row()]
            return text

    def rowCount(self, index):
        return len(self.todos)

The TodoModel class inherits from QAbstractListModel. The todos attribute is the data store for our model. It is a regular Python list in which we'll store tuples of values in the format (complete, todo), where complete is the done state of a given entry, and todo is the text of the todo task. We initialize todos to the result of the .load() method.

This method reads existing todo items from a JSON file and returns a list of tuples. If the file doesn't exist, then the method returns an empty list.

The data() and rowCount() methods are standard Model methods we must implement for a list-based model.

The data() method is the core of your model. It handles requests for data from the view and returns the appropriate result. It receives two arguments: index and role. The index argument is the position/coordinates of the data which the view is requesting, accessible by two methods row() and column(), which give the position in each dimension.

For our QListView the column is always 0 and can be ignored, but you would need to use this for 2D data in a spreadsheet view.

The role argument is a flag indicating the type of data the view is requesting. This is because the data() method actually has more responsibility than just the core data. It also handles requests for style information, tooltips, status bars, etc. — basically anything that could be informed by the data itself.

The naming of Qt.ItemDataRole.DisplayRole is a bit weird, but this indicates that the view is asking us: "Please give me data for display". There are other roles that the data() method can receive for styling requests or requesting data in edit-ready format:

Role Value Description
Qt.ItemDataRole.DisplayRole 0 The key data to be rendered in the form of text. (QString)
Qt.ItemDataRole.DecorationRole 1 The data to be rendered as a decoration in the form of an icon. (QColor, QIcon or QPixmap)
Qt.ItemDataRole.EditRole 2 The data in a form suitable for editing in an editor. (QString)
Qt.ItemDataRole.ToolTipRole 3 The data displayed in the item's tooltip. (QString)
Qt.ItemDataRole.StatusTipRole 4 The data displayed in the status bar. (QString)
Qt.ItemDataRole.WhatsThisRole 5 The data displayed for the item in "What's This?" mode. (QString)
Qt.ItemDataRole.SizeHintRole 13 The size hint for the item that will be supplied to views. (QSize)

For a full list of available roles, see the Qt ItemDataRole documentation. Our todo list will only use Qt.ItemDataRole.DisplayRole and Qt.ItemDataRole.DecorationRole.

The rowCount() method is called by the view to get the number of rows in the current data. This is required for the view to know the maximum index it can request from the data store (row count - 1). Since we're using a Python list as our data store, the return value for this is the length of the list, which we get using the built-in len() function.

Outlining a Basic Stub Application

Below is the basic stub application needed to load and display the UI. We'll add our model code and application logic to this base:

python
import json

from PyQt6 import uic
from PyQt6.QtCore import QAbstractListModel, Qt
from PyQt6.QtGui import QImage
from PyQt6.QtWidgets import QApplication, QMainWindow

MainWindowUI, _ = uic.loadUiType("mainwindow.ui")

class TodoModel(QAbstractListModel):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.todos = self.load()

    def _load(self):
        try:
            with open("data.db", mode="r", encoding="utf-8") as todo_db:
                return json.load(todo_db)
        except FileNotFoundError:
            return []

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            _, text = self.todos[index.row()]
            return text

    def rowCount(self, index):
        return len(self.todos)

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = MainWindowUI()
        self.ui.setupUi(self)
        self.model = TodoModel()
        self.ui.todoView.setModel(self.model)

app = QApplication([])
window = MainWindow()
window.show()
app.exec()

Here, we ad two new lines to the __init__() method of MainWindow. In the first line, we create an instance of our todo model, TodoModel, and store it in the model attribute. Then, we set this model on the todoView.

Over 10,000 developers have bought Create GUI Applications with Python & Qt!
Create GUI Applications with Python & Qt6
Take a look

Downloadable ebook (PDF, ePub) & Complete Source code

Also available from Leanpub and Amazon Paperback

[[ 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 ]]

Adding Todo Items

First, we will add a method to our model class. The method will be called add() and contain the following code:

python
class TodoModel(QAbstractListModel):
    # ...
    def add(self, todo_text):
        self.todos.append((False, todo_text))
        self._save()

In add(), we take a todo_text argument holding the todo description provided by the user. Then we append a tuple to the todos list. The tuple's first value is the todo task status. The second value is the todo description text.

Then, we call the _save() helper method, which has the following implication:

python
class TodoModel(QAbstractListModel):
    # ...
    def _save(self):
        with open("data.db", mode="w", encoding="utf-8") as todo_db:
            json.dump(self.todos, todo_db)
        self.layoutChanged.emit()

This method allows you to create an external file called data.db to persistently store the todo list. We will reuse _save() in every method that changes the model, so we take this opportunity to call layoutChanged.emit() to let the view know that the shape of the data has been altered. This signal triggers a refresh of the entirety of the view. If you omit this line, the todo will still be added, but the QListView won't be updated.

Next, we create a new method on the MainWindow named add(). This is a callback and will take care of adding the current text from the input as a new todo. Connect this method to the addButton.pressed signal at the end of the __init__() block:

python
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = MainWindowUI()
        self.ui.setupUi(self)
        self.model = TodoModel()
        self.ui.todoView.setModel(self.model)
        # Connect buttons to their respective methods
        self.ui.addButton.pressed.connect(self.add)

    def add(self):
        if text := self.ui.todoEdit.text():
            self.model.add(text)
            self.ui.todoEdit.clear()

In add(), we check if the user has provided a todo description through the todoEdit. If that's the case, we add the todo to the model and clear the todoEdit widget to allow the next entry.

Deleting Todo Items

Next, we will provide functionality to delete an existing todo item. Define a new delete() method in TodoModel with the following content:

python
class TodoModel(QAbstractListModel):
    # ...
    def delete(self, index):
        try:
            del self.todos[index]
        except IndexError:
            return
        self._save()

In this method, we delete a todo tuple from the list of todos. We wrap the deletion in a try block to handle the cases where the index is out of range. In the final line, we call the _save() method again to update the external JSON file.

Here's the delete() method in the view:

python
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = MainWindowUI()
        self.ui.setupUi(self)
        self.model = TodoModel()
        self.ui.todoView.setModel(self.model)
        # Connect buttons to their respective methods
        self.ui.addButton.pressed.connect(self.add)
        self.ui.deleteButton.pressed.connect(self.delete)

    # ...

    def delete(self):
        if indexes := self.ui.todoView.selectedIndexes():
            index = indexes[0]
            self.model.delete(index.row())
            self.ui.todoView.clearSelection()

We use self.todoView.selectedIndexes to get the indexes (actually a list of a single item, as we're in single-selection mode) and then use the row() as an index into our model's delete() method. Finally, we clear the active selection since the item it relates to may now be out of bounds (if you had selected the last item).

You could try to make this smarter, and select the last item in the list instead

Completing Todo Items

Now, we will provide the functionality for completing a given todo. First, go to the TodoModel class and add the following method:

python
class TodoModel(QAbstractListModel):
    # ...
    def complete(self, index):
        try:
            self.todos[index] = (True, self.todos[index][1])
        except IndexError:
            return
        self._save()

This uses the same indexing as for delete(), but this time we fetch the item from the model's todos list and then replace the status with True. We have to do this fetch-and-replace, as our data is stored as Python tuples, which cannot be modified.

The complete() method in the MainWindow class looks like this:

python
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = MainWindowUI()
        self.ui.setupUi(self)
        self.model = TodoModel()
        self.ui.todoView.setModel(self.model)
        # Connect buttons to their respective methods
        self.ui.addButton.pressed.connect(self.add)
        self.ui.deleteButton.pressed.connect(self.delete)
        self.ui.completeButton.pressed.connect(self.complete)

    # ...

    def complete(self):
        if indexes := self.ui.todoView.selectedIndexes():
            index = indexes[0]
            self.model.complete(index.row())
            self.ui.todoView.clearSelection()

In complete(), we check the selected item and grab its index. Then, we call the model's delete() method with that index as an argument. Finally, we clear the selection as usual.

The key difference here vs. standard Qt widgets is that we make changes directly to our data, and simply need to notify Qt that some change has occurred — updating the widget state is handled automatically.

Marking Items as Completed

If you run the application now, you'll find that adding and deleting both work, but while completing items is working, there is no indication of it in the view. We need to update our model to provide the view with an indicator to display when an item is complete. The updated model is shown below:

python
class TodoModel(QAbstractListModel):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.todos = self._load()
        self.tick = QImage("tick.png")

    # ...

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            _, text = self.todos[index.row()]
            return text

        if role == Qt.ItemDataRole.DecorationRole:
            status, _ = self.todos[index.row()]
            if status:
                return self.tick
    # ...

We're using a tick icon tick.png to indicate completed items, which we load into a QImage object in the tick attribute. In the model, we've implemented a handler for the Qt.ItemDataRole.DecorationRole which returns the tick icon for rows who's status is True (for complete).

The icon I'm using is taken from the Fugue set by p.yusukekamiyamane

Instead of an icon you can also return a color, e.g. QtGui.QColor('green') which will be drawn as solid square.

Running the app you should now be able to mark items as complete.

Todos Marked Complete Todos Marked Complete

The Complete Todo App's Code

The final code looks like the following:

python
import json

from PyQt6 import uic
from PyQt6.QtCore import QAbstractListModel, Qt
from PyQt6.QtGui import QImage
from PyQt6.QtWidgets import QApplication, QMainWindow

MainWindowUI, _ = uic.loadUiType("mainwindow.ui")

class TodoModel(QAbstractListModel):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.todos = self._load()
        self.tick = QImage("tick.png")

    def _load(self):
        try:
            with open("data.db", mode="r", encoding="utf-8") as todo_db:
                return json.load(todo_db)
        except FileNotFoundError:
            return []

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            _, text = self.todos[index.row()]
            return text

        if role == Qt.ItemDataRole.DecorationRole:
            status, _ = self.todos[index.row()]
            if status:
                return self.tick

    def rowCount(self, index):
        return len(self.todos)

    def add(self, todo_text):
        self.todos.append((False, todo_text))
        self._save()

    def _save(self):
        with open("data.db", mode="w", encoding="utf-8") as todo_db:
            json.dump(self.todos, todo_db)
        self.layoutChanged.emit()

    def delete(self, index):
        try:
            del self.todos[index]
        except IndexError:
            return
        self._save()

    def complete(self, index):
        try:
            self.todos[index] = (True, self.todos[index][1])
        except IndexError:
            return
        self._save()

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = MainWindowUI()
        self.ui.setupUi(self)
        self.model = TodoModel()
        self.ui.todoView.setModel(self.model)
        # Connect buttons to their respective methods
        self.ui.addButton.pressed.connect(self.add)
        self.ui.deleteButton.pressed.connect(self.delete)
        self.ui.completeButton.pressed.connect(self.complete)

    def add(self):
        if text := self.ui.todoEdit.text():
            self.model.add(text)
            self.ui.todoEdit.clear()

    def delete(self):
        if indexes := self.ui.todoView.selectedIndexes():
            index = indexes[0]
            self.model.delete(index.row())
            self.ui.todoView.clearSelection()

    def complete(self):
        if indexes := self.ui.todoView.selectedIndexes():
            index = indexes[0]
            self.model.complete(index.row())
            self.ui.todoView.clearSelection()

app = QApplication([])
window = MainWindow()
window.show()
app.exec()

If your application's data has the potential to become large or more complex, you may prefer to store it in an actual database. In this case, the model will wrap the interface to the database and query it directly for data to display.

For another interesting example of a QListView see the media player application we've built. It uses the Qt built-in QMediaPlaylist as the datastore, with the contents displayed in a QListView.

Conclusion

In this tutorial, you've built a simple yet functional todo application using PyQt and a custom data model. By inheriting QAbstractListModel, you gained fine-grained control over how todo items are displayed and managed.

The TodoModel class handles item storage, completion, and deletion while maintaining data persistence with JSON. Meanwhile, the MainWindow class---the View---connects UI elements to interactive logic, creating a seamless user experience. This demonstrates the power of combining Qt's ModelView architecture.

Packaging Python Applications with PyInstaller by Martin Fitzpatrick — This step-by-step guide walks you through packaging your own Python applications from simple examples to complete installers and signed executables.

More info Get the book

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak
Martin Fitzpatrick

The ModelView Architecture in PyQt6 was written by Martin Fitzpatrick with contributions from Leo Well.

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt. Martin founded PythonGUIs to provide easy to follow GUI programming tutorials to the Python community. He has written a number of popular Python books on the subject.