Connecting QGIS Form Widgets to Vector Layer Attributes

Use QgsEditorWidgetSetup and the layer edit buffer to bind Qt controls to QgsVectorLayer fields in QGIS 3.x — with production code, signal wiring, undo/redo…

TL;DR: Bind a Qt control to a QgsVectorLayer field by calling layer.setEditorWidgetSetup(field_idx, QgsEditorWidgetSetup(...)) for declarative form configuration, or by connecting changeAttributeValue() and attributeValueChanged for custom dialogs — both paths run through the layer’s edit buffer, preserving undo/redo and constraint validation automatically.

This page is part of the Designing Qt Dialogs and Form Widgets guide, which itself sits within Plugin Development & UI Integration.

Complete Runnable Template

The snippet below is a self-contained, drop-in dialog that binds a single QLineEdit to a named field on any valid QgsVectorLayer. It handles missing fields, blocks recursive signals, and leaves changes in the edit buffer for undo/redo — wire commitChanges() only when your workflow demands an immediate flush to disk.

python
"""
attribute_binding_dialog.py
Bidirectional binding between a QLineEdit and a QgsVectorLayer field.
Compatible with QGIS 3.16+ / Python 3.9+.
"""
from __future__ import annotations

from qgis.core import QgsVectorLayer
from qgis.PyQt.QtCore import pyqtSlot
from qgis.PyQt.QtWidgets import (
    QLabel,
    QLineEdit,
    QPushButton,
    QVBoxLayout,
    QWidget,
)


class AttributeBindingDialog(QWidget):
    """A minimal widget that keeps a QLineEdit in sync with one layer field.

    Args:
        layer: The target QgsVectorLayer (must be valid).
        feature_id: The QgsFeature FID to read/write.
        field_name: The attribute field name to bind.
        parent: Optional Qt parent widget.

    Raises:
        RuntimeError: If *layer* is None or invalid.
        KeyError: If *field_name* does not exist in the layer schema.
    """

    def __init__(
        self,
        layer: QgsVectorLayer,
        feature_id: int,
        field_name: str,
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        if layer is None or not layer.isValid():
            raise RuntimeError("Layer is None or invalid — cannot bind widgets.")
        self.layer = layer
        self.feature_id = feature_id
        self.field_name = field_name
        self.field_idx = self.layer.fields().indexFromName(field_name)
        if self.field_idx == -1:
            raise KeyError(
                f"Field '{field_name}' not found in layer '{layer.name()}' schema."
            )

        self._build_ui()
        self._load_initial_value()
        self._connect_signals()

    # ------------------------------------------------------------------
    # UI construction
    # ------------------------------------------------------------------

    def _build_ui(self) -> None:
        layout = QVBoxLayout(self)
        self.label = QLabel(f"Edit <b>{self.field_name}</b>:")
        self.input_widget = QLineEdit()
        self.input_widget.setPlaceholderText(f"Enter value for {self.field_name}…")
        self.save_btn = QPushButton("Write to edit buffer")
        self.save_btn.clicked.connect(self._commit_to_buffer)
        layout.addWidget(self.label)
        layout.addWidget(self.input_widget)
        layout.addWidget(self.save_btn)

    # ------------------------------------------------------------------
    # Data loading
    # ------------------------------------------------------------------

    def _load_initial_value(self) -> None:
        """Read the current attribute value from the layer and populate the widget."""
        feat = self.layer.getFeature(self.feature_id)
        if feat.isValid():
            raw = feat[self.field_idx]
            self.input_widget.setText("" if raw is None else str(raw))

    # ------------------------------------------------------------------
    # Slots
    # ------------------------------------------------------------------

    @pyqtSlot()
    def _commit_to_buffer(self) -> None:
        """Write the current widget text to the layer's edit buffer."""
        if not self.layer.isEditable():
            self.layer.startEditing()

        field = self.layer.fields()[self.field_idx]
        raw_text = self.input_widget.text()

        # Use QGIS-aware coercion to match the field's native type.
        typed_value, ok = field.convertCompatible(raw_text)
        if not ok:
            typed_value = raw_text  # Fall back; validation expression will catch it.

        self.layer.changeAttributeValue(self.feature_id, self.field_idx, typed_value)

    @pyqtSlot(int, int, object)
    def _on_external_change(self, fid: int, idx: int, value: object) -> None:
        """Reflect changes made by other dialogs or scripts without triggering re-entry."""
        if fid == self.feature_id and idx == self.field_idx:
            new_text = "" if value is None else str(value)
            if self.input_widget.text() != new_text:
                self.input_widget.blockSignals(True)
                self.input_widget.setText(new_text)
                self.input_widget.blockSignals(False)

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def _connect_signals(self) -> None:
        self.layer.attributeValueChanged.connect(self._on_external_change)

    def closeEvent(self, event) -> None:  # type: ignore[override]
        """Disconnect layer signals before the widget is destroyed."""
        try:
            self.layer.attributeValueChanged.disconnect(self._on_external_change)
        except RuntimeError:
            pass  # Layer already deleted — safe to ignore.
        super().closeEvent(event)

Data-Flow Diagram

The diagram below shows how user input, the edit buffer, external changes, and provider commits all interlock. Understanding this flow prevents the most common mistakes: bypassing the buffer, or writing to the provider inside a slot that fires during a transaction.

Qt widget to QGIS edit buffer data-flow A flowchart showing how QLineEdit user input calls changeAttributeValue to write to the layer edit buffer, which then offers commitChanges to the data provider or rollBack to discard changes. The attributeValueChanged signal flows back from the buffer to update the widget when external scripts or other dialogs modify the same attribute. QLineEdit (Qt widget) Edit Buffer QgsVectorLayer undo / redo stack Data Provider (GeoPackage / PG…) rollBack() discards buffer changeAttributeValue() attributeValueChanged commitChanges() rollBack()

Architecture Breakdown

QgsEditorWidgetSetup — Declarative Field Configuration

QgsEditorWidgetSetup attaches a widget type and its configuration dictionary to a field at the layer schema level. This persists across sessions and applies to both the default attribute form and any QgsAttributeDialog instantiated programmatically. It does not require an active edit session.

python
"""
configure_field_widget.py
Apply a declarative widget type to a named field.
"""
from __future__ import annotations

from qgis.core import QgsEditorWidgetSetup, QgsProject, QgsVectorLayer


def configure_field_widget(
    layer_id: str,
    field_name: str,
    widget_type: str = "TextEdit",
    config: dict | None = None,
) -> None:
    """Attach *widget_type* to *field_name* on the layer identified by *layer_id*.

    The configuration persists in the project file (QGIS 3.16+).
    Supported widget_type values: TextEdit, ValueMap, Range, DateTime,
    FileName, UuidGenerator, RelationReference, Hidden.

    Args:
        layer_id: The layer's unique ID string from QgsProject.
        field_name: The attribute field name to configure.
        widget_type: The QGIS editor widget type string.
        config: Type-specific configuration dict (see notes below).

    Raises:
        RuntimeError: If the layer is not found or invalid.
        KeyError: If *field_name* is not present in the layer schema.
    """
    layer: QgsVectorLayer | None = QgsProject.instance().mapLayer(layer_id)
    if layer is None or not layer.isValid():
        raise RuntimeError(f"Layer '{layer_id}' is invalid or not loaded.")

    field_idx = layer.fields().indexFromName(field_name)
    if field_idx == -1:
        raise KeyError(f"Field '{field_name}' not found in layer schema.")

    effective_config: dict = config or {"IsMultiline": False, "UseHtml": False}
    setup = QgsEditorWidgetSetup(widget_type, effective_config)
    layer.setEditorWidgetSetup(field_idx, setup)

Widget type configuration reference:

  • ValueMap{"map": [{"Display Label": "stored_value"}, ...]}
  • Range{"Min": 0, "Max": 100, "Step": 1, "Style": "SpinBox"}
  • DateTime{"field_format": "yyyy-MM-dd", "calendar_popup": True}
  • RelationReference{"Relation": "relation_id", "AllowAddFeatures": False, "ShowOpenFormButton": True}

layer.fields().indexFromName() — The Integer Index Contract

Every attribute API call (changeAttributeValue, setEditorWidgetSetup, setConstraintExpression) uses an integer field index, never the field name string. Resolve this index once at construction time and cache it — calling indexFromName in a tight loop or inside a signal slot is wasteful and occasionally returns stale results if the schema has changed since the layer was loaded.

If indexFromName returns -1, the field does not exist. Do not fall back to index 0; raise immediately with the field name and layer name in the message so the error is actionable.

changeAttributeValue() — Writing to the Edit Buffer

layer.changeAttributeValue(fid, field_idx, new_value) pushes a delta onto the undo stack. QGIS records the old value automatically, so a subsequent layer.rollBack() restores it without any bookkeeping on your side. The call is a no-op if the layer is not in edit mode — always gate it with layer.isEditable() and call layer.startEditing() first.

The return value is True on success, False on failure (e.g., a constraint expression rejects the value). Check it in production code and surface the failure to the user via QgsMessageBar rather than silently swallowing it.

attributeValueChanged — Listening for External Edits

The signal fires whenever any code path writes to the edit buffer for this layer: the attribute table, another custom dialog, the Python console, or a Processing algorithm. Its signature is (fid: int, idx: int, value: object).

Guard the update inside blockSignals(True) / blockSignals(False) to prevent the widget’s own textChanged signal from retriggering _commit_to_buffer, which would write back the same value, emit attributeValueChanged again, and loop indefinitely.

Edit Session Lifecycle

QGIS’s edit buffer is activated by layer.startEditing() and closed by either layer.commitChanges() (flush to provider) or layer.rollBack() (discard). Both emit editingStopped. Calling startEditing() on a layer that is already in edit mode is a safe no-op — you do not need to check layer.isEditable() before every startEditing() call, only before changeAttributeValue().

Never bypass the edit buffer by calling layer.dataProvider().changeAttributeValues() directly from a plugin. Doing so skips constraint validation, breaks the undo stack that the signal and slot event handling subsystem relies on, and produces inconsistent state in multi-layer transactions.

Registration / Integration Snippet

To open the binding dialog from a toolbar action or menu item inside a plugin, resolve the active layer and the target feature from the map canvas selection, then instantiate the widget:

python
"""
plugin_action_handler.py
Open AttributeBindingDialog for the first selected feature.
"""
from __future__ import annotations

from qgis.core import QgsVectorLayer
from qgis.utils import iface

from .attribute_binding_dialog import AttributeBindingDialog


def open_attribute_editor(field_name: str = "notes") -> None:
    """Launch the attribute binding dialog for the active layer's first selection.

    Attach this function to a QAction triggered signal in your plugin's
    initGui() method. See the plugin lifecycle guide for resource cleanup.

    Args:
        field_name: Name of the attribute field to bind.
    """
    layer = iface.activeLayer()
    if not isinstance(layer, QgsVectorLayer):
        iface.messageBar().pushWarning("Attribute Editor", "Select a vector layer first.")
        return

    selected_ids = layer.selectedFeatureIds()
    if not selected_ids:
        iface.messageBar().pushWarning("Attribute Editor", "Select at least one feature.")
        return

    fid = selected_ids[0]
    try:
        dlg = AttributeBindingDialog(layer, fid, field_name, parent=iface.mainWindow())
    except (RuntimeError, KeyError) as exc:
        iface.messageBar().pushCritical("Attribute Editor", str(exc))
        return

    dlg.show()
    # Keep a reference so Qt does not garbage-collect the dialog.
    iface.mainWindow().setProperty("_attr_binding_dlg", dlg)

Wire this in your plugin’s initGui():

python
from qgis.PyQt.QtWidgets import QAction

self._action = QAction("Edit Attribute…", iface.mainWindow())
self._action.triggered.connect(lambda: open_attribute_editor("notes"))
iface.addToolBarIcon(self._action)

For the full teardown pattern — disconnecting actions, removing toolbar icons, and releasing layer references — see properly cleaning up plugin resources on QGIS shutdown.

Production Best Practices

  • Cache field_idx at construction. Never call indexFromName inside a signal handler; the layer schema is stable within a session and the lookup is not free.
  • Type-coerce before writing. Qt text widgets return str. Call field.convertCompatible(raw_text) to get the native field type (int, float, date). Passing a raw string to a numeric field will silently store NULL on some providers.
  • Check changeAttributeValue return value. A False return means a constraint expression fired. Retrieve the constraint description via layer.fields()[field_idx].constraintDescription() and display it in a QgsMessageBar warning.
  • Disconnect in closeEvent. Wrap the disconnect call in a try/except RuntimeError because the layer may have been removed from QgsProject between the dialog opening and closing.
  • Respect QgsVectorDataProvider capabilities. Before calling startEditing(), verify layer.dataProvider().capabilities() & QgsVectorDataProvider.ChangeAttributeValues. WFS and some WMS providers advertise read-only mode at runtime.
  • Multi-user environments. On PostgreSQL layers with QgsTransaction, wrap edits in a shared transaction group so concurrent commits from other sessions do not produce constraint violations in your buffer.
  • Test with QgsMemoryProviderUtils. Unit-test the dialog by creating an in-memory layer with QgsMemoryProviderUtils.createMemoryLayer(...). This avoids file I/O and gives you a clean, deterministic attribute schema.