Python PyQt Desktop Apps — Deep Dive

Choosing between PyQt6 and PySide6

The APIs are 95% identical. Key differences:

  • License: PyQt6 uses GPL or requires a commercial license. PySide6 uses LGPL, allowing proprietary applications without a license fee.
  • Signal syntax: PyQt6 uses pyqtSignal, PySide6 uses Signal. A compatibility shim handles this in one line.
  • Tooling: PySide6 ships with the official pyside6-uic, pyside6-rcc, and pyside6-designer. PyQt6 equivalents are pyuic6 and pyrcc6.

For open-source projects, either works. For commercial products, PySide6 avoids licensing costs.

Custom Model/View with delegates

The Model/View framework shines when you customize rendering and editing:

from PyQt6.QtWidgets import QStyledItemDelegate, QSpinBox
from PyQt6.QtCore import Qt, QAbstractTableModel

class InventoryModel(QAbstractTableModel):
    def __init__(self, data):
        super().__init__()
        self._data = data  # list of dicts
        self._columns = ["name", "quantity", "price"]

    def rowCount(self, parent=None):
        return len(self._data)

    def columnCount(self, parent=None):
        return len(self._columns)

    def data(self, index, role=Qt.ItemDataRole.DisplayRole):
        if role == Qt.ItemDataRole.DisplayRole:
            return self._data[index.row()][self._columns[index.column()]]

    def flags(self, index):
        return super().flags(index) | Qt.ItemFlag.ItemIsEditable

    def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
        if role == Qt.ItemDataRole.EditRole:
            self._data[index.row()][self._columns[index.column()]] = value
            self.dataChanged.emit(index, index)
            return True
        return False

class SpinBoxDelegate(QStyledItemDelegate):
    def createEditor(self, parent, option, index):
        editor = QSpinBox(parent)
        editor.setRange(0, 99999)
        return editor

    def setEditorData(self, editor, index):
        editor.setValue(int(index.data()))

    def setModelData(self, editor, model, index):
        model.setData(index, editor.value())

Delegates control how cells render and which editor widget appears during inline editing — essential for spreadsheet-like interfaces.

Docking and multi-panel layouts

QDockWidget creates detachable, rearrangeable panels similar to IDE layouts:

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setCentralWidget(CodeEditor())

        explorer = QDockWidget("Explorer", self)
        explorer.setWidget(FileTreeView())
        self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, explorer)

        console = QDockWidget("Console", self)
        console.setWidget(ConsoleWidget())
        self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, console)

        # Save and restore layout
        self.restoreState(settings.value("windowState", b""))

Users can drag panels to new positions, float them as separate windows, or close them entirely. saveState() and restoreState() persist the arrangement between sessions.

Asynchronous patterns beyond QThread

For modern async code, integrate Python’s asyncio with Qt’s event loop using qasync:

import asyncio
import qasync

async def fetch_data(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            return await resp.json()

class App(QMainWindow):
    async def on_refresh(self):
        self.status_bar.showMessage("Loading...")
        data = await fetch_data("https://api.example.com/items")
        self.model.update(data)
        self.status_bar.showMessage(f"Loaded {len(data)} items")

app = QApplication(sys.argv)
loop = qasync.QEventLoop(app)
asyncio.set_event_loop(loop)
window = App()
window.show()
loop.run_forever()

This avoids thread complexity for I/O-bound work while keeping the UI responsive.

QML for modern UI layers

Qt Quick (QML) is a declarative language for building fluid, animated interfaces. You can embed QML in Python:

from PyQt6.QtQuick import QQuickView
from PyQt6.QtCore import QUrl

view = QQuickView()
view.setSource(QUrl.fromLocalFile("main.qml"))
view.show()

QML excels at animated dashboards, touch interfaces, and custom-styled applications where CSS-like styling isn’t enough. The Python backend handles logic while QML handles presentation — a clean separation layer.

Resource system and i18n

Qt’s resource system bundles icons, images, and translations into the binary:

# compile resources
# pyrcc6 resources.qrc -o resources_rc.py

from PyQt6.QtCore import QTranslator, QLocale

translator = QTranslator()
translator.load(f"translations/app_{QLocale.system().name()}")
app.installTranslator(translator)

Use self.tr("Save File") to mark translatable strings. Qt Linguist provides a GUI for translators to work with .ts files.

Testing Qt applications

pytest-qt provides fixtures for simulating user interaction:

def test_button_updates_label(qtbot):
    widget = MyWidget()
    qtbot.addWidget(widget)
    qtbot.mouseClick(widget.button, Qt.MouseButton.LeftButton)
    assert widget.label.text() == "Clicked!"

For model tests, instantiate models directly and call data(), setData(), and rowCount() without any GUI — one of the benefits of the Model/View split.

Deployment and packaging

Build standalone executables with PyInstaller or Nuitka:

pyinstaller --windowed --name "MyApp" --icon=icon.ico main.py

For platform-native installers:

  • Windows: Use NSIS or Inno Setup on the PyInstaller output folder
  • macOS: py2app or PyInstaller produces a .app bundle; wrap it in a .dmg with create-dmg
  • Linux: Package as an AppImage, Flatpak, or Snap for broad compatibility

PySide6 also supports pyside6-deploy which wraps Nuitka for a more integrated build experience.

Performance profiling

When the UI feels sluggish:

  1. Qt’s built-in logging: Set QT_LOGGING_RULES="qt.*.debug=true" to see event processing details.
  2. Model performance: Implement canFetchMore() and fetchMore() for lazy loading in large models instead of loading everything upfront.
  3. Paint events: Override paintEvent carefully — avoid allocating objects inside paint calls. Use QPixmapCache for repeated drawing.
  4. Signal storms: Disconnect signals during bulk data updates, then reconnect and emit a single layoutChanged signal.

Undo/redo framework

Qt provides QUndoStack and QUndoCommand for implementing undo/redo in any editor:

from PyQt6.QtWidgets import QUndoStack, QUndoCommand

class ChangeValueCommand(QUndoCommand):
    def __init__(self, model, index, old_value, new_value):
        super().__init__(f"Change {old_value}{new_value}")
        self.model = model
        self.index = index
        self.old_value = old_value
        self.new_value = new_value

    def redo(self):
        self.model.setData(self.index, self.new_value)

    def undo(self):
        self.model.setData(self.index, self.old_value)

undo_stack = QUndoStack()
undo_stack.push(ChangeValueCommand(model, idx, "old", "new"))

Connect undo_stack.createUndoAction() and undo_stack.createRedoAction() to menu items for standard Edit menu behavior with keyboard shortcuts included automatically. The stack also supports grouping multiple commands into a single undo step via macros.

One thing to remember: PyQt’s power comes from the full Qt ecosystem — Model/View for data, QDockWidget for IDE-like layouts, QML for modern visuals, and a mature deployment pipeline. The learning curve is steeper than Tkinter, but the ceiling is production-grade desktop software.

pythonpyqtguidesktopqt

See Also

  • Python Dearpygui Imagine a video game menu builder for Python — Dear PyGui uses your graphics card to draw fast, smooth interfaces for tools and dashboards.
  • Python Kivy Mobile Apps Imagine writing one recipe that works in every kitchen — Kivy lets you build a single Python app that runs on phones, tablets, and computers.
  • Python Tkinter Gui Think of building a cardboard control panel to understand how Python's built-in Tkinter lets you create windows, buttons, and text boxes without installing anything extra.
  • Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.
  • Containerization Why does software that works on your computer break on everyone else's? Containers fix that — and they're why Netflix can deploy 100 updates a day without the site going down.