Configure Editor Widgets in PyQGIS
An attribute table filled in by hand through plain text boxes decays fast. Somebody types Oak, somebody else oak, a third person Quercus robur; a condition score of 7 appears on a scale that stops at 5; a date goes in as 14/9 with no year. Every one of those is preventable at the point of entry, because every field in QGIS can carry an editor widget — a drop-down, a lookup against another layer, a bounded spin box, a calendar — that only accepts sensible values.
This recipe belongs to Attribute Forms & Layer Actions in PyQGIS. It sets widgets from Python field by field, covers the configuration keys for the widgets that matter most, and turns the result into a reusable scheme you can apply across a whole project of survey layers.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series.
- An editable vector layer, ideally in GeoPackage or PostGIS. Widgets work on any provider, but a read-only layer never shows an edit form.
- For value relations, a lookup layer in the same project.
How a widget setup works
Each field has one QgsEditorWidgetSetup: a widget type name and a configuration dictionary. The layer stores it by field index, it is saved in the project and in the layer's QML style, and it is what both the attribute form and the attribute table use when a user edits a value.
from qgis.core import QgsProject, QgsEditorWidgetSetup
trees = QgsProject.instance().mapLayersByName("street_trees")[0]
for idx, field in enumerate(trees.fields()):
setup = trees.editorWidgetSetup(idx)
print(f"{field.name():<16} {setup.type() or '(default)':<16} {setup.config()}")
def set_widget(layer, field_name, widget_type, config):
idx = layer.fields().indexOf(field_name)
if idx < 0:
raise KeyError(f"{layer.name()} has no field {field_name!r}")
layer.setEditorWidgetSetup(idx, QgsEditorWidgetSetup(widget_type, config))
Breakdown: An empty type string means QGIS picks a default from the field type — a text edit for strings, a spin box for integers, a date edit for dates. The configuration keys are specific to each widget type and are not validated: a misspelt key is silently ignored and the widget falls back to its default for that option, which is why printing the setup of a field configured by hand in the Layer Properties dialog is the most reliable way to discover the exact keys your QGIS version writes. The small helper raises on a missing field, because indexOf returns -1 and setEditorWidgetSetup(-1, …) does nothing without complaint.
Value maps for coded values
A value map shows a readable label and stores a code. It is the right widget for short, fixed lists — condition classes, land-use codes, yes/no/unknown — that belong to the data model rather than to a separate table.
set_widget(trees, "condition", "ValueMap", {
"map": [
{"1 – dead or dying": 1},
{"2 – poor": 2},
{"3 – fair": 3},
{"4 – good": 4},
{"5 – excellent": 5},
]
})
set_widget(trees, "ownership", "ValueMap", {
"map": [
{"Council": "CNL"},
{"Private": "PRV"},
{"Highways authority": "HWY"},
{"<NULL>": "{2839923C-8B7D-419E-B84B-CA2FE9B80EC7}"},
]
})
Breakdown: The configuration is a list of single-entry dictionaries mapping label to stored value; a list preserves order, which a plain dictionary would too on modern Python but older QGIS releases did not rely on. Stored values should match the field type — integers for an integer field — or the widget shows the raw value instead of the label. The long brace-wrapped string is the sentinel QGIS uses to offer an explicit NULL entry in the drop-down; copy it exactly. The attribute table displays the labels too, so users never see the codes.
Value relations to a lookup layer
When the list is long, shared between layers, or maintained by someone else — a species list, a register of assets, a table of staff — keep it in a layer and point a value relation at it. Adding a row to the lookup updates every form that uses it.
species = QgsProject.instance().mapLayersByName("species_lookup")[0]
set_widget(trees, "species_code", "ValueRelation", {
"Layer": species.id(),
"LayerName": species.name(),
"LayerSource": species.source(),
"LayerProviderName": species.providerType(),
"Key": "code",
"Value": "common_name",
"OrderByValue": True,
"AllowNull": False,
"AllowMulti": False,
"UseCompleter": True,
"FilterExpression": "\"planting_approved\" = true",
})
Breakdown: Layer holds the lookup layer's id, which is the link QGIS actually follows; the name, source and provider are stored alongside so the relation can be re-established if the project is opened with a different layer id — for instance after the lookup layer was removed and re-added. Key is the stored column, Value the displayed one. UseCompleter turns the combo box into a type-ahead search, which matters once the list is longer than a screen. FilterExpression restricts choices without changing the lookup; it can even reference the feature being edited with current_value('field'), which is how cascading drop-downs — region, then district within region — are built. For a genuine one-to-many relationship rather than a lookup, defining layer relations is the better model.
Ranges, checkboxes and dates
Numbers, booleans and dates each have a widget that makes invalid input impossible rather than merely discouraged.
set_widget(trees, "height_m", "Range", {
"Min": 0.5, "Max": 45.0, "Step": 0.5, "Precision": 1,
"Style": "SpinBox", "AllowNull": True,
})
set_widget(trees, "tpo", "CheckBox", {
"CheckedState": "Y",
"UncheckedState": "N",
})
set_widget(trees, "surveyed_on", "DateTime", {
"field_format": "yyyy-MM-dd",
"display_format": "d MMM yyyy",
"calendar_popup": True,
"allow_null": False,
})
Breakdown: Range limits spin boxes and sliders to the bounds given; with AllowNull the widget also offers an empty state, which is important for measurements that were not taken as opposed to measurements of zero. CheckBox stores whatever values the field uses — Y/N text, 1/0 integers, or a real boolean when the states are left empty on a boolean field. For DateTime, field_format describes how the value is stored in a text field and is ignored for true date fields, while display_format only affects what users see. Storing ISO dates in a text field keeps sorting correct even in formats that have no date type.
Attachments for photos and documents
The ExternalResource widget stores a path and shows the file — a photo preview, or a link that opens a PDF. Paired with relative storage it keeps working when the project folder moves.
set_widget(trees, "photo", "ExternalResource", {
"FileWidget": True,
"FileWidgetButton": True,
"DocumentViewer": 1,
"DocumentViewerHeight": 240,
"DocumentViewerWidth": 320,
"RelativeStorage": 1,
"StorageMode": 0,
"FileWidgetFilter": "Images (*.jpg *.jpeg *.png)",
})
Breakdown: DocumentViewer 1 shows an image preview inside the form; 2 embeds a web view, useful for PDFs on platforms that render them; 0 shows the path only. RelativeStorage 1 stores paths relative to the project file and 2 relative to a DefaultRoot you set, which suits a shared photo directory on a server. StorageMode 0 picks files, 1 picks folders. Photos imported with native:importphotos already have a path field that this widget displays directly.
Roll the scheme out to many layers
A survey project often has several layers with the same fields — one per ward, one per year, one per crew. Define the widget scheme once as data and apply it wherever the fields exist.
from qgis.core import QgsMapLayer
SCHEME = {
"condition": ("ValueMap", {"map": [{"1 – dead or dying": 1}, {"2 – poor": 2},
{"3 – fair": 3}, {"4 – good": 4},
{"5 – excellent": 5}]}),
"height_m": ("Range", {"Min": 0.5, "Max": 45.0, "Step": 0.5,
"Style": "SpinBox", "AllowNull": True}),
"surveyed_on": ("DateTime", {"display_format": "d MMM yyyy",
"calendar_popup": True}),
}
for layer in QgsProject.instance().mapLayers().values():
if layer.type() != QgsMapLayer.VectorLayer or not layer.name().startswith("trees_"):
continue
missing = []
for field_name, (widget_type, config) in SCHEME.items():
if layer.fields().indexOf(field_name) < 0:
missing.append(field_name)
continue
set_widget(layer, field_name, widget_type, config)
layer.saveStyleToDatabase("default", "survey form", True, "",
QgsMapLayer.StyleCategory.Forms)
print(layer.name(), "missing:", missing or "none")
Breakdown: Keeping the scheme as a dictionary means the form design lives in one reviewable place and can be version-controlled next to the plugin or script that applies it. Missing fields are reported rather than treated as errors, because older layers legitimately lack newer attributes. Saving with the Forms style category writes only the form configuration into the GeoPackage's layer_styles table as the default style — symbology is untouched — so the next person to add the layer from that file gets the widgets automatically. For layers that should also share symbology, copying a style between layers covers the full style route.
QGIS version compatibility
Widget type names and the configuration keys shown have been stable since QGIS 3.0; UseCompleter was added to value relations in 3.4 and FileWidgetFilter in 3.10. QgsMapLayer.StyleCategory.Forms is the scoped spelling used on the QGIS 4 series; on 3.x the unscoped QgsMapLayer.Forms also works. saveStyleToDatabase gained a categories argument in 3.26 — on older releases it saves every category.
Troubleshooting
- The drop-down shows codes instead of labels. The stored values in the map do not match the field type;
"1"is not1. - A value relation is empty. The
Layerid points at a layer that is not in the project, or the filter expression excludes everything. - Widgets vanish when the layer is loaded elsewhere. They were saved in the project, not in the layer style; save the
Formscategory to the data source. - The date widget shows
NULLfor every row.field_formatdoes not match how dates are stored in a text field. - Photo preview is blank. The path is relative to a different root than
RelativeStorageexpects.
Conclusion
Give every field that people type into a widget that only accepts valid values: value maps for short fixed lists, value relations for shared or changing ones, ranges, checkboxes and date pickers for typed data, and attachment widgets for files. Keep the configuration as a scheme in code, apply it across layers, and save the Forms style category with the data so the form follows the layer wherever it goes.
Frequently Asked Questions
Do widgets validate values written by scripts? No. They constrain user input in forms and the attribute table; values written through the API bypass them. Use field constraints for rules that must hold regardless.
Can I see which widget types are available?QgsGui.editorWidgetRegistry().factories() returns a dictionary keyed by type name.
Do widgets work in QField and Mergin Maps? Yes — both read the form configuration from the project, so widgets set here carry over to mobile data collection.
How do I hide a field from the form?
Use the Hidden widget type with an empty configuration.