Define Layer Relations in PyQGIS

A relation tells QGIS that rows in one layer belong to rows in another — inspections to a parcel, readings to a gauge, defects to a pipe. Once one exists, the parent's attribute form grows a table of its children, relation_aggregate starts working, and cascading delete becomes possible. None of that happens automatically from a foreign key in the data; you have to declare it.

This recipe belongs to Working with QGIS Projects. It covers building a QgsRelation, the direction of the field mapping, relation strength, registering it in the project, and reading related features from Python.

Referencing points at referencedThe parent layer holds the referenced field, usually its primary key. The child layer holds the referencing field, the foreign key. The relation records that the child's field points at the parent's field, and QGIS uses that to show each parent's children in its form and to resolve relation aggregates.The child does the referencingparcels — the parentparcel_id use_classP-1043 residentialP-1044 retailreferencedField = "parcel_id"inspections — the childparcel_fk inspected_onP-1043 2026-03-11P-1043 2026-07-02referencingField = "parcel_fk"points atget these two the wrong way round and the relation silently finds nothingaddFieldPair(referencingField, referencedField) — child first

Prerequisites

  • QGIS 3.34 LTR or newer.
  • Two layers loaded in the project with a shared key — a parent and its children.
  • The parent's key field should be unique; QGIS does not enforce it and a duplicate produces children attached to several parents.

Build and register the relation

from qgis.core import QgsProject, QgsRelation

project = QgsProject.instance()
parcels = project.mapLayersByName("parcels")[0]
inspections = project.mapLayersByName("inspections")[0]

relation = QgsRelation()
relation.setId("parcel_inspections")
relation.setName("Inspections")
relation.setReferencedLayer(parcels.id())
relation.setReferencingLayer(inspections.id())
relation.addFieldPair("parcel_fk", "parcel_id")

if not relation.isValid():
    raise SystemExit("relation is not valid — check layer ids and field names")

project.relationManager().addRelation(relation)

Breakdown: addFieldPair takes the referencing field first and the referenced field second — child, then parent — and reversing them produces a relation that validates and finds nothing, which is the single most common mistake here. The layers are identified by id rather than by object, so both must already be in the project. setId is what relation_aggregate and the project file refer to, so give it something stable and readable rather than letting QGIS generate one; setName is the label a user sees. isValid() checks that both layers exist and both fields resolve, and it is worth asserting before registering.

A composite key is expressed as several field pairs:

relation.addFieldPair("site_fk", "site_id")
relation.addFieldPair("year_fk", "year")

Breakdown: All pairs must match for a child to belong to a parent, which is an AND rather than an OR. Composite relations work everywhere single-field ones do, but the attribute form's relation editor handles them less gracefully, so a surrogate single key is worth considering if users will edit through the form.

Relation strength and deletion

from qgis.core import Qgis

relation.setStrength(Qgis.RelationshipStrength.Composition)

Breakdown: Strength decides what happens to children when a parent is deleted or copied. Association — the default — means the children are independent: deleting a parcel leaves its inspections orphaned. Composition means the children belong to the parent: deleting the parcel deletes its inspections, and copying the parcel copies them. Composition is right when the child records have no meaning without the parent, which is the usual case for inspections, readings and defects, and wrong when the child is a shared reference table. On 3.28 and earlier the enum is QgsRelation.Composition.

Because composition deletes data, it is worth setting deliberately rather than by default, and worth stating in whatever documentation the project carries.

Both directions, two methodsFrom a parent feature, getRelatedFeatures returns a request that iterates its children. From a child feature, getReferencedFeature returns the single parent it points at. Both come from the relation object, and neither requires writing the join condition by hand.The relation knows the join; you never write itparent → childrenone parcelmany inspectionsgetRelatedFeatures(parent)child → parentone inspectionone parcelexactly one, or an invalid featuregetReferencedFeature(child)check isValid() on the returned parent — an orphan child returns an invalid one

relation = project.relationManager().relation("parcel_inspections")

parent = next(parcels.getFeatures())
request = relation.getRelatedFeaturesRequest(parent)
for child in inspections.getFeatures(request):
    print(child["inspected_on"], child["condition"])

child = next(inspections.getFeatures())
owner = relation.getReferencedFeature(child)
print("belongs to", owner["parcel_id"] if owner.isValid() else "nothing")

Breakdown: getRelatedFeaturesRequest returns a QgsFeatureRequest with the filter already built, which you then pass to the child layer's getFeatures — the relation does not fetch for you. That indirection is useful, because you can add a further filter or a subset of attributes to the request before running it. getReferencedFeature returns a feature that may be invalid when the child's key matches no parent, and checking isValid() is what turns a silent wrong answer into a detected orphan. Finding all orphans is that check in a loop, and it is a good data-quality report to run once on any dataset you did not create.

Auditing the relation against the data

A relation describes what you believe about the data. Checking that belief takes a few lines and is worth doing once on any dataset you inherited.

parent_keys = {f["parcel_id"] for f in parcels.getFeatures()}

orphans = []
duplicates = len(parent_keys) != parcels.featureCount()

for child in inspections.getFeatures():
    key = child["parcel_fk"]
    if key is None or key not in parent_keys:
        orphans.append(child.id())

print(f"{len(orphans)} orphaned children")
print("parent key is not unique" if duplicates else "parent key is unique")

Breakdown: Building the parent key set once and testing membership makes this linear rather than quadratic, which matters on any real dataset. Comparing the set's size against the feature count is the cheapest possible uniqueness check — if they differ, some key value appears twice, and every child with that key will be attached to both parents, which QGIS will happily display without comment. Treating a null foreign key as an orphan is the usual convention; where nulls legitimately mean "not yet assigned", count them separately so the number of genuine breakages stays visible.

Running this before and after a data load is a cheap regression test, and it catches the class of problem — a key column re-typed from text to integer somewhere in an export chain — that makes a working relation stop matching without anything visibly changing.

Making the form useful

A relation on its own gives the parent's attribute form a child table. Two settings make it pleasant rather than merely present:

from qgis.core import QgsEditorWidgetSetup

config = {"Relation": "parcel_inspections", "ShowForm": False, "AllowAddFeatures": True}
setup = QgsEditorWidgetSetup("RelationReference", config)

Breakdown: The relation-reference widget on the child's foreign key field turns a free-text box into a picker of valid parents, which is the single biggest data-quality improvement a relation enables — a typed key can be wrong, a picked one cannot. ShowForm false keeps the embedded parent form collapsed so the child form stays compact. The parent side's child table is configured through the form layout rather than a widget, and it is where AllowAddFeatures decides whether users can create children in place.

Discovering the relations a project already has

Before adding one, it is worth seeing what is there — a project inherited from someone else frequently has relations you did not expect, and duplicated relations with different ids are a common cause of a form showing the same child table twice.

manager = project.relationManager()

for relation_id, existing in manager.relations().items():
    parent_layer = existing.referencedLayer()
    child_layer = existing.referencingLayer()
    pairs = existing.fieldPairs()
    print(f"{relation_id}: {child_layer.name()}{parent_layer.name()} {pairs} "
          f"strength={existing.strength()}")

Breakdown: relations() returns a dictionary keyed by relation id, and fieldPairs() gives the referencing-to-referenced mapping as a dictionary, so this one loop tells you the whole relational structure of a project. Printing the strength alongside is worth it because a Composition relation nobody knew about is a data-loss risk the moment somebody deletes a parent. Removing one is manager.removeRelation(relation_id), and it takes effect immediately in the forms.

For a project that will be handed on, generating this listing into the project's own metadata — as described in reading and writing layer metadata — means the next person does not have to run a script to find out how the data fits together.

QGIS version compatibility

QgsRelation has been present since QGIS 2.x with a stable API. Relation strength arrived in 3.0 as QgsRelation.RelationStrength and moved to Qgis.RelationshipStrength in 3.28, with the old name retained. Many-to-many relations through a linking table are configured as two one-to-many relations, which has been the approach throughout. In 3.28 a broader relationship API for provider-declared relationships was added; project relations as described here are unchanged.

Troubleshooting

  • isValid() is False. A layer id is wrong, or a field name does not exist on the layer you named.
  • The relation is valid but finds no children. The field pair is reversed.
  • Children appear under the wrong parent. The parent key is not unique.
  • The relation disappears after reopening the project. It was never added to the relation manager, only constructed.
  • relation_aggregate returns NULL. It takes the relation id, not the name.
  • Deleting a parent leaves orphans. Strength is Association; set Composition if that is the intent.

Conclusion

Declare the relation with the child field first, give it a stable id, choose the strength deliberately, and register it with the relation manager so it lives in the project. Then use the relation object to traverse rather than writing join filters by hand — it knows the mapping, and it keeps working when the mapping changes.

Frequently Asked Questions

Can a relation span two different data sources? Yes. The layers can be a GeoPackage and a PostGIS table, or anything else QGIS can load, because the relation is a project-level concept rather than a database constraint.

How do I express many-to-many? As two one-to-many relations against a linking table. QGIS's form widgets understand that pattern and present it as a single list.

Does a relation enforce referential integrity? No. It describes a relationship for QGIS's benefit; nothing stops a child pointing at a non-existent parent. Database-level constraints are the place for enforcement.

Are relations included when I save a project as a template? Yes, they are part of the project file, which is why registering them matters — see saving and loading a QGIS project.