Add Python Logic to an Attribute Form in PyQGIS
Widgets, defaults and constraints cover most of what a form needs, and all of them are declarative: they describe rules, and QGIS enforces them. Occasionally a form has to do something — look up a record in another system when an asset id is typed, fill three fields from one selection, grey out a section until a checkbox is ticked, or run a check that no expression can express. For that, QGIS lets each layer's form run a Python function when it opens.
This recipe belongs to Attribute Forms & Layer Actions in PyQGIS. It configures form init code from a script, writes an init function that reacts to edits, adds a save-time check, and explains where to keep the code so it survives being shared.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series.
- A layer with a configured form — ideally already using editor widgets, since init code works best as a thin layer on top of them.
- A reason to need Python. Check first whether a default value, a constraint expression or a conditional visibility rule already does the job; those are faster, portable to mobile apps, and involve no executable code.
Configure the init function
A layer's form configuration names the function to call and where its code comes from. The simplest source is code stored inside the project itself.
from qgis.core import Qgis, QgsProject
inspections = QgsProject.instance().mapLayersByName("inspections")[0]
INIT_CODE = '''
from qgis.PyQt.QtWidgets import QComboBox, QLineEdit
def form_open(dialog, layer, feature):
condition = dialog.findChild(QComboBox, "condition")
action = dialog.findChild(QLineEdit, "action_required")
if condition is None or action is None:
return
def on_condition_changed(_index):
poor = condition.currentData() in (1, 2)
action.setEnabled(poor)
if not poor:
action.clear()
condition.currentIndexChanged.connect(on_condition_changed)
on_condition_changed(condition.currentIndex())
'''
config = inspections.editFormConfig()
config.setInitCodeSource(Qgis.AttributeFormPythonInitCodeSource.Dialog)
config.setInitFunction("form_open")
config.setInitCode(INIT_CODE)
inspections.setEditFormConfig(config)
Breakdown: editFormConfig() returns a copy, so changes do nothing until setEditFormConfig writes it back — the most common reason init code "never runs". The Dialog source stores the code text in the project. QGIS names each editor widget after its field, which is what makes findChild(QComboBox, "condition") work; the widget class depends on the editor widget type, so a value map is a QComboBox and a plain text field a QLineEdit. Calling the handler once at the end sets the initial state for the feature being opened, not only for later changes. currentData() returns the stored value from the value map rather than the label, so the comparison is against codes.
React through the form, not the widgets
Reaching into individual Qt widgets is fragile: a widget type change in the Layer Properties dialog breaks the findChild call. The form object itself emits a signal whenever any field's value changes, and can set values by field name, which keeps the code independent of widget classes.
INIT_CODE = '''
from qgis.core import QgsProject, QgsFeatureRequest, QgsExpression
def form_open(dialog, layer, feature):
assets = QgsProject.instance().mapLayersByName("asset_register")
if not assets:
return
assets = assets[0]
def on_value_changed(field_name, value, attribute_changed):
if field_name != "asset_id" or not attribute_changed or not value:
return
expr = f"\\"asset_id\\" = {QgsExpression.quotedValue(value)}"
match = next(assets.getFeatures(
QgsFeatureRequest().setFilterExpression(expr).setLimit(1)), None)
if match is None:
dialog.displayWarning(f"Asset {value} is not in the register")
return
dialog.changeAttribute("asset_type", match["asset_type"])
dialog.changeAttribute("installed_year", match["installed_year"])
dialog.widgetValueChanged.connect(on_value_changed)
'''
Breakdown: widgetValueChanged passes the field name, the new value and a flag that is true when the change came from the user rather than from the form being populated — checking it avoids a lookup firing for every field as the form loads. changeAttribute sets a value by field name through whatever widget that field uses, and marks the feature as modified. displayWarning shows a message bar inside the form rather than a modal dialog, which does not interrupt typing. The lookup uses setLimit(1) against a local layer so it returns instantly; the doubled backslashes are there because the code sits inside a Python string that is itself stored as code.
Refuse to save when a check fails
Constraints cover most save-time rules. When a rule needs Python — a check against an external service, a cross-feature rule that is too slow as an expression — intercept the form's accept.
INIT_CODE = '''
from qgis.PyQt.QtWidgets import QDialogButtonBox
def form_open(dialog, layer, feature):
parent = dialog.parent()
buttons = parent.findChild(QDialogButtonBox) if parent else None
if buttons is None:
return
dialog.disconnectButtonBox()
def on_accept():
values = {name: dialog.feature()[name] for name in ("condition", "notes")}
if values["condition"] in (1, 2) and not (values["notes"] or "").strip():
dialog.displayWarning("A poor condition needs a note before saving")
return
if dialog.save():
parent.accept()
buttons.accepted.connect(on_accept)
buttons.rejected.connect(parent.reject)
'''
Breakdown: disconnectButtonBox detaches QGIS's own OK handling so your function decides whether to save. dialog.feature() returns the feature with the values currently in the widgets, not the values last committed. dialog.save() pushes those values into the layer's edit buffer and returns false if a hard constraint fails, so constraints still apply. The check here duplicates a constraint on purpose, to show the shape; in real use keep rules that expressions can express as constraints, where they also protect edits from the attribute table and from mobile apps, and reserve this for checks that cannot be written any other way.
Keep the code where it can be maintained
Code pasted into a project is invisible to version control, cannot be reviewed, and is copied into every project that duplicates the layer. For anything beyond a few lines, keep it in a Python module and point the form at it.
config = inspections.editFormConfig()
config.setInitCodeSource(Qgis.AttributeFormPythonInitCodeSource.Environment)
config.setInitFunction("survey_forms.inspections.form_open")
inspections.setEditFormConfig(config)
Breakdown: With the Environment source, the init function is a dotted path to a function importable from QGIS's Python path. A plugin called survey_forms with an inspections.py module is on that path once installed, so every project that uses the layer gets the current version of the logic whenever the plugin updates — and the code lives in a repository with tests, like the rest of the plugin, as described in unit testing a QGIS plugin. The File source sits between the two: config.setInitFilePath("/srv/gis/forms/inspections.py") with the function name alone, which suits a team without a plugin but with a shared drive.
Security deserves a sentence. Code stored in a project runs on the machine of whoever opens it. Recent QGIS releases can ask before running embedded Python from a project, depending on each user's security settings, and organisations often disable it entirely. The Environment route avoids the question, because the code comes from an installed plugin the user already trusts.
QGIS version compatibility
Qgis.AttributeFormPythonInitCodeSource is the 3.32+ name; earlier releases use QgsEditFormConfig.CodeSourceDialog, CodeSourceFile and CodeSourceEnvironment. The QGIS 4 series accepts only the scoped form. widgetValueChanged has carried the attributeChanged flag since 3.0. On QGIS 4, PyQt6 requires scoped Qt enums inside init code too, such as QDialogButtonBox.StandardButton.Ok.
Troubleshooting
- The function never runs.
setEditFormConfigwas not called, the function name does not match, or project code execution is disabled in the user's settings. findChildreturns None. The field uses a different widget class, or the form uses a drag-and-drop layout that nests widgets — use form-level signals instead.- Handlers fire repeatedly as the form opens. Check the
attributeChangedflag. - Errors appear only in the log. Exceptions in init code go to the Python tab of the message log; logging to the message log makes your own messages land there too.
- Works in QGIS, not in QField. Mobile apps do not run Python form code; use constraints and expressions for anything that must work in the field.
Conclusion
Reach for Python in a form only after widgets, defaults, constraints and visibility expressions have run out. When you do, react through the form's widgetValueChanged and changeAttribute rather than individual widgets, intercept saving only for checks nothing else can express, and ship the code as a module in a plugin so it is versioned, tested and trusted.
Frequently Asked Questions
Is init code called for the attribute table too? No. It runs for feature forms. Edits made directly in table cells use widgets and constraints but not init code.
Can I use the init function to open a custom dialog instead?
You can, but it is cleaner to replace the whole form with a .ui file via setUiForm and keep the init function for behaviour.
Does the form know whether it is adding or editing?dialog.mode() returns the form mode, distinguishing new features, edits, multi-edit and search.
Can init code access the map canvas?
Yes, through qgis.utils.iface when running in QGIS Desktop; guard it, because it is None in QGIS Server and standalone scripts.