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) or pyuic6 (PyQt6) available on your PATH — bundled with QGIS or installable via pip 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 the QgsProject API 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:

Qt Dialog Lifecycle in QGIS Plugins Flow from Qt Designer .ui XML through pyuic5 compilation to Ui_Dialog mixin, then QDialog subclass calling setupUi, signal-slot wiring, QSettings persistence, and optional QgsTask for background work. The main thread owns all widget interactions; QgsTask runs on a worker thread and communicates back via signals. Qt Designer dialog.ui (XML) pyuic5 Ui_Dialog mixin class setupUi() MyPluginDialog QDialog subclass Signal / Slot wiring MAIN THREAD (Qt event loop) User Input form controls Validation accept() / reject() QSettings persist state Caller receives result heavy work QgsTask (worker) run() on thread pool signal → main thread dashed = worker-thread path; must not touch widgets directly

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 QGroupBox to communicate structure to screen readers.
  • Embed a QDialogButtonBox with OK and Cancel roles for consistent keyboard navigation — even if you replace its default accepted/rejected slots.
  • Save the .ui file in the same directory as your compiled output so pyuic5 paths 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:

bash
# 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:

bash
# 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:

python
"""
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:

python
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:

python
"""
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:

python
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:

python
"""
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 inside QgsTask.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:

python
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:

python
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:

python
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() as parent. If you store a reference to the dialog on the plugin instance, set it to None after closing and call dialog.deleteLater() rather than relying on Python’s garbage collector to reach C++ objects through the SIP boundary.
  • Hardcoded pixel geometry. Never call self.setFixedSize() or self.resize(800, 600) unconditionally. Use self.adjustSize() after populating dynamic lists, and rely on QLayout for adaptive sizing across DPI settings and screen resolutions.
  • Blocking the event loop. Replace any time.sleep(), synchronous requests.get(), or long database queries with QgsTask offloads. Use QProgressDialog to communicate duration to the user during operations that cannot be backgrounded.
  • PyQt5 / PyQt6 incompatibility. Use exec() not exec_() (removed in PyQt6). Avoid QVariant — it does not exist in Python bindings. Use try/except ImportError compatibility 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. Check QgsProject.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, QSettings stores all values as strings in the registry. Always pass type=bool or type=float to value() — without it, "true" does not evaluate as True in 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