PyQt5 Guide for 2026: Qt Designer, Signals, QThread, Scroll Areas, Icons, and Tabs

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.

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 returned Connection instead 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

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

Leave a Reply