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.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A
.uifile produced by Qt Designer — see Qt Designer for GIS Interfaces. - A plugin skeleton with the usual structure — see Plugin Boilerplate and Structure.
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.
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.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | qgis.PyQt.uic present; QgsMapLayerProxyModel filters identical. |
| 3.28 LTR | 3.9 | Behaviour matches this page. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Qt 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__). AttributeErroron a widget name. The object name in Designer differs from the attribute used, orsetupUi()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.pyis 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.