Load a .ui File at Runtime in PyQGIS

There are two ways to get a Qt Designer dialog into a plugin: compile the .ui into a Python module with pyuic5, or load it at runtime with uic. The second is what the QGIS Plugin Builder template does, and it is the better default — the designer file stays the single source of truth, no build step can go stale, and a colleague editing the dialog does not have to remember to regenerate anything.

This recipe belongs to Qt Designer for GIS Interfaces. It covers loadUiType, resolving the path so it works from any working directory, wiring signals, using the QGIS custom widgets, and the reload behaviour that makes runtime loading pleasant during development.

Compile the dialog, or load it at runtimeThe compiled route runs pyuic over the designer file to produce a generated Python module which the plugin imports, adding a build step that can be forgotten. The runtime route reads the designer file directly with uic loadUiType each time the plugin starts, so the designer file is always the source of truth.One of these has a step you can forget to runcompileddialog.uipyuic5build stepui_dialog.pydialogedit the .ui, forget the build, ship yesterday's dialogruntimedialog.uiuic.loadUiType(path) at import timedialogthe designer file is always what the user sees

Prerequisites

Load the form

import os
from qgis.PyQt import uic
from qgis.PyQt.QtWidgets import QDialog

FORM_CLASS, _ = uic.loadUiType(
    os.path.join(os.path.dirname(__file__), "parcel_tools_dialog.ui")
)

class ParcelToolsDialog(QDialog, FORM_CLASS):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setupUi(self)

Breakdown: loadUiType() returns a tuple of the generated form class and the base class the designer file declared; taking only the first and inheriting from QDialog explicitly is the conventional pattern. The path is built from os.path.dirname(__file__) rather than a relative string, and that is the important line — a relative path resolves against the process's working directory, which for QGIS is wherever it was launched from, not the plugin folder. Calling setupUi(self) builds the widgets and, crucially, assigns each one to an attribute named after its objectName in Designer, which is how self.wardCombo comes to exist.

Import uic from qgis.PyQt rather than PyQt5 directly. The shim picks the right Qt binding for the QGIS build, so the same plugin works on a Qt 6 build without changes.

Wire the signals

class ParcelToolsDialog(QDialog, FORM_CLASS):
    def __init__(self, iface, parent=None):
        super().__init__(parent)
        self.setupUi(self)
        self.iface = iface

        self.browseButton.clicked.connect(self.choose_output)
        self.wardCombo.currentIndexChanged.connect(self.on_ward_changed)
        self.buttonBox.accepted.connect(self.run)
        self.buttonBox.rejected.connect(self.reject)

    def choose_output(self):
        from qgis.PyQt.QtWidgets import QFileDialog
        path, _ = QFileDialog.getSaveFileName(self, "Output", "", "GeoPackage (*.gpkg)")
        if path:
            self.outputEdit.setText(path)

Breakdown: Connections belong in __init__ after setupUi(), because the widgets do not exist before it. Widget names come straight from Designer's object names, so keeping those meaningful — wardCombo, not comboBox_3 — is what makes this code readable a year later. Avoid Qt's auto-connection convention (on_wardCombo_currentIndexChanged): it works, but a renamed widget silently disconnects the handler with no error, whereas an explicit connection raises AttributeError immediately.

Use the QGIS custom widgets

Designer, launched with the QGIS environment, offers QGIS's own widgets — layer combo boxes, field selectors, colour buttons, extent pickers. They load at runtime with no extra work and remove a great deal of boilerplate.

from qgis.core import QgsMapLayerProxyModel

self.layerCombo.setFilters(QgsMapLayerProxyModel.PolygonLayer)
self.fieldCombo.setLayer(self.layerCombo.currentLayer())
self.layerCombo.layerChanged.connect(self.fieldCombo.setLayer)

Breakdown: QgsMapLayerComboBox populates itself from the project and keeps itself current as layers are added or removed — replacing the manual population loop that every plugin used to carry, and the stale-list bug that came with it. Filtering to polygon layers stops the user selecting something the tool cannot process. Connecting layerChanged straight to setLayer on the field combo makes the field list follow the chosen layer with no handler of your own. If the widgets appear as plain Qt ones in Designer, launch Designer through the QGIS environment — on Linux qgis --designer, on Windows the Designer shortcut inside the OSGeo4W menu.

Why the path must be built from __file__A plugin folder contains the dialog python module and the designer file side by side. Building the path from the module's own directory resolves correctly wherever QGIS was launched from. A bare relative path resolves against the process working directory, which is usually the user's home or the QGIS installation folder, and fails.The plugin folder is not the working directory~/.local/share/QGIS/…/parcel_tools/parcel_tools_dialog.pyparcel_tools_dialog.uimetadata.txtboth files live togetheros.path.join(os.path.dirname(__file__), "…ui")resolves next to the module, alwaysuic.loadUiType("parcel_tools_dialog.ui")resolves against wherever QGIS was started

Show it without blocking

def run(self):
    if self.dialog is None:
        self.dialog = ParcelToolsDialog(self.iface, self.iface.mainWindow())
    self.dialog.show()
    self.dialog.raise_()
    self.dialog.activateWindow()

Breakdown: Passing iface.mainWindow() as the parent makes the dialog behave properly with the main window — it stays on top of QGIS, minimises with it, and is destroyed with it. Creating it once and reusing it keeps the user's entries between invocations, which is almost always what they expect; recreating it every time silently discards their last settings. show() is modeless, so QGIS stays usable behind it; exec_() would block everything, which is only right for a question that must be answered before anything else can happen.

Reload during development

Runtime loading pairs well with the Plugin Reloader: edit the .ui in Designer, save, reload the plugin, and the new dialog appears — no build, no restart. The reload mechanics are covered in Reload a QGIS Plugin Without Restarting.

One caveat: loadUiType() runs at import time, so the form class is captured when the module is first imported. A plugin reload re-imports the module and therefore re-reads the file; simply calling run() again does not. If a dialog stubbornly shows an old layout, the module was not re-imported.

Remember what the user typed

A dialog that forgets its settings between sessions makes a user re-enter the same output folder every morning. QgsSettings persists them into the QGIS profile with no file handling of your own.

from qgis.core import QgsSettings

GROUP = "plugins/parcel_tools"

def restore_state(self):
    settings = QgsSettings()
    self.outputEdit.setText(settings.value(f"{GROUP}/output_dir", "", type=str))
    self.bufferSpin.setValue(settings.value(f"{GROUP}/buffer", 25, type=int))
    self.overwriteCheck.setChecked(settings.value(f"{GROUP}/overwrite", False, type=bool))

def save_state(self):
    settings = QgsSettings()
    settings.setValue(f"{GROUP}/output_dir", self.outputEdit.text())
    settings.setValue(f"{GROUP}/buffer", self.bufferSpin.value())
    settings.setValue(f"{GROUP}/overwrite", self.overwriteCheck.isChecked())

Breakdown: Passing type= to value() is what makes the round trip reliable — settings are stored as strings on some platforms, and a checkbox restored without type=bool receives the string "false", which Python considers true. Namespacing every key under plugins/<your plugin> keeps your settings out of everyone else's and makes them easy to find, or clear, in the profile's settings file. Call restore_state() after setupUi() and save_state() when the dialog is accepted, not on every keystroke.

What not to persist is equally important: never store a password here, since QgsSettings is plain text — that is what the authentication database is for, as described in Connect to a PostGIS Database in PyQGIS. Storing a layer name rather than a layer id is similarly fragile across projects; store the id and fall back gracefully when it no longer resolves.

What belongs in settings, and what does notThe output folder, buffer distance and overwrite flag are written to the QGIS settings under a plugin namespace when the dialog is accepted, and read back the next time it opens. A password is marked as excluded, belonging in the encrypted authentication database rather than in plain-text settings.Persist the preferences, never the secretthe dialogoutput folderbuffer distanceoverwrite flagQgsSettingsplugins/parcel_tools/…in the QGIS profileplain text on diska passwordnot hereauthenticationdatabase insteadAlways pass type= when reading back, or a stored "false" comes back as a truthy string

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.22 LTR3.9qgis.PyQt.uic present; QgsMapLayerProxyModel filters identical.
3.28 LTR3.9Behaviour matches this page.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Qt 6 builds appear; importing from qgis.PyQt rather than PyQt5 is what keeps the plugin working on both.

Troubleshooting

  • "No such file or directory" for the .ui. A relative path was used. Build it from os.path.dirname(__file__).
  • AttributeError on a widget name. The object name in Designer differs from the attribute used, or setupUi() was never called.
  • QGIS custom widgets fail to load. Designer was not launched with the QGIS environment, so the widget plugin was unavailable and the promoted class is unknown at load time.
  • The dialog appears behind the main window. No parent was passed. Use iface.mainWindow().
  • Edits to the .ui do not show. The module was not re-imported. Reload the plugin rather than reopening the dialog.
  • Icons are missing. A compiled resources.py is stale or missing. Regenerate it, or reference icons by path from the plugin directory.

Conclusion

uic.loadUiType() with a path built from __file__ keeps the designer file as the single source of truth and removes a build step that is easy to forget. Connect signals explicitly after setupUi(), use the QGIS custom widgets rather than repopulating combo boxes by hand, and parent the dialog to the main window so it behaves like the rest of QGIS.

Frequently Asked Questions

Is runtime loading slower than a compiled form? Marginally — parsing the XML takes a few milliseconds, once per import. It is not measurable next to plugin start-up.

When should I compile instead? When you want the form under static analysis, when a packaging step strips non-Python files, or when a very large form is created repeatedly in a loop.

Can I load a .ui into an existing widget instead of subclassing? Yes — uic.loadUi(path, self) populates an already-constructed widget. loadUiType is preferred because the resulting class is explicit and inspectable.

How do I add a QGIS widget to a form Designer does not offer? Promote a base widget: place a QWidget or QComboBox, right-click, Promote to, and give the QGIS class name and header. Runtime loading resolves the promotion.

Can two dialogs share one .ui file? Yes — call loadUiType() once at module level and let two classes inherit the same form class. It is occasionally useful for a dialog and a dock widget that present the same controls, though usually a shared widget promoted into both is cleaner.

Where should the dialog live in the plugin? Beside the module that loads it, in the plugin's top folder or a ui/ subfolder. Whichever you pick, build the path from __file__ so the choice does not matter to the code.