🐍 Python·Mar 2026·8 rules · 25 min read

pyside6-best-practices

A production-grade guide to building robust Python desktop applications with PySide6 (Qt for Python / Qt6). Covers the two-garbage-collector ownership model, the Worker + QThread pattern that eliminates UI freezes, signal/slot architecture for decoupled components, layout management, the Model/View pattern for large datasets, QSS dark theming, asyncio integration via qasync, and cross-platform distribution with PyInstaller and Nuitka.

QThreadWorkerSignal/SlotQSSQAbstractTableModelmoveToThreadqasyncPyInstallerNuitkaQSettingsQSplitterQStackedWidgetdeleteLatershiboken6QRunnable

Why PySide6 — and What This Skill Covers

PySide6 is the official Qt6 binding for Python, maintained by The Qt Company. Unlike Tkinter, it provides a complete, production-ready widget toolkit with native rendering on Windows, macOS, and Linux, a mature signal/slot system, hardware-accelerated graphics, a built-in threading model, and excellent tooling for packaging desktop applications.

8
Rule files
in this skill
3
Threading
patterns
2
GC systems
to bridge

The biggest source of bugs in PySide6 apps is not Qt itself, but the tension between Qt's C++ ownership model and Python's reference-counting GC. This skill addresses that tension head-on in every section, starting with the most important concept of all: widget lifecycle.

1 — Widget Lifecycle & Memory Management

PySide6 bridges two garbage-collection systems simultaneously. Qt (C++) destroys a child widget when its parent is destroyed. Python destroys an object when no Python reference points to it. When Qt deletes the C++ side while Python still holds a reference, you get the most common PySide6 crash:

❌ The crash you will see

RuntimeError: Internal C++ object (QLabel) already deleted.
Root cause: Qt destroyed the widget, but a Python variable or list still held a reference to the now-dead C++ object.

The Parent-Child Ownership Rule

Every widget either gets a parent= argument, enters a layout (which sets the parent automatically), or is stored as self.something. A local variable widget with no parent will be silently garbage-collected — the widget disappears from the window with no error.

pythonparent ownership — correct patterns
class MainWindow(QMainWindow):
    def _setup_ui(self) -> None:
        central = QWidget(parent=self)       # Qt owns via parent
        self.setCentralWidget(central)
        layout = QVBoxLayout(central)        # central is the layout's parent

        # addWidget() sets parent automatically — safe as a local var
        ok_btn = QPushButton("OK", parent=self)
        layout.addWidget(ok_btn)

        # ✅ Instance variable — alive for the entire object's lifetime
        self.status_label = QLabel("Ready")
        layout.addWidget(self.status_label)

        # ❌ Local var with no parent → GC'd silently → widget disappears
        ghost = QLabel("You won't see this for long")
        layout.addWidget(ghost)             # DON'T do this

Widget Lifecycle Event Order

textlifecycle sequence
__init__()
    ↓
showEvent()      ← first time widget becomes visible
    ↓
paintEvent()     ← each repaint
    ↓
resizeEvent()    ← on size change
    ↓
[events → signals → slots]
    ↓
closeEvent()     ← window asked to close  ← YOUR CLEANUP HOOK
    ↓
hideEvent()      ← hidden (not yet destroyed)
    ↓
destroyed        ← C++ object about to be deleted

closeEvent — The Right Way to Clean Up

Override closeEvent in every QMainWindow. This is where you stop background threads, save application state, and release file handles. Failing to stop threads here causes the process to hang on exit.

pythoncloseEvent — canonical implementation
from PySide6.QtGui import QCloseEvent
from PySide6.QtCore import QSettings, QThread


class MainWindow(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        self._worker_thread: QThread | None = None
        self._restore_settings()

    def closeEvent(self, event: QCloseEvent) -> None:
        # 1. Stop all background threads gracefully
        if self._worker_thread and self._worker_thread.isRunning():
            self._worker_thread.quit()
            if not self._worker_thread.wait(3000):   # 3-second timeout
                self._worker_thread.terminate()       # last resort

        # 2. Persist application state
        s = QSettings("MyCompany", "MyApp")
        s.setValue("geometry",    self.saveGeometry())
        s.setValue("windowState", self.saveState())

        event.accept()   # call event.ignore() to cancel close

    def _restore_settings(self) -> None:
        s = QSettings("MyCompany", "MyApp")
        if geom := s.value("geometry"):
            self.restoreGeometry(geom)
        if state := s.value("windowState"):
            self.restoreState(state)

Safe Deletion with deleteLater

Never use del widget or widget = None to destroy a widget — this only drops the Python reference; the C++ object may linger or crash. Use deleteLater(), which schedules deletion at the next event-loop iteration.

pythonsafe widget removal
def remove_row(self, widget: QWidget) -> None:
    self.layout.removeWidget(widget)   # remove from layout (doesn't destroy)
    widget.setParent(None)             # detach from parent
    widget.deleteLater()               # schedule safe deletion

# Guard against "already deleted" errors using shiboken6
try:
    from shiboken6 import isValid
except ImportError:
    def isValid(obj: object) -> bool:
        return True  # fallback

def safe_update(widget: QLabel, text: str) -> None:
    if isValid(widget):
        widget.setText(text)

2 — Signal & Slot Patterns

Signals and slots are Qt's core messaging mechanism: thread-safe, loosely coupled, and the only correct way to communicate between components — especially across threads. A signal announces that something happened; a slot responds to it.

pythondefining and connecting signals
from PySide6.QtCore import QObject, Signal


class DataProcessor(QObject):
    # Signals are class-level variables — never instance variables
    started         = Signal()
    progress        = Signal(int)        # carries one int (0-100)
    result_ready    = Signal(dict)       # carries a dict
    error_occurred  = Signal(str, int)   # message + error code
    finished        = Signal()


class MainWindow(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        self._processor = DataProcessor()
        self._connect_signals()

    def _connect_signals(self) -> None:
        p = self._processor
        p.started.connect(self._on_started)
        p.progress.connect(self.progress_bar.setValue)   # direct Qt slot
        p.result_ready.connect(self._on_result)
        p.error_occurred.connect(self._on_error)
        p.finished.connect(self._on_finished)

        # Lambda — keep short, lambdas are hard to disconnect later
        p.started.connect(lambda: self.btn_run.setEnabled(False))

Connection Types & Thread Safety

Connection TypeBehaviourWhen to Use
AutoConnection (default)Qt picks Direct or Queued based on threadAlways safe — use this by default
DirectConnectionSlot called immediately in emitter's threadOnly when both objects share the same thread
QueuedConnectionSlot posted to receiver's event loopCross-thread (explicit, for clarity)
BlockingQueuedConnectionemit() blocks until slot returnsRarely — can deadlock if misused

Decoupled Widget Communication

The golden rule: widgets never talk directly to each other. Each widget exposes signals. MainWindow wires them together. This keeps widgets reusable and independently testable.

pythondecoupled pattern — MainWindow as the wirer
class SearchBar(QWidget):
    search_requested = Signal(str)   # SearchBar knows nothing about ResultTable

    def _on_return_pressed(self) -> None:
        self.search_requested.emit(self._input.text())


class ResultTable(QWidget):
    def filter_by(self, query: str) -> None:
        ...  # plain method slot


class MainWindow(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        self._search  = SearchBar()
        self._results = ResultTable()
        # Wire them together in one place
        self._search.search_requested.connect(self._results.filter_by)

Application Signal Bus

For large apps where many unrelated components need to react to the same events, a singleton signal bus avoids a tangle of direct connections:

pythoncentralised signal bus
from __future__ import annotations
from PySide6.QtCore import QObject, Signal

class AppBus(QObject):
    """Application-wide signal bus — instantiate once."""
    user_logged_in   = Signal(dict)
    user_logged_out  = Signal()
    data_refreshed   = Signal()
    item_selected    = Signal(int)
    theme_changed    = Signal(str)

_bus: AppBus | None = None

def bus() -> AppBus:
    global _bus
    if _bus is None:
        _bus = AppBus()
    return _bus

# Anywhere in the app:
bus().user_logged_in.emit({"id": 1, "name": "Alice"})
bus().theme_changed.connect(self._apply_theme)

Common Signal Mistakes

pythonanti-patterns to avoid
# ❌ Lambda capturing mutable loop variable — all callbacks get the last i
for i, btn in enumerate(buttons):
    btn.clicked.connect(lambda: self._select(i))

# ✅ Capture by value with a default argument
for i, btn in enumerate(buttons):
    btn.clicked.connect(lambda checked=False, idx=i: self._select(idx))


# ❌ Connecting inside a loop — duplicates slots, N calls per click
for item in items:
    item.clicked.connect(self._on_click)

# ✅ Connect once, outside the loop


# ❌ Forgetting to disconnect in closeEvent → use-after-free crash
# ✅ Always disconnect in closeEvent or use deleteLater on the sender
try:
    self._processor.data_ready.disconnect(self._on_data)
except RuntimeError:
    pass  # already disconnected

3 — Threading: The Worker + QThread Pattern

The Golden Rule

Never run blocking code on the main (GUI) thread. Every time.sleep(), network request, file read, or CPU-heavy loop that runs on the main thread freezes the entire UI. Qt's solution: move the work to a QThread and send results back via signals.

Pattern 1: Worker Object + moveToThread (Recommended)

The Worker owns the logic. QThread owns the OS thread. The Worker is moved into the thread, not subclassed from it. This is the most flexible and correct pattern for the vast majority of use cases.

pythonworker + moveToThread — full implementation
from PySide6.QtCore import QObject, QThread, Signal, Slot


# ── 1. Define the Worker ─────────────────────────────────────────
class DownloadWorker(QObject):
    """Runs in a background thread. Never touch GUI objects here."""
    progress = Signal(int)       # 0-100
    result   = Signal(bytes)
    error    = Signal(str)
    finished = Signal()          # always emitted last

    def __init__(self, url: str) -> None:
        super().__init__()
        self._url   = url
        self._abort = False

    @Slot()
    def run(self) -> None:
        try:
            import requests
            r = requests.get(self._url, stream=True, timeout=30)
            r.raise_for_status()
            chunks, total, fetched = [], int(r.headers.get("content-length", 0)), 0
            for chunk in r.iter_content(8192):
                if self._abort:
                    return
                chunks.append(chunk)
                fetched += len(chunk)
                if total:
                    self.progress.emit(int(fetched / total * 100))
            self.result.emit(b"".join(chunks))
        except Exception as exc:
            self.error.emit(str(exc))
        finally:
            self.finished.emit()

    def abort(self) -> None:
        self._abort = True   # thread-safe: bool write is atomic in CPython


# ── 2. Wire it up in the Window ──────────────────────────────────
class MainWindow(QMainWindow):
    def _start_download(self) -> None:
        self._thread = QThread(parent=self)
        self._worker = DownloadWorker("https://example.com/bigfile.zip")
        self._worker.moveToThread(self._thread)

        # Connect signals BEFORE starting the thread (avoids race conditions)
        self._worker.progress.connect(self.progress_bar.setValue)
        self._worker.result.connect(self._on_result)
        self._worker.error.connect(self._on_error)
        self._worker.finished.connect(self._on_finished)

        # Lifecycle wiring
        self._thread.started.connect(self._worker.run)
        self._worker.finished.connect(self._thread.quit)
        self._worker.finished.connect(self._worker.deleteLater)
        self._thread.finished.connect(self._thread.deleteLater)

        self.btn_start.setEnabled(False)
        self._thread.start()

    def closeEvent(self, event) -> None:
        if self._worker:
            self._worker.abort()
        if self._thread and self._thread.isRunning():
            self._thread.quit()
            self._thread.wait(3000)
        event.accept()

Pattern 2: QRunnable + QThreadPool (Fire-and-Forget)

Best for short, independent tasks where you don't need cancellation or lifecycle control. Qt manages a thread pool automatically.

pythonQRunnable — fire-and-forget
from PySide6.QtCore import QRunnable, QThreadPool, QObject, Signal, Slot

class _Signals(QObject):
    result   = Signal(object)
    error    = Signal(str)
    finished = Signal()

class HashTask(QRunnable):
    def __init__(self, data: bytes) -> None:
        super().__init__()
        self.signals = _Signals()
        self._data   = data
        self.setAutoDelete(True)

    @Slot()
    def run(self) -> None:
        try:
            import hashlib
            self.signals.result.emit(hashlib.sha256(self._data).hexdigest())
        except Exception as exc:
            self.signals.error.emit(str(exc))
        finally:
            self.signals.finished.emit()

# Usage
task = HashTask(b"hello world")
task.signals.result.connect(lambda h: print(f"Hash: {h}"))
QThreadPool.globalInstance().start(task)

Threading Mistakes to Never Make

pythonanti-patterns
# ❌ Touching a widget directly from a background thread → crash
def run(self):
    self.label.setText("done")    # widget lives in main thread!

# ✅ Emit a signal — Qt delivers it to the main thread via the event loop
def run(self):
    self.finished.emit("done")


# ❌ Starting thread before connecting signals → missed emissions
thread.start()
worker.finished.connect(self._on_done)  # race condition!

# ✅ Always connect signals BEFORE calling thread.start()


# ❌ Calling thread.terminate() as the first option — leaks resources
# ✅ Request abort → wait → terminate only as absolute last resort

4 — Layouts & Widget Structure

ClassUse When
QVBoxLayoutStack widgets vertically
QHBoxLayoutPlace widgets side by side
QGridLayoutGrid with row/column spans
QFormLayoutLabel + field pairs (settings forms)
QStackedWidgetMultiple pages, one visible at a time
QSplitterResizable panes, user-draggable divider
pythonMainWindow — structural skeleton
class MainWindow(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        self.setWindowTitle("My App")
        self.setMinimumSize(1024, 720)
        self._setup_ui()
        self._setup_menu()
        self._setup_statusbar()

    def _setup_ui(self) -> None:
        central = QWidget()
        self.setCentralWidget(central)          # QMainWindow requires this

        main_layout = QHBoxLayout(central)
        main_layout.setContentsMargins(0, 0, 0, 0)
        main_layout.setSpacing(0)

        self.sidebar = SidebarWidget()
        self.content = QStackedWidget()

        main_layout.addWidget(self.sidebar, stretch=0)  # fixed width
        main_layout.addWidget(self.content, stretch=1)  # takes all spare space

    def _setup_menu(self) -> None:
        file_menu = self.menuBar().addMenu("&File")
        file_menu.addAction("&Open",  self._on_open,  "Ctrl+O")
        file_menu.addAction("&Save",  self._on_save,  "Ctrl+S")
        file_menu.addSeparator()
        file_menu.addAction("E&xit",  self.close,     "Ctrl+Q")

    def _setup_statusbar(self) -> None:
        self.statusBar().showMessage("Ready")

QSplitter — Resizable Panes with Persistence

pythonQSplitter with state persistence
from PySide6.QtWidgets import QSplitter
from PySide6.QtCore import Qt, QSettings

splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(self.file_tree)
splitter.addWidget(self.editor)
splitter.setSizes([220, 780])         # initial pixel widths
splitter.setStretchFactor(1, 1)       # editor takes all extra space

# Persist the user's position across restarts
def _save_settings(self) -> None:
    QSettings("Co", "App").setValue("splitter", splitter.saveState())

def _restore_settings(self) -> None:
    if state := QSettings("Co", "App").value("splitter"):
        splitter.restoreState(state)

QStackedWidget — Multi-Page Navigation

pythonpage navigation pattern
self.pages = QStackedWidget()
self.pages.addWidget(DashboardPage())   # index 0
self.pages.addWidget(SettingsPage())    # index 1
self.pages.addWidget(AboutPage())       # index 2

# Lambda captures idx by value — avoids the loop variable bug
nav_items = [("Dashboard", 0), ("Settings", 1), ("About", 2)]
for label, idx in nav_items:
    btn = QPushButton(label)
    btn.clicked.connect(lambda _, i=idx: self.pages.setCurrentIndex(i))
    self.sidebar_layout.addWidget(btn)

Custom Widget Pattern

pythonreusable StatCard widget
class StatCard(QFrame):
    clicked = Signal()

    def __init__(self, title: str, value: str = "—",
                 parent: QWidget | None = None) -> None:
        super().__init__(parent)
        self.setFrameShape(QFrame.Shape.StyledPanel)
        self.setCursor(Qt.CursorShape.PointingHandCursor)

        layout = QVBoxLayout(self)
        layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._title_lbl = QLabel(title)
        self._value_lbl = QLabel(value)
        layout.addWidget(self._title_lbl)
        layout.addWidget(self._value_lbl)

    def set_value(self, value: str) -> None:
        self._value_lbl.setText(value)

    def mousePressEvent(self, event) -> None:
        self.clicked.emit()
        super().mousePressEvent(event)

5 — Model/View Architecture

Model/View separates data from presentation. One model can feed multiple views. Views update efficiently when only the changed rows are re-rendered. Sorting and filtering are handled by a QSortFilterProxyModel layer between the model and the view — no data duplication needed.

pythonQAbstractTableModel — custom table data source
from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex


class TradeModel(QAbstractTableModel):
    HEADERS = ["Symbol", "Side", "Price", "Qty", "PnL"]
    COL_SYMBOL, COL_SIDE, COL_PRICE, COL_QTY, COL_PNL = range(5)

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self._rows: list[dict] = []

    # ── Required overrides ────────────────────────────────────────
    def rowCount(self, parent=QModelIndex()) -> int:
        return 0 if parent.isValid() else len(self._rows)

    def columnCount(self, parent=QModelIndex()) -> int:
        return 0 if parent.isValid() else len(self.HEADERS)

    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
        if not index.isValid():
            return None
        row, col = self._rows[index.row()], index.column()

        if role == Qt.ItemDataRole.DisplayRole:
            return [row["symbol"], row["side"],
                    f"{row['price']:.4f}", str(row["qty"]),
                    f"{row['pnl']:+.2f}"][col]

        if role == Qt.ItemDataRole.ForegroundRole:
            from PySide6.QtGui import QColor
            if col == self.COL_PNL:
                return QColor("green") if row["pnl"] >= 0 else QColor("red")

        if role == Qt.ItemDataRole.TextAlignmentRole:
            if col in (self.COL_PRICE, self.COL_QTY, self.COL_PNL):
                return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter

        return None

    def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
        if role == Qt.ItemDataRole.DisplayRole:
            if orientation == Qt.Orientation.Horizontal:
                return self.HEADERS[section]
        return None

    # ── Mutation helpers ──────────────────────────────────────────
    def set_rows(self, rows: list[dict]) -> None:
        self.beginResetModel()
        self._rows = rows
        self.endResetModel()

    def append_row(self, row: dict) -> None:
        pos = len(self._rows)
        self.beginInsertRows(QModelIndex(), pos, pos)
        self._rows.append(row)
        self.endInsertRows()

    def update_row(self, idx: int, row: dict) -> None:
        self._rows[idx] = row
        self.dataChanged.emit(
            self.index(idx, 0),
            self.index(idx, self.columnCount() - 1)
        )

Wiring a Model to QTableView with Sorting

pythonQTableView + QSortFilterProxyModel
from PySide6.QtWidgets import QTableView, QHeaderView
from PySide6.QtCore import QSortFilterProxyModel


class TradesWidget(QWidget):
    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self._model = TradeModel(parent=self)

        self._proxy = QSortFilterProxyModel(self)
        self._proxy.setSourceModel(self._model)
        self._proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
        self._proxy.setFilterKeyColumn(-1)   # search across all columns

        self._view = QTableView()
        self._view.setModel(self._proxy)
        self._view.setSortingEnabled(True)
        self._view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
        self._view.setAlternatingRowColors(True)
        self._view.setShowGrid(False)
        self._view.verticalHeader().setVisible(False)

        hdr = self._view.horizontalHeader()
        hdr.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)

        QVBoxLayout(self).addWidget(self._view)

    def set_filter(self, text: str) -> None:
        self._proxy.setFilterFixedString(text)

    def selected_source_row(self) -> dict | None:
        indexes = self._view.selectionModel().selectedRows()
        if not indexes:
            return None
        source_idx = self._proxy.mapToSource(indexes[0])
        return self._model._rows[source_idx.row()]

6 — Stylesheet & QSS Theming

Qt Style Sheets (QSS) follow CSS syntax but target Qt widget classes. A global stylesheet applied to QApplication cascades to every widget in the app. Styles on individual widgets override the global sheet for that widget and its children.

cssQSS selector reference
QPushButton          { ... }             /* class */
QPushButton#primary  { ... }             /* object name */
QPushButton[variant="danger"] { ... }    /* dynamic property */
QPushButton:hover    { ... }             /* pseudo-state */
QPushButton:pressed  { ... }
QPushButton:disabled { ... }
QPushButton:checked  { ... }             /* for checkable buttons */
QGroupBox QLabel     { ... }             /* child selector */
QComboBox::drop-down { ... }             /* sub-control */
QScrollBar::handle:vertical { ... }
QProgressBar::chunk  { ... }

Dark Theme Starter (Catppuccin Mocha)

pythondark_theme.qss — production-ready
DARK_THEME = """
QWidget {
    background-color: #1e1e2e;
    color: #cdd6f4;
    font-family: "Segoe UI", "Inter", sans-serif;
    font-size: 13px;
}

QPushButton {
    background-color: #313244;
    color: #cdd6f4;
    border: 1px solid #45475a;
    border-radius: 6px;
    padding: 6px 16px;
    min-height: 28px;
}
QPushButton:hover   { background-color: #45475a; }
QPushButton:pressed { background-color: #585b70; }
QPushButton:disabled { color: #585b70; border-color: #313244; }

QPushButton#primary {
    background-color: #89b4fa;
    color: #1e1e2e;
    border: none;
    font-weight: 600;
}
QPushButton#primary:hover { background-color: #b4befe; }

QLineEdit, QTextEdit {
    background-color: #313244;
    border: 1px solid #45475a;
    border-radius: 6px;
    padding: 5px 8px;
}
QLineEdit:focus { border-color: #89b4fa; }

QTableView {
    background-color: #181825;
    alternate-background-color: #1e1e2e;
    gridline-color: #313244;
    border: none;
}
QHeaderView::section {
    background-color: #313244;
    color: #a6adc8;
    border: none;
    padding: 6px 8px;
    font-weight: 600;
}
"""

app.setStyleSheet(DARK_THEME)

Dynamic Properties for State-Based Styling

pythonproperty-based style switching
# Define in stylesheet
app.setStyleSheet("""
    QLabel[status="ok"]      { color: #a6e3a1; }
    QLabel[status="warning"] { color: #f9e2af; }
    QLabel[status="error"]   { color: #f38ba8; }
""")

# Change at runtime — must call unpolish/polish to re-evaluate QSS
def set_status(self, label: QLabel, status: str) -> None:
    label.setProperty("status", status)
    label.style().unpolish(label)   # ← required
    label.style().polish(label)

7 — Asyncio Integration

Qt and Python's asyncio each want to own the event loop. Running both in the same thread without a bridge is not possible. Three solutions exist, each with different trade-offs:

SOLUTION 01 — Recommended
qasync — replace Qt's loop with asyncio
pip install qasync. Replaces Qt's event loop with an asyncio-compatible loop. You get native async/await throughout the app. Schedule coroutines with asyncio.ensure_future()from regular Qt slots.
SOLUTION 02 — No extra deps
Worker thread runs its own asyncio loop
Start a threading.Thread that runs loop.run_forever(). Submit coroutines from the main thread via asyncio.run_coroutine_threadsafe(coro, loop). Results come back via signals.
SOLUTION 03 — Simplest
asyncio.run inside a QThread
For one-shot coroutines where you don't need a persistent loop. The worker's run() slot calls asyncio.run(coro). Simple, no extra deps, but creates a new event loop per task.
pythonqasync — full application setup
import asyncio
import sys
import qasync
from PySide6.QtWidgets import QApplication, QMainWindow, QPushButton, QLabel, QVBoxLayout, QWidget


class MainWindow(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        central = QWidget()
        self.setCentralWidget(central)
        layout = QVBoxLayout(central)

        self.label = QLabel("Ready")
        self.btn   = QPushButton("Fetch")
        self.btn.clicked.connect(self._on_fetch)

        layout.addWidget(self.label)
        layout.addWidget(self.btn)

    def _on_fetch(self) -> None:
        # Schedule coroutine on the running loop — doesn't block UI
        asyncio.ensure_future(self._fetch_data())

    async def _fetch_data(self) -> None:
        import aiohttp
        self.btn.setEnabled(False)
        self.label.setText("Loading…")
        try:
            async with aiohttp.ClientSession() as session:
                async with session.get("https://api.example.com/data") as resp:
                    data = await resp.json()
            self.label.setText(str(data)[:80])
        except Exception as exc:
            self.label.setText(f"Error: {exc}")
        finally:
            self.btn.setEnabled(True)


def main() -> None:
    app = QApplication(sys.argv)
    loop = qasync.QEventLoop(app)
    asyncio.set_event_loop(loop)
    window = MainWindow()
    window.show()
    with loop:
        loop.run_forever()

if __name__ == "__main__":
    main()
ApproachProsCons
qasyncNative async/await everywhere, clean codeExtra dependency, replaces Qt event loop
Thread with own loopNo extra deps, worker fully isolatedMore boilerplate, harder to cancel
asyncio.run in QThreadSimplest for one-off tasksNew loop per call, no persistent state

8 — Packaging & Distribution

PySide6 apps are distributed as frozen executables — the Python interpreter, all dependencies, and your application code compiled into a single folder or binary. The two main tools are PyInstaller (easiest) and Nuitka (compiled binary, faster startup, harder to reverse).

ToolOutputBuild SpeedBest For
PyInstaller ⭐folder or .exeFastMost projects — start here
Nuitkacompiled binarySlow (5–20×)Performance / obfuscation
cx_FreezefolderMediumCross-platform, no UPX
briefcaseplatform-nativeSlowmacOS .app, Linux AppImage

PyInstaller Spec File (Recommended over CLI Flags)

pythonmyapp.spec
from PyInstaller.utils.hooks import collect_data_files, collect_submodules

a = Analysis(
    ["main.py"],
    datas=[
        ("src/myapp/styles/*.qss",  "myapp/styles"),
        ("src/myapp/assets/*.png",  "myapp/assets"),
    ],
    hiddenimports=[
        "PySide6.QtXml",
        "PySide6.QtSvg",
    ],
    excludes=["tkinter", "matplotlib"],
)

exe = EXE(
    PYZ(a.pure),
    a.scripts,
    [],
    exclude_binaries=True,
    name="MyApp",
    console=False,                  # no console window on Windows
    icon="src/myapp/assets/icon.ico",
)

coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, name="MyApp")
bashbuild commands
pip install pyinstaller

# One-directory build (faster, easier to debug)
pyinstaller myapp.spec --clean

# Output: dist/MyApp/MyApp.exe (Windows) or dist/MyApp/MyApp (Linux/macOS)

Reading Bundled Resources at Runtime

PyInstaller extracts files to a temp directory (sys._MEIPASS). Always use this helper — never rely on __file__ in a frozen app:

pythonresource_path helper
import sys
from pathlib import Path

def resource_path(relative: str) -> Path:
    """Return the absolute path to a bundled resource."""
    if hasattr(sys, "_MEIPASS"):
        base = Path(sys._MEIPASS)     # running from PyInstaller bundle
    else:
        base = Path(__file__).parent  # running from source
    return base / relative

# Usage
stylesheet = resource_path("myapp/styles/dark.qss").read_text()
icon_path   = str(resource_path("myapp/assets/icon.png"))

Nuitka — Compiled Binary

bashnuitka build command
pip install nuitka

python -m nuitka     --onefile     --enable-plugin=pyside6     --windows-disable-console     --windows-icon-from-ico=icon.ico     --include-data-dir=src/myapp/styles=styles     --include-data-dir=src/myapp/assets=assets     --output-dir=dist     main.py

Cross-Platform Build Matrix

You cannot cross-compile

A Windows .exe must be built on Windows. A macOS .app must be built on macOS. Use GitHub Actions with a matrix strategy to build all three platforms automatically on every tag push.

yaml.github/workflows/build.yml
name: Build
on:
  push:
    tags: ["v*"]

jobs:
  build:
    strategy:
      matrix:
        os: [windows-latest, ubuntu-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install pyinstaller PySide6
      - run: pyinstaller myapp.spec --clean
      - uses: actions/upload-artifact@v4
        with:
          name: dist-${{ matrix.os }}
          path: dist/

Pre-Release Checklist

  • All network / file / CPU-heavy operations run in a Worker thread
  • Cross-thread signals use AutoConnection (default) or QueuedConnection
  • Every widget has a correct parent — no orphan local variable widgets
  • All QThread instances are stopped cleanly in closeEvent
  • closeEvent overridden to save QSettings geometry & state
  • Signal connections made before thread.start()
  • No direct widget access from background threads — only signals
  • PyInstaller build verified in a clean virtualenv before shipping
  • Tested on all target platforms (Windows + Linux at minimum)
  • No thread.terminate() as first resort — abort flag + wait()

Anti-Patterns to Avoid

  • Running requests.get() or time.sleep() on the main thread — UI will freeze
  • Updating widgets directly from a background thread — always emit a signal instead
  • Creating widgets as local variables with no parent and no self.xxx — they disappear silently
  • Connecting signals inside a loop without disconnecting — causes N slots to fire per event
  • Using del widget to destroy a widget — use deleteLater()
  • Subclassing QThread for the Worker logic — use moveToThread() instead
  • Using asyncio.run() on the main thread while Qt is running — blocks the event loop
  • Using __file__ for resource paths in a frozen PyInstaller app — use resource_path()
  • Calling thread.start() before connecting all signals — risks missing emissions
AI Skill File

Download pyside6-best-practices Skill

This .skill file contains 8 complete rule files covering every aspect of production PySide6 development, ready to load into Claude or any other AI tool as expert context for your desktop app questions.

8 rule files
Widget lifecycle guide
QThread Worker pattern
Dark QSS theme
Model/View patterns
PyInstaller spec
⬇ Download Skill File

Hosted by ZynU Host · host.zynu.net