Building Professional Desktop Applications with Python and PySide6: From Qt Designer to Advanced Event Handling

Python tutorial - IT technology blog
Python tutorial - IT technology blog

Why Choose PySide6 Over Tkinter or PyQt5?

My automation project started at just 200 lines — a small terminal script was all I needed. But six months later, it had ballooned to 2,000 lines, and clients wanted a real interface, something they could click instead of type. I tried Tkinter first — fast to set up, but the UI looked like something out of Windows 98. Then I switched to PySide6 and never looked back.

PySide6 is the official Qt 6 binding for Python, developed by The Qt Company and licensed under LGPL — meaning you can use it in commercial projects for free. Compared to PyQt5 (or PyQt6), PySide6 offers a more permissive license with an equivalent API. Compared to Tkinter, it provides modern widgets, CSS-like styling support, and most importantly: Qt Designer — a visual drag-and-drop UI design tool.

After six months of real-world use, here’s what I’ve learned about structuring a PySide6 application correctly from day one.

Setting Up the Environment

Start with a virtualenv to avoid conflicts with other projects:

python -m venv venv
source venv/bin/activate  # Linux/macOS
# or: venv\Scripts\activate  # Windows

pip install PySide6

This installs the full Qt 6 binding including Qt Designer, Qt Quick, and all required modules. It’s around 150MB — the first download takes a while, but you only need to do it once.

Verify the installation:

python -c "import PySide6; print(PySide6.__version__)"

Launch Qt Designer directly from the terminal:

pyside6-designer

Designing the UI with Qt Designer

Qt Designer is where PySide6 really shines over other Python GUI libraries. Instead of writing layout code by hand, you drag and drop widgets, set properties, and save everything as a .ui file (which is just XML under the hood). It took me a couple of sessions to get comfortable with Designer — after that, my productivity jumped significantly.

Creating Your First .ui File

In Designer, go to File → New → Main Window. Drag widgets from the left panel onto the canvas:

  • QLabel — displays static text
  • QLineEdit — input field
  • QPushButton — clickable button
  • QTextEdit — multi-line text area

Set an objectName for each widget in the Properties panel — this is the name you’ll use in your Python code. Choose meaningful names: btn_submit, input_username, label_status.

Save the file as main_window.ui.

Converting .ui to Python

There are two ways to load a .ui file in your code:

Option 1: Load directly at runtime (convenient for development):

from PySide6.QtWidgets import QApplication, QMainWindow
from PySide6.QtUiTools import QUiLoader
from PySide6.QtCore import QFile
import sys

loader = QUiLoader()
file = QFile("main_window.ui")
file.open(QFile.ReadOnly)
window = loader.load(file)
file.close()
window.show()

Option 2: Convert to a Python class (recommended for production):

pyside6-uic main_window.ui -o ui_main_window.py

The generated ui_main_window.py contains the Ui_MainWindow class, which you can import and inherit from. I use Option 2 because it enables IDE autocomplete for widget names, reducing bugs from typos.

Production-Ready Code Structure

This is the most expensive lesson I learned over six months: don’t cram all your logic into a single class. When a project grows from 200 to 2,000 lines, a flat structure turns into a nightmare. I organize things using a simplified Model-View-Controller pattern:

myapp/
├── main.py              # Entry point
├── ui/
│   ├── main_window.ui   # Qt Designer file
│   └── ui_main_window.py # Auto-generated, do not edit manually
├── views/
│   └── main_window.py   # Controller: connects UI with logic
└── core/
    └── processor.py     # Pure Python business logic

views/main_window.py — Where Everything Connects

from PySide6.QtWidgets import QMainWindow, QMessageBox
from PySide6.QtCore import Qt, QThread, Signal
from ui.ui_main_window import Ui_MainWindow
from core.processor import DataProcessor


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)
        self.processor = DataProcessor()
        self._connect_signals()

    def _connect_signals(self):
        # Connect signals to slots — the heart of Qt
        self.ui.btn_submit.clicked.connect(self._on_submit_clicked)
        self.ui.input_username.returnPressed.connect(self._on_submit_clicked)

    def _on_submit_clicked(self):
        username = self.ui.input_username.text().strip()
        if not username:
            QMessageBox.warning(self, "Error", "Please enter a username")
            return
        result = self.processor.process(username)
        self.ui.label_status.setText(f"Result: {result}")

Handling Heavy Tasks with QThread

The biggest pitfall when starting with Qt: running heavy tasks (API calls, reading large files) directly in a slot will freeze the entire UI. The solution is QThread:

from PySide6.QtCore import QThread, Signal


class WorkerThread(QThread):
    # Signal to send results back to the main thread
    result_ready = Signal(str)
    error_occurred = Signal(str)

    def __init__(self, username: str):
        super().__init__()
        self.username = username

    def run(self):
        # Runs in a separate thread — do NOT update the UI here
        try:
            import time
            time.sleep(2)  # Simulate a heavy task
            result = f"Processed: {self.username.upper()}"
            self.result_ready.emit(result)
        except Exception as e:
            self.error_occurred.emit(str(e))


# In MainWindow._on_submit_clicked():
def _on_submit_clicked(self):
    username = self.ui.input_username.text().strip()
    self.ui.btn_submit.setEnabled(False)  # Disable to prevent double-click
    self.ui.label_status.setText("Processing...")

    self.worker = WorkerThread(username)
    self.worker.result_ready.connect(self._on_result_ready)
    self.worker.error_occurred.connect(self._on_error)
    self.worker.finished.connect(lambda: self.ui.btn_submit.setEnabled(True))
    self.worker.start()

def _on_result_ready(self, result: str):
    self.ui.label_status.setText(result)

def _on_error(self, error: str):
    QMessageBox.critical(self, "Error", error)

Important note: always keep a reference to the worker thread (self.worker) — if you use a local variable instead, Python’s garbage collector will destroy the thread before it finishes running.

Styling with QSS — Qt’s CSS

Qt supports stylesheets similar to CSS, called QSS. I usually load them from a separate file for easy editing:

# In main.py
app = QApplication(sys.argv)
with open("style.qss", "r") as f:
    app.setStyleSheet(f.read())
/* style.qss */
QPushButton {
    background-color: #2563eb;
    color: white;
    border: none;
    padding: 8px 16px;
    border-radius: 4px;
    font-size: 14px;
}

QPushButton:hover {
    background-color: #1d4ed8;
}

QPushButton:disabled {
    background-color: #9ca3af;
}

QLineEdit {
    border: 1px solid #d1d5db;
    border-radius: 4px;
    padding: 6px 10px;
    font-size: 14px;
}

Testing and Packaging the Application

Running the Application

# main.py
import sys
from PySide6.QtWidgets import QApplication
from views.main_window import MainWindow


def main():
    app = QApplication(sys.argv)
    app.setApplicationName("MyApp")
    app.setApplicationVersion("1.0.0")
    window = MainWindow()
    window.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()

Packaging as an .exe or Binary

Use pyinstaller to package the app:

pip install pyinstaller

# Package into a single file
pyinstaller --onefile --windowed \
    --add-data "ui/*.ui:ui" \
    --add-data "style.qss:." \
    main.py

The output file ends up in the dist/ directory. On Windows it produces a .exe, on Linux a binary with no extension, and on macOS a .app bundle.

Common Issues

  • UI freeze: Heavy tasks running on the main thread → use QThread as shown above
  • Widget not updating: Forgot to emit a signal, or updating the UI from a background thread → only update UI from the main thread via signals
  • Missing .ui file after packaging: Add --add-data to your pyinstaller command, or convert to a Python class (Option 2 above) to avoid this entirely
  • Memory leak: Creating widgets without a parent → Qt won’t clean them up automatically. Always pass parent=self when creating child widgets

Summary

After six months with PySide6, here’s my takeaway: its greatest strength is the Signal/Slot system — once you understand how it works, every UI interaction becomes clear and testable. Qt Designer saves a significant amount of time compared to writing layout code by hand.

If your project needs a cross-platform GUI with polished styling and the ability to handle heavy tasks without freezing — PySide6 is a solid choice. Structuring your code with a clean UI/Logic/Core separation from the start will save you from a painful full refactor as the project grows.

Share: