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.

A form is a tree of containersThe invisible root container has three tab children: Site, Condition and Follow-up. Site holds fields asset_id, species_code and height_m arranged in two columns. Condition holds the condition field and a Damage group box that is only visible when condition is 2 or less. Follow-up holds action_required and a relation element showing repair records from a child layer.Root → tabs → groups → fieldsinvisible roottab: Site · 2 colstab: Conditiontab: Follow-upasset_idspecies_codeheight_mconditiongroup: Damagevisible if condition ≤ 2damage_typedamage_notesaction_requiredrelation: repairschild records inline

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.

Sections that appear only when relevantTwo states of the Condition tab. With condition 4 good, only the condition drop-down is visible and the form is short. With condition 2 poor, the Damage group box appears below it with damage type and notes fields, because its visibility expression condition less than or equal to 2 now evaluates true. The expression is re-evaluated as the user edits.The form grows only when the answer needs itcondition = 4Condition4 – goodDamage group hiddencondition = 2Condition2 – poorDamageDamage typeNotes

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.

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.

One layout function, many layersA build_form function takes a layer and a layout specification listing tabs, groups and field names. It is applied to three inspection layers. Fields present in a layer are placed; fields missing from a layer are skipped and reported. Each layer then saves its Forms style category so the layout goes with the data.Describe the layout as data, build it in a loopLAYOUT specSite: asset_id, species…Condition: condition Damage if cond ≤ 2Follow-up: action…build_form()per layerinspections_north · completeinspections_south · completeinspections_2019 · no girth_cm

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.