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.
in this skill
patterns
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.
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 thisWidget Lifecycle Event Order
__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 deletedcloseEvent — 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.
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.
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.
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 Type | Behaviour | When to Use |
|---|---|---|
AutoConnection (default) | Qt picks Direct or Queued based on thread | Always safe — use this by default |
DirectConnection | Slot called immediately in emitter's thread | Only when both objects share the same thread |
QueuedConnection | Slot posted to receiver's event loop | Cross-thread (explicit, for clarity) |
BlockingQueuedConnection | emit() blocks until slot returns | Rarely — 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.
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:
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
# ❌ 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 disconnected3 — 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.
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.
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
# ❌ 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 resort4 — Layouts & Widget Structure
| Class | Use When |
|---|---|
QVBoxLayout | Stack widgets vertically |
QHBoxLayout | Place widgets side by side |
QGridLayout | Grid with row/column spans |
QFormLayout | Label + field pairs (settings forms) |
QStackedWidget | Multiple pages, one visible at a time |
QSplitter | Resizable panes, user-draggable divider |
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
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
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
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.
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
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.
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)
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
# 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:
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.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.run() slot calls asyncio.run(coro). Simple, no extra deps, but creates a new event loop per task.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()| Approach | Pros | Cons |
|---|---|---|
| qasync | Native async/await everywhere, clean code | Extra dependency, replaces Qt event loop |
| Thread with own loop | No extra deps, worker fully isolated | More boilerplate, harder to cancel |
| asyncio.run in QThread | Simplest for one-off tasks | New 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).
| Tool | Output | Build Speed | Best For |
|---|---|---|---|
| PyInstaller ⭐ | folder or .exe | Fast | Most projects — start here |
| Nuitka | compiled binary | Slow (5–20×) | Performance / obfuscation |
| cx_Freeze | folder | Medium | Cross-platform, no UPX |
| briefcase | platform-native | Slow | macOS .app, Linux AppImage |
PyInstaller Spec File (Recommended over CLI Flags)
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")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:
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
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.
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) orQueuedConnection - Every widget has a correct parent — no orphan local variable widgets
- All
QThreadinstances are stopped cleanly incloseEvent closeEventoverridden to saveQSettingsgeometry & 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()ortime.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 widgetto destroy a widget — usedeleteLater() - Subclassing
QThreadfor the Worker logic — usemoveToThread()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 — useresource_path() - Calling
thread.start()before connecting all signals — risks missing emissions
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.
Hosted by ZynU Host · host.zynu.net