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 usesSignal. A compatibility shim handles this in one line. - Tooling: PySide6 ships with the official
pyside6-uic,pyside6-rcc, andpyside6-designer. PyQt6 equivalents arepyuic6andpyrcc6.
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:
py2appor PyInstaller produces a.appbundle; wrap it in a.dmgwithcreate-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:
- Qt’s built-in logging: Set
QT_LOGGING_RULES="qt.*.debug=true"to see event processing details. - Model performance: Implement
canFetchMore()andfetchMore()for lazy loading in large models instead of loading everything upfront. - Paint events: Override
paintEventcarefully — avoid allocating objects inside paint calls. UseQPixmapCachefor repeated drawing. - Signal storms: Disconnect signals during bulk data updates, then reconnect and emit a single
layoutChangedsignal.
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.
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.