Build a Tabbed Attribute Form Layout in PyQGIS
The form QGIS generates for a layer is a single column of every field in table order, labelled with raw field names. For a layer with six fields that is fine. For an inspection record with forty — site details, measurements, damage observations, follow-up actions, photos, related repair records — it is a scrolling wall that nobody fills in consistently. The drag-and-drop designer fixes that with tabs, groups and conditional sections, and everything it does can be built from Python, which means one script can lay out the same form on every layer that needs it.
This recipe belongs to Attribute Forms & Layer Actions in PyQGIS. It builds a form tree from containers and fields, adds columns and a section that only appears when it is relevant, and embeds related child records.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series. Container types and visibility expressions shown here need 3.32 or later.
- A vector layer with enough fields to justify structure, plus a relation to a child layer if you want to embed related records.
Switch to a designed layout and add tabs
The form configuration has a layout mode. In drag-and-drop mode, the form is a tree hanging off an invisible root container; tabs are containers added directly under the root, and fields are elements inside them.
from qgis.core import (
Qgis, QgsProject, QgsAttributeEditorContainer, QgsAttributeEditorField,
)
inspections = QgsProject.instance().mapLayersByName("inspections")[0]
fields = inspections.fields()
def field_element(name, parent):
idx = fields.indexOf(name)
if idx < 0:
raise KeyError(name)
return QgsAttributeEditorField(name, idx, parent)
config = inspections.editFormConfig()
config.setLayout(Qgis.AttributeFormLayout.DragAndDrop)
config.clearTabs()
root = config.invisibleRootContainer()
site = QgsAttributeEditorContainer("Site", root)
site.setType(Qgis.AttributeEditorContainerType.Tab)
site.setColumnCount(2)
for name in ("asset_id", "species_code", "height_m", "girth_cm", "surveyed_on", "surveyor"):
site.addChildElement(field_element(name, site))
root.addChildElement(site)
inspections.setEditFormConfig(config)
Breakdown: clearTabs() removes any existing designed layout, so running the script twice gives the same form rather than doubling it. Every element takes its parent in the constructor and must be added to that parent with addChildElement; the constructor argument only records the relationship. setColumnCount(2) lays the six fields out in two columns of three, which is what makes a tab look designed rather than listed. As with all form configuration, editFormConfig() hands you a copy, and nothing changes until setEditFormConfig writes it back.
Any field not placed in the tree does not appear in the form at all. That is useful for technical columns — ids, audit timestamps filled by default values — but it also means a field added to the table later stays invisible until the layout is updated.
Groups, visibility and readable labels
Inside a tab, group boxes cluster related fields, and any container can carry a visibility expression so it appears only when it applies. Aliases and label positions do the rest of the work of making a form readable.
from qgis.core import QgsExpression, QgsOptionalExpression
condition_tab = QgsAttributeEditorContainer("Condition", root)
condition_tab.setType(Qgis.AttributeEditorContainerType.Tab)
condition_tab.addChildElement(field_element("condition", condition_tab))
damage = QgsAttributeEditorContainer("Damage", condition_tab)
damage.setType(Qgis.AttributeEditorContainerType.GroupBox)
damage.setColumnCount(1)
damage.setVisibilityExpression(
QgsOptionalExpression(QgsExpression('"condition" <= 2')))
for name in ("damage_type", "damage_extent", "damage_notes"):
damage.addChildElement(field_element(name, damage))
condition_tab.addChildElement(damage)
root.addChildElement(condition_tab)
aliases = {
"asset_id": "Asset ID", "species_code": "Species", "height_m": "Height (m)",
"girth_cm": "Girth at 1.5 m (cm)", "damage_notes": "Describe the damage",
}
for name, alias in aliases.items():
inspections.setFieldAlias(fields.indexOf(name), alias)
config.setLabelOnTop(fields.indexOf("damage_notes"), True)
inspections.setEditFormConfig(config)
Breakdown: QgsOptionalExpression wraps the expression with an enabled flag, which is what the designer's checkbox toggles; constructing it from an expression enables it. The expression is re-evaluated as values change, so the section appears the moment a poor condition is chosen — no Python in the form. Hidden fields keep their values, so pair a conditional section with a constraint or an applyOnUpdate default if stale damage notes on a now-healthy tree would be a problem. Aliases are set on the layer and used everywhere field names are shown to users: the form, the attribute table, identify results. Putting the label on top gives a multi-line notes field the full width of the form.
Embed related records
When the layer is the parent in a relation — a tree and its repair records, an inspection and its photos — a relation element shows the child features inside the parent's form, with buttons to add, link and open them.
from qgis.core import QgsAttributeEditorRelation
relation = QgsProject.instance().relationManager().relation("inspections_repairs")
if not relation.isValid():
raise RuntimeError("relation inspections_repairs is not defined")
follow_up = QgsAttributeEditorContainer("Follow-up", root)
follow_up.setType(Qgis.AttributeEditorContainerType.Tab)
follow_up.addChildElement(field_element("action_required", follow_up))
follow_up.addChildElement(field_element("priority", follow_up))
repairs = QgsAttributeEditorRelation(relation, follow_up)
repairs.setLabel("Repairs carried out")
repairs.setShowLabel(True)
follow_up.addChildElement(repairs)
root.addChildElement(follow_up)
inspections.setEditFormConfig(config)
Breakdown: The relation must already exist in the project's relation manager, which is covered in defining layer relations; the element only refers to it by id. The embedded child table uses the child layer's own form configuration when a record is opened, so the repair records can have their own tabs and widgets. Putting relations on their own tab keeps the parent form fast to open, because child features are only fetched when the tab is shown.
Build it from a specification
Hand-writing the tree for every layer repeats itself quickly. A small specification — tabs, optional groups with visibility expressions, field names — and one function that walks it make the layout reviewable and reusable.
LAYOUT = [
("Site", 2, ["asset_id", "species_code", "height_m", "girth_cm"], []),
("Condition", 1, ["condition"], [
("Damage", '"condition" <= 2', ["damage_type", "damage_notes"]),
]),
("Follow-up", 1, ["action_required", "priority"], []),
]
def build_form(layer, layout):
config = layer.editFormConfig()
config.setLayout(Qgis.AttributeFormLayout.DragAndDrop)
config.clearTabs()
root = config.invisibleRootContainer()
names = set(layer.fields().names())
skipped = []
def add_fields(container, field_names):
for name in field_names:
if name not in names:
skipped.append(name)
continue
container.addChildElement(QgsAttributeEditorField(
name, layer.fields().indexOf(name), container))
for title, columns, tab_fields, groups in layout:
tab = QgsAttributeEditorContainer(title, root)
tab.setType(Qgis.AttributeEditorContainerType.Tab)
tab.setColumnCount(columns)
add_fields(tab, tab_fields)
for group_title, visible_if, group_fields in groups:
group = QgsAttributeEditorContainer(group_title, tab)
group.setType(Qgis.AttributeEditorContainerType.GroupBox)
if visible_if:
group.setVisibilityExpression(
QgsOptionalExpression(QgsExpression(visible_if)))
add_fields(group, group_fields)
tab.addChildElement(group)
root.addChildElement(tab)
layer.setEditFormConfig(config)
return skipped
for layer in QgsProject.instance().mapLayers().values():
if layer.name().startswith("inspections"):
print(layer.name(), "skipped:", build_form(layer, LAYOUT) or "none")
Breakdown: The specification is plain Python data, so it can live in a plugin module, be diffed in code review, and be loaded from JSON if non-developers maintain it. Skipping missing fields keeps older layers working with a newer layout and reports what they lack. After building, save the Forms style category to the data source — as in configuring editor widgets — so the layout is picked up wherever the layer is loaded.
Read an existing layout back
Forms designed by hand in the Layer Properties dialog are often the starting point: someone has already arranged the fields sensibly, and the job is to capture that arrangement as a specification so it can be applied elsewhere. Walking the tree recursively turns any designed form back into data.
from qgis.core import QgsAttributeEditorElement
def describe(element, depth=0):
pad = " " * depth
kind = element.type()
if isinstance(element, QgsAttributeEditorContainer):
visible = element.visibilityExpression()
condition = f" if {visible.data().expression()}" if visible.enabled() else ""
print(f"{pad}[{element.name()}] cols={element.columnCount()}{condition}")
for child in element.children():
describe(child, depth + 1)
elif isinstance(element, QgsAttributeEditorField):
print(f"{pad}- {element.name()}")
else:
print(f"{pad}* {element.name()} ({kind})")
source = QgsProject.instance().mapLayersByName("inspections_template")[0]
for tab in source.editFormConfig().invisibleRootContainer().children():
describe(tab)
Breakdown: Every element in the tree is a subclass of QgsAttributeEditorElement, so isinstance checks sort containers, fields and everything else — relations, text blocks, HTML — into the right branch. Printing the visibility expression only when it is enabled matches what users actually see, because the designer keeps a disabled expression around. The output reads almost exactly like the LAYOUT specification used by build_form, and extending describe to return tuples instead of printing gives you a converter from a hand-designed form to a reusable one. That round trip — design visually once, capture as data, apply everywhere by script — is usually the most practical way for a team to agree a form and then roll it out.
QGIS version compatibility
setType with Qgis.AttributeEditorContainerType arrived in QGIS 3.32; earlier releases use setIsGroupBox(True) for group boxes and False for tabs, and QgsEditFormConfig.TabLayout instead of Qgis.AttributeFormLayout.DragAndDrop. Visibility expressions on containers have existed since 3.0. The QGIS 4 series accepts only the scoped enum forms. Mobile apps such as QField honour tabs, groups, visibility expressions and relation editors.
Troubleshooting
- The form still shows every field in a list. The layout mode was not switched, or the config was not written back.
- A tab is empty. Elements were created with the tab as parent but never added with
addChildElement. - Fields appear twice. The script ran twice without
clearTabs(). - A conditional group never appears. The expression compares a coded value to a label; compare to the stored value.
- The relation element shows nothing. The relation id is wrong or the relation is invalid; check
relation.isValid().
Conclusion
Switch the form to the drag-and-drop layout, build tabs and group boxes as containers under the invisible root, and add fields by name. Use columns and aliases to make tabs readable, visibility expressions to keep sections out of the way until they apply, and relation elements to bring child records into the parent's form. Keep the layout as a specification and build it with one function so every matching layer gets the same form.
Frequently Asked Questions
Can I read an existing designed form back into Python?
Yes. Walk config.invisibleRootContainer().children() recursively; each child is a container, field, relation or other element with its own properties.
Can a tab contain plain explanatory text?
Yes — add a QgsAttributeEditorTextElement (3.30+) or QgsAttributeEditorHtmlElement with instructions for the person filling in the form.
Do layouts work in the attribute table's form view? Yes. The form view in the attribute table uses the same layout.
Can I stop the form popping up after digitising?
Set config.setSuppress(Qgis.AttributeFormSuppression.On); defaults are still applied.