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.

The init function runs once per formA user opens a feature form. QGIS builds widgets for every field according to the form configuration. It then imports the init code and calls the named function with three arguments: the form dialog, the layer and the feature. The function looks up widgets by field name and connects handlers to signals. After it returns, later edits by the user trigger those handlers, which can change other widgets or show warnings.Wire up once, react for as long as the form is openuser opensfeature formQGIS buildswidgets per fieldcalls your init functionform_open(dialog, layer, feature)find widgets, connect signals, returnlater: your handlers firevalue changed → fill, enable, warnOK pressed → check, accept or refuseruns for every form opened — keep it fast,and never block on a slow network call

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.

Talk to the form, not its widgetsLeft: code that finds a QComboBox named condition and connects to currentIndexChanged breaks when the field is switched to a value relation or a radio-style widget. Right: code that connects to the form's widgetValueChanged signal receives the field name and new value for any widget type, and uses changeAttribute to set other fields by name.Survive a widget change in Layer Propertieswidget-levelfindChild(QComboBox, …).currentIndexChangedswitch field to ValueRelation→ findChild returns Nonelogic silently stopsform-leveldialog.widgetValueChangeddialog.changeAttribute(name, v)receives field name + valuefor every widget typekeeps working

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.

Dialog, file or environmentDialog: code stored inside the project file, travels with it, hard to version, and may be blocked by project trust settings. File: code in a .py file referenced by path, easy to version but breaks if the path changes. Environment: a function importable from the Python path, such as a module in your plugin, versioned and updated with the plugin, and the right choice for organisation-wide forms.Where should the form code live?Dialoginside the projecttravels with ithard to reviewa few lines, one projectFilea .py by pathcan be versionedbreaks if path movesa shared network folderEnvironmentmodule on Python pathshipped in a pluginupdates with the pluginorganisation-wide forms

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. setEditFormConfig was not called, the function name does not match, or project code execution is disabled in the user's settings.
  • findChild returns 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 attributeChanged flag.
  • 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.