Maintained layer, checked 2026-09-01. This guide is for existing PyQt5 applications and teams that have deliberately chosen Qt 5. The original 2019 checklist is preserved verbatim at the end, separate from the maintained instructions.
This practical guide covers the small decisions that prevent common PyQt5 failures: installing into an isolated environment, choosing generated or runtime-loaded Qt Designer forms, building a real QScrollArea, resolving resource paths, connecting and disconnecting exact signal handlers, keeping the GUI thread responsive, cleaning up QThread workers, and moving tabs without losing their metadata.
Table of Contents
Should a New Project Use PyQt5 in 2026?
Riverbank still distributes both PyQt5 and PyQt6. PyQt5 binds Qt 5; PyQt6 binds Qt 6. Keep PyQt5 when maintaining a tested Qt 5 application or depending on a Qt 5-only component. For a new application, evaluate PyQt6 before locking the project to Qt 5, then write the chosen binding and version range into the dependency file rather than relying on whichever package happens to be installed globally.
Licensing is a release requirement, not a final-day detail. Riverbank publishes PyQt under GPL v3 and a commercial license; PyQt is not LGPL. If the application will be distributed under terms that are not GPL-compatible, read Riverbank’s official license material and obtain appropriate advice before shipping.
Install PyQt5 in a Reproducible Environment
Create a disposable virtual environment and install with the interpreter’s pip:
python -m venv .venv
# POSIX shells
source .venv/bin/activate
# Windows PowerShell
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install PyQt5
Check which Python, PyQt, and Qt the application is actually using:
python -c "import sys; from PyQt5.QtCore import PYQT_VERSION_STR, QT_VERSION_STR; print(sys.executable); print('PyQt', PYQT_VERSION_STR, 'Qt', QT_VERSION_STR)"
Riverbank notes that pip may fall back from a wheel to a source distribution when no compatible wheel exists. A cryptic compiler or qmake failure is therefore not evidence that another random Qt package should be installed; first inspect the Python version, platform, pip version, and whether a compatible wheel was selected.
Generate a .py File From a .ui File
Qt Designer stores forms as XML .ui files. PyQt5 officially supports two workflows: generate Python with pyuic5, or load the .ui file dynamically with PyQt5.uic. For generated code, run:
pyuic5 -x main_window.ui -o ui_main_window.py
The -x option adds a small runnable test block. If the generated file will only be imported by another module, omit -x:
pyuic5 main_window.ui -o ui_main_window.py
A useful project structure is:
project/
├── main.py
├── ui_main.py # generated from .ui
├── workers.py # QThread/Worker code
└── resources/
└── icon.png
Keep generated UI files separate from application logic. If the .ui file changes, regenerate ui_main.py instead of editing it manually.
Runtime loading is useful when the form should remain editable without a generation step:
from pathlib import Path
from PyQt5 import uic
from PyQt5.QtWidgets import QMainWindow
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
ui_file = Path(__file__).resolve().parent / "main_window.ui"
uic.loadUi(str(ui_file), self)
Choose one workflow for each form. Generated modules should be reproducible build artifacts; runtime-loaded forms must be included in the installed package. Riverbank also warns that code generated by pyuic5 is not guaranteed to run with an earlier PyQt5 release, so generate with the project’s pinned toolchain.
Make a Widget Scrollable
To make a large form or result panel scrollable, put the content widget inside a QScrollArea:
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QScrollArea, QLabel
content = QWidget()
layout = QVBoxLayout(content)
for i in range(100):
layout.addWidget(QLabel(f"Row {i}"))
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setWidget(content)
If using Qt Designer, add a QScrollArea, place a child widget inside it, and put the actual layout on that child widget. The scroll area itself should usually have widgetResizable enabled.
Change Appearance and Set the Taskbar Icon
Use setWindowTitle() and setWindowIcon() on the main window. Set the application icon before showing the window:
import sys
from pathlib import Path
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QApplication, QMainWindow
base_dir = Path(__file__).resolve().parent
icon = QIcon(str(base_dir / "resources" / "icon.png"))
app = QApplication(sys.argv)
app.setApplicationName("PyQt5 Application")
app.setWindowIcon(icon)
window = QMainWindow()
window.setWindowTitle("PyQt5 Application")
window.setWindowIcon(icon)
window.show()
sys.exit(app.exec_())
For simple styling, use a stylesheet:
window.setStyleSheet("""
QPushButton {
padding: 6px 10px;
}
QLineEdit {
padding: 4px;
}
""")
Keep stylesheets small unless the project has a dedicated theme file. Resolve files from the module or package rather than the process’s current working directory. Qt provides a resource system, exposed in PyQt5 through .qrc files and pyrcc5, when icons and translations should be embedded. The exact taskbar or dock presentation remains platform- and desktop-dependent.
Connect Signals
A signal is connected to a slot with .connect():
self.button.clicked.connect(self.run_task)
A slot can be any callable:
def run_task(self):
print("Button clicked")
To pass arguments, use lambda or functools.partial. Account for a signal’s own arguments; QAbstractButton.clicked may supply a checked state:
self.button.clicked.connect(
lambda _checked=False: self.open_file("data.txt")
)
Prefer connecting once and changing state inside the slot. If a dynamic connection really must be replaced, keep the Connection returned by connect() and disconnect that exact connection:
self._open_connection = self.button.clicked.connect(
lambda _checked=False: self.open_file("data.txt")
)
# Later, before installing a replacement:
self.button.clicked.disconnect(self._open_connection)
self.button.clicked.connect(self.new_handler)
Calling disconnect() without an argument removes every slot from that bound signal. Calling it for a connection that is not present raises an exception. Keeping the returned connection avoids silently removing another component’s handler and is the documented way to disconnect a lambda.
Use pyqtSignal
Custom signals are declared as class attributes:
from PyQt5.QtCore import QObject, pyqtSignal
class Worker(QObject):
progress = pyqtSignal(int)
result = pyqtSignal(str)
failed = pyqtSignal(str)
A signal can emit multiple values:
class Worker(QObject):
finished = pyqtSignal(str, int)
The type list follows this pattern:
pyqtSignal(type1, type2, ...)
For multiple values, prefer emitting structured values directly instead of relying on global variables:
self.finished.emit("done", 100)
A tuple can also be emitted as one Python object:
summary = pyqtSignal(tuple)
self.summary.emit((filename, count, elapsed_seconds))
Run Long Tasks With QThread
Do not run slow work directly in a button handler. It blocks the event loop and freezes the GUI. Qt’s documented worker-object pattern moves a QObject to a separate QThread; results return to the GUI through signals. Never update widgets directly from the worker.
from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
class Worker(QObject):
progress = pyqtSignal(int)
result = pyqtSignal(str)
failed = pyqtSignal(str)
finished = pyqtSignal()
@pyqtSlot()
def run(self):
try:
for i in range(101):
if QThread.currentThread().isInterruptionRequested():
return
# Do part of the long-running task here.
self.progress.emit(i)
self.result.emit("Task complete")
except Exception as exc:
self.failed.emit(str(exc))
finally:
self.finished.emit()
def start_worker(self):
if getattr(self, "thread", None) is not None:
return
self.thread = QThread()
self.worker = Worker()
self.worker.moveToThread(self.thread)
self.thread.started.connect(self.worker.run)
self.worker.progress.connect(self.progress_bar.setValue)
self.worker.result.connect(self.on_worker_finished)
self.worker.failed.connect(self.on_worker_failed)
self.worker.finished.connect(self.thread.quit)
self.thread.finished.connect(self.worker.deleteLater)
self.thread.finished.connect(self.thread.deleteLater)
self.thread.finished.connect(self.clear_worker_references)
self.thread.start()
def cancel_worker(self):
if self.thread is not None:
self.thread.requestInterruption()
def clear_worker_references(self):
self.worker = None
self.thread = None
Keep references to both objects until finished; local-only wrappers may be garbage-collected while work is active. Cancellation must be cooperative: requestInterruption() only sets a flag, so the worker must check it at safe boundaries. Ensure every success, failure, and cancellation path emits finished, quits the event loop, and schedules Qt-owned objects for deletion.
QThread improves responsiveness and suits blocking I/O or work in native code that releases Python’s global interpreter lock. On ordinary GIL-enabled CPython builds, it does not automatically make pure-Python CPU-bound work run in parallel. For that case, measure first and consider a process-based worker; free-threaded CPython builds exist but remain a separate deployment choice whose extension compatibility must be verified.
Change the Order of Tabs
If users should drag tabs themselves, enable the built-in behavior:
self.tabs.setMovable(True)
For a controlled programmatic move, validate the index and preserve the tab’s metadata while using removeTab() and insertTab():
index = self.tabs.indexOf(self.settings_tab)
if index < 0:
raise ValueError("settings_tab is not in the tab widget")
widget = self.tabs.widget(index)
label = self.tabs.tabText(index)
icon = self.tabs.tabIcon(index)
tooltip = self.tabs.tabToolTip(index)
enabled = self.tabs.isTabEnabled(index)
self.tabs.removeTab(index)
self.tabs.insertTab(0, widget, icon, label)
self.tabs.setTabToolTip(0, tooltip)
self.tabs.setTabEnabled(0, enabled)
self.tabs.setCurrentWidget(widget)
If the tabs are created in Qt Designer, it is often easier to reorder them there. Use code when the order depends on user settings or runtime state.
Modularize Preprocessing and Control Logic
A maintainable PyQt project separates UI setup, preprocessing, and control logic:
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.connect_signals()
def connect_signals(self):
self.ui.runButton.clicked.connect(self.run_preprocessing)
def run_preprocessing(self):
options = self.collect_options()
self.start_worker(options)
def collect_options(self):
return {
"input": self.ui.inputLineEdit.text(),
"enabled": self.ui.enableCheckBox.isChecked(),
}
This keeps generated UI code disposable and business logic testable. The main window coordinates the interface; worker classes handle slow orchestration; plain helper modules hold reusable parsing, preprocessing, or calculation functions.
A small project can make those boundaries visible:
project/
├── pyproject.toml
├── src/app/
│ ├── main.py
│ ├── window.py
│ ├── ui_main_window.py # generated; do not hand-edit
│ ├── workers.py
│ ├── services.py # no widget access
│ └── resources/
└── tests/
Failure Checklist
- The window freezes: a slot still performs long work on the GUI thread.
QThread: Destroyed while thread is still running: the controller did not retain the thread/worker or did not stop and wait during application shutdown.- A worker touches a widget: emit data and update the widget in a GUI-thread slot instead.
- A scroll area is blank: put the layout on one child widget before passing that widget to
setWidget(). - An icon works only from the project directory: resolve it from
__file__or use the Qt resource system. - A click runs twice: the same signal was connected more than once; centralize connection setup.
disconnect()raises: track the exact callable or returnedConnectioninstead of guessing connection state.- A tab loses its tooltip or enabled state: preserve all metadata around remove/insert, or let the user move tabs with
setMovable(True).
Primary Documentation
- Riverbank: PyQt introduction, supported binding generations, and licensing
- Riverbank: installing PyQt5
- Riverbank: using Qt Designer and `pyuic5`
- Riverbank: PyQt5 signals, slots, connection objects, and `pyqtSignal`
- Riverbank: the PyQt5 resource system
- Qt: `QThread` and the worker-object pattern
- Qt: `QScrollArea`
- Qt: `QTabWidget`
- Python: creating virtual environments with `venv`
- Python: GIL and thread-performance considerations
—
Original 2019 Archive (Verbatim)
The following is the complete visible wording and order from the original WordPress export, published 2019-04-15 and last modified 2019-04-20. Only invisible trailing whitespace has been normalized for repository formatting.
Make it scrollable
Generating .py file from .ui
python -m PyQt5.uic.pyuic -x [FILENAME].ui -o [FILENAME].py
Modularized
Change appearance & setup taskbar icon
Signal connection
Parallel processing
pyqtSignal, QThread, Worker
pyqtSignal[type1, type2, …]
using global variable, tuple
using disconnect to clear outdated signal
button.clicked.disconnect()
Change the order of tab
modularized the pre-processing and control part
