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-datato 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=selfwhen 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.

