Designing Qt Dialogs and Form Widgets for QGIS Plugins
A production-ready guide to creating, compiling, and integrating custom Qt dialogs and form widgets in QGIS plugins — covering Qt Designer, pyuic5,…
Professional GIS development demands interfaces that are both responsive and tightly coupled to spatial workflows. This page is part of the Plugin Development & UI Integration guide. Unlike standalone desktop applications, QGIS plugins operate inside a constrained Python environment where UI components must respect the host application’s event loop, styling, and resource management. The patterns here provide a production-ready workflow for creating, compiling, and integrating custom dialogs that align with the plugin lifecycle QGIS enforces at load and unload time.
Prerequisites
Before implementing custom interfaces, confirm your development environment meets all of the following:
- QGIS 3.28+ with the embedded Python 3.9+ interpreter
pyuic5(PyQt5) orpyuic6(PyQt6) available on your PATH — bundled with QGIS or installable viapip install pyqt5-tools- Working knowledge of PyQt5/PyQt6 signal-slot architecture and Qt layout management
- A functioning plugin skeleton generated via Plugin Builder,
qgis-plugin-ci, or manual scaffolding - Familiarity with
QDialog,QSettings, and theQgsProjectAPI boundary
Architecture: How Qt Dialog Rendering Works Inside QGIS
Understanding how Qt manages the dialog lifecycle prevents the most common memory and thread errors before they occur.
Qt uses a parent-child ownership tree: when a QDialog is constructed with a parent widget, Qt registers the child in its object tree and guarantees destruction when the parent is destroyed. QGIS’s main window (iface.mainWindow()) sits at the root of this tree. Passing it as parent means QGIS will reclaim dialog memory on shutdown even if unload() is imperfect.
The .ui XML file produced by Qt Designer is not loaded at runtime in production — it is compiled to a Python class (Ui_Dialog) by pyuic5. That class is a mixin: it carries no Qt inheritance of its own; it only provides setupUi(self). Your QDialog subclass calls setupUi(self) to inflate the widget tree into itself and take ownership of every child widget created during that call.
The diagram below shows the complete lifecycle from .ui source to a live, thread-safe dialog inside QGIS:
Step-by-Step Implementation
The process follows a strict separation of concerns: interface definition in .ui XML, Python compilation, programmatic instantiation, signal wiring, state persistence, and background task offload. Deviating from this sequence produces memory leaks, UI thread contention, or broken cross-version compatibility.
Step 1 — Interface Definition with Qt Designer
Qt Designer ships with QGIS and provides a drag-and-drop environment tailored to PyQt. Start with the Dialog without Buttons template. This prevents automatic accept()/reject() bindings that conflict with QGIS modal behavior. Arrange controls using QVBoxLayout and QHBoxLayout containers, assign a meaningful objectName to every widget you will reference from Python, and avoid hardcoded pixel dimensions. Use layout stretch factors and QSizePolicy expansion flags to ensure the dialog scales correctly across DPI settings and QGIS themes.
Key Qt Designer hygiene rules:
- Never set a fixed width or height on top-level containers; use minimum/maximum hints instead.
- Group related controls inside a
QGroupBoxto communicate structure to screen readers. - Embed a
QDialogButtonBoxwithOKandCancelroles for consistent keyboard navigation — even if you replace its defaultaccepted/rejectedslots. - Save the
.uifile in the same directory as your compiled output sopyuic5paths stay predictable.
Step 2 — Compiling the UI File
QGIS plugins must never load .ui files via uic.loadUi() in production. Compilation to a Python class improves startup performance, enables static analysis, and removes XML-parsing overhead at dialog instantiation time. Run the following from your plugin root:
# Compile the Qt Designer XML to a Python mixin class
pyuic5 dialog.ui -o ui_dialog.py --no-compress
The --no-compress flag keeps the output readable for code review. Omit -x in production builds; that flag injects a standalone test runner that must not ship in the packaged plugin. Version-control the compiled .py alongside its .ui source, and integrate the compile step into your CI pipeline so .ui changes automatically regenerate the class:
# Makefile excerpt for CI/CD recompilation
%.py: %.ui
pyuic5 $< -o $@ --no-compress
Step 3 — Programmatic Instantiation
The Ui_Dialog class is a mixin with no Qt inheritance. Combine it with QDialog in a subclass, then call setupUi(self) to inflate the widget tree:
"""
Plugin dialog module.
Combines the compiled Ui_Dialog mixin with QDialog to produce a
QGIS-compatible, memory-safe modal dialog.
"""
from __future__ import annotations
from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtWidgets import QDialog
from qgis.gui import QgisInterface
from .ui_dialog import Ui_Dialog
class MyPluginDialog(QDialog):
"""Modal dialog for MyPlugin.
Parameters
----------
iface:
The QGIS interface object. Its main window is used as the Qt
parent to ensure correct ownership and garbage collection.
"""
def __init__(self, iface: QgisInterface) -> None:
super().__init__(iface.mainWindow()) # establishes Qt parent ownership
self._ui = Ui_Dialog()
self._ui.setupUi(self)
self.setWindowTitle("My Spatial Tool")
self.setWindowModality(Qt.ApplicationModal)
self._setup_connections()
Passing iface.mainWindow() as parent is not optional. Without a parent, the dialog becomes an orphaned top-level window that survives plugin reloads and can accumulate across sessions, consuming memory and producing stale event handlers.
Once instantiated, the dialog is triggered from a toolbar button or menu entry. When integrating toolbars and menu actions, instantiate the dialog lazily — inside the action’s triggered slot — rather than at initGui() time. Use dialog.exec() for modal execution that blocks map interaction, or dialog.show() for non-modal workflows where users need to pan or query the canvas while the dialog is open.
Step 4 — Signal-Slot Wiring
Qt’s signal-slot mechanism decouples UI events from business logic. Centralise all connections in a _setup_connections() method called at the end of __init__. This makes the connection graph auditable and avoids the silent errors that arise when connections are scattered across multiple methods:
def _setup_connections(self) -> None:
"""Wire all Qt signals to their handler slots."""
self._ui.run_button.clicked.connect(self._on_run_clicked)
self._ui.cancel_button.clicked.connect(self.reject)
# Keep widgets in sync: clear status when input changes
self._ui.input_line_edit.textChanged.connect(
lambda _: self._ui.status_label.clear()
)
def _on_run_clicked(self) -> None:
"""Validate inputs and accept the dialog, or report inline errors."""
input_path: str = self._ui.input_line_edit.text().strip()
if not input_path:
self._ui.status_label.setText("Please select an input layer.")
return
# Store validated path so the caller can retrieve it
self._input_path = input_path
self.accept()
@property
def input_path(self) -> str:
"""Return the validated input path chosen by the user."""
return getattr(self, "_input_path", "")
When dialog outputs feed into spatial operations, pass parameters to building custom processing algorithms rather than embedding geoprocessing logic inside the UI class. This keeps the dialog lightweight and lets QGIS’s native progress tracking, undo/redo, and cancellation infrastructure handle the heavy work.
Step 5 — State Persistence with QSettings
Users expect dialogs to remember their last-used paths, layer selections, and numeric thresholds across sessions. QGIS’s QSettings registry persists values in the same store as core application preferences:
"""
QSettings integration pattern for dialog state persistence.
Uses hierarchical keys to avoid namespace collisions with other plugins.
"""
from __future__ import annotations
from qgis.PyQt.QtCore import QSettings
from qgis.PyQt.QtWidgets import QDialog
from qgis.gui import QgisInterface
from .ui_dialog import Ui_Dialog
_SETTINGS_PREFIX = "MyPlugin/dialogs/main"
class MyPluginDialog(QDialog):
"""Dialog with automatic state persistence via QSettings."""
def __init__(self, iface: QgisInterface) -> None:
super().__init__(iface.mainWindow())
self._ui = Ui_Dialog()
self._ui.setupUi(self)
self._settings = QSettings()
self._restore_settings()
self._setup_connections()
def _restore_settings(self) -> None:
"""Populate controls from the last saved session values."""
self._ui.input_line_edit.setText(
self._settings.value(f"{_SETTINGS_PREFIX}/last_input_path", "")
)
self._ui.threshold_spin.setValue(
float(self._settings.value(f"{_SETTINGS_PREFIX}/threshold", 0.5))
)
self._ui.overwrite_check.setChecked(
self._settings.value(f"{_SETTINGS_PREFIX}/overwrite", False, type=bool)
)
def _save_settings(self) -> None:
"""Persist current control values for the next session."""
self._settings.setValue(
f"{_SETTINGS_PREFIX}/last_input_path",
self._ui.input_line_edit.text(),
)
self._settings.setValue(
f"{_SETTINGS_PREFIX}/threshold",
self._ui.threshold_spin.value(),
)
self._settings.setValue(
f"{_SETTINGS_PREFIX}/overwrite",
self._ui.overwrite_check.isChecked(),
)
def accept(self) -> None:
"""Persist state, then close the dialog with an Accepted result."""
self._save_settings()
super().accept()
Use hierarchical keys (Plugin/section/control) consistently. QSettings values are untyped strings at the OS level — always supply type= to value() for booleans and numerics to avoid silent coercion bugs that only manifest on the second run.
For complex state, serialize dictionaries to JSON and store them as a single string value:
import json
# Saving a dict
self._settings.setValue(
f"{_SETTINGS_PREFIX}/column_map",
json.dumps({"id_field": "ogc_fid", "label_field": "name"}),
)
# Restoring with a safe default
raw: str = self._settings.value(f"{_SETTINGS_PREFIX}/column_map", "{}")
column_map: dict = json.loads(raw)
Step 6 — Thread Safety and Background Task Offload
The QGIS main thread handles rendering, event dispatch, and all Python execution. Blocking it with synchronous I/O, network requests, or heavy raster computations freezes the interface and triggers OS “Not Responding” warnings. For data-heavy workflows — especially when connecting QGIS form widgets to vector layer attributes — defer expensive queries to a QgsTask:
"""
Thread-safe background task pattern for QGIS plugin dialogs.
Results are delivered back to the main thread via Qt signals, never by
modifying widget state directly from the worker thread.
"""
from __future__ import annotations
from typing import Any
from qgis.core import QgsApplication, QgsMessageLog, QgsTask
from qgis.PyQt.QtCore import pyqtSignal, QObject
class _ResultRelay(QObject):
"""Relay object that lives on the main thread and emits results."""
result_ready = pyqtSignal(object)
class HeavyComputationTask(QgsTask):
"""Off-thread computation task that signals results to the UI.
Parameters
----------
description:
Human-readable task label shown in the QGIS task manager panel.
input_path:
Path to the dataset being processed.
relay:
QObject relay whose ``result_ready`` signal is connected to a
main-thread slot on the dialog.
"""
def __init__(
self,
description: str,
input_path: str,
relay: _ResultRelay,
) -> None:
super().__init__(description, QgsTask.CanCancel)
self._input_path = input_path
self._relay = relay
self._result: Any = None
def run(self) -> bool:
"""Execute on the QGIS thread pool. Must not touch any widget."""
try:
# Simulated heavy spatial work
self.setProgress(25)
# ... load and process self._input_path ...
self._result = {"feature_count": 42, "crs": "EPSG:4326"}
self.setProgress(100)
return True
except Exception as exc: # noqa: BLE001
QgsMessageLog.logMessage(str(exc), "MyPlugin", level=2)
return False
def finished(self, result: bool) -> None:
"""Called on the MAIN thread after run() completes."""
if result:
# Safe to emit signal here; slot runs on main thread
self._relay.result_ready.emit(self._result)
else:
QgsMessageLog.logMessage("Task failed.", "MyPlugin", level=2)
# Inside the dialog class:
#
# def _launch_background_task(self) -> None:
# self._relay = _ResultRelay()
# self._relay.result_ready.connect(self._on_result_ready)
# task = HeavyComputationTask("Loading attributes", self.input_path, self._relay)
# QgsApplication.taskManager().addTask(task)
#
# def _on_result_ready(self, result: dict) -> None:
# self._ui.status_label.setText(f"{result['feature_count']} features found.")
Key thread safety rules:
- Never reference
self._ui.*widgets insideQgsTask.run(). - Always communicate results through signals emitted in
finished(), which Qt guarantees runs on the main thread. - Use
QgsApplication.taskManager().addTask()rather than constructing threads manually — the task manager integrates with QGIS’s native progress panel and cancellation infrastructure. - Avoid
QMetaObject.invokeMethod()in new code; the relay signal pattern above is cleaner and statically analyzable.
Advanced Patterns
Dynamic Widget Population from Layer Attributes
Many dialogs need to populate combo boxes with field names or layer names from the current project. Pull this data lazily — inside the dialog’s showEvent() — not at construction time:
from qgis.core import QgsProject, QgsMapLayerType
from qgis.PyQt.QtWidgets import QDialog
class LayerSelectDialog(QDialog):
"""Dialog that populates controls from the active QGIS project state."""
def showEvent(self, event) -> None: # type: ignore[override]
"""Refresh layer list every time the dialog becomes visible."""
super().showEvent(event)
self._refresh_layer_combo()
def _refresh_layer_combo(self) -> None:
"""Populate the layer combo with all vector layers in the project."""
self._ui.layer_combo.clear()
project = QgsProject.instance()
for layer_id, layer in project.mapLayers().items():
if layer.type() == QgsMapLayerType.VectorLayer:
self._ui.layer_combo.addItem(layer.name(), layer_id)
Refreshing on showEvent() rather than __init__ means the combo stays accurate when the project changes between dialog invocations without creating a new dialog instance.
Dockable Panels with QDockWidget
For tools that users keep open alongside the map canvas, wrap your form widget in a QDockWidget instead of a QDialog. Register it during initGui() and deregister it in unload() as part of proper plugin lifecycle and resource management:
from qgis.PyQt.QtWidgets import QDockWidget
from qgis.PyQt.QtCore import Qt
class MyDockPanel(QDockWidget):
"""Persistent dockable panel for the plugin.
Parameters
----------
iface:
QGIS interface used to add/remove the dock widget.
"""
def __init__(self, iface) -> None:
super().__init__("My Spatial Tool", iface.mainWindow())
self._iface = iface
inner = MyInnerWidget(self)
self.setWidget(inner)
iface.addDockWidget(Qt.RightDockWidgetArea, self)
def close_and_deregister(self) -> None:
"""Remove from QGIS and schedule destruction."""
self._iface.removeDockWidget(self)
self.deleteLater()
Composable Form Sections with Custom Widgets
For dialogs that appear across multiple plugin tools, extract reusable form sections into QWidget subclasses and import them as custom widget promotions in Qt Designer. This keeps each section independently testable and prevents the Ui_ class from growing into an unmaintainable monolith:
from qgis.PyQt.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit
class CrsPickerWidget(QWidget):
"""Reusable CRS selection widget for embedding in any dialog.
Promotes to 'CrsPickerWidget' in Qt Designer via custom widget promotion.
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._label = QLabel("Coordinate Reference System:", self)
self._crs_input = QLineEdit(self)
self._crs_input.setPlaceholderText("EPSG:4326")
layout.addWidget(self._label)
layout.addWidget(self._crs_input)
@property
def crs_string(self) -> str:
"""Return the raw CRS string entered by the user."""
return self._crs_input.text().strip()
Pitfalls and Debugging
- Memory leaks on plugin reload. Always pass
iface.mainWindow()asparent. If you store a reference to the dialog on the plugin instance, set it toNoneafter closing and calldialog.deleteLater()rather than relying on Python’s garbage collector to reach C++ objects through the SIP boundary. - Hardcoded pixel geometry. Never call
self.setFixedSize()orself.resize(800, 600)unconditionally. Useself.adjustSize()after populating dynamic lists, and rely onQLayoutfor adaptive sizing across DPI settings and screen resolutions. - Blocking the event loop. Replace any
time.sleep(), synchronousrequests.get(), or long database queries withQgsTaskoffloads. UseQProgressDialogto communicate duration to the user during operations that cannot be backgrounded. - PyQt5 / PyQt6 incompatibility. Use
exec()notexec_()(removed in PyQt6). AvoidQVariant— it does not exist in Python bindings. Usetry/except ImportErrorcompatibility shims only when you must support both generations in the same codebase. - Unescaped file paths. Always normalize with
os.path.normpath()and validate existence before passing to QGIS APIs. CheckQgsProject.instance().isValid()before deriving paths relative to the project file. - Stale combo box data. Populate layer and field combos in
showEvent(), not__init__(). Otherwise the combo shows the project state at plugin load time, which may be hours stale in long-running QGIS sessions. - Silent QSettings type coercion. On Windows,
QSettingsstores all values as strings in the registry. Always passtype=boolortype=floattovalue()— without it,"true"does not evaluate asTruein a boolean check.
Conclusion
Building Qt dialogs for QGIS plugins is a discipline that spans compile-time tooling (pyuic5), Qt’s ownership model, signal-slot wiring, persistent settings, and thread-safe task offload. Strict separation between the compiled UI mixin and your QDialog subclass keeps the code auditable; centralised connection setup in _setup_connections() makes the event graph legible; and routing heavy work through QgsTask with a relay signal ensures the map canvas stays responsive. Apply these patterns across every dialog in a plugin and the interface will scale from a single developer’s workstation to an organisation-wide QGIS deployment without modification.
Related
- Plugin Lifecycle and Resource Management —
initGui()/unload()teardown contracts that govern when dialogs must be destroyed - Integrating Toolbars and Menu Actions — wiring
QActiontriggers that open these dialogs - Building Custom Processing Algorithms — routing validated dialog parameters into the Processing Framework
- Asynchronous Task Execution with QgsTask — full coverage of
QgsTaskand the task manager - Connecting QGIS Form Widgets to Vector Layer Attributes — deep-dive into attribute-driven form population