Join Attributes by Field Value in PyQGIS

Attaching a spreadsheet of measurements to a set of polygons is one of the most common GIS tasks there is, and it fails the same way for nearly everyone the first time: the join runs, every joined column is NULL, and nothing in the output says why. The cause is almost never the join — it is that one side stores the key as an integer and the other loaded it as text, so no two values are ever equal.

This page is a focused recipe within Attribute Tables and Field Management in PyQGIS. It covers the two kinds of join QGIS offers, diagnosing key mismatches before running anything, handling one-to-many relationships, and choosing which form belongs in a pipeline.

Why an all-NULL join is almost always a key-type problemThe parcels layer stores parcel identifiers as integers. The CSV loaded them as text, and one value additionally carries a leading zero. Printed side by side the values look almost the same, but the equality test between an integer and a string is false for every row, so every joined column comes back empty.They print the same. They do not compare equal.parcels · parcel_idInteger1041104210431044valuations.csv · parcel_idString"1041""1042""01043""1044 "✗ type✗ type✗ padding✗ whitespacefour rows, four different reasons, one identical symptom

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A target vector layer and a source table — another vector layer, a CSV, or a database table.
  • A key field present on both. It does not have to have the same name, but the values must genuinely match.

Diagnose the key first

Ten seconds of checking saves an hour of confusion. Compare the types and a sample of values before running any join.

from qgis.core import QgsProject

parcels = QgsProject.instance().mapLayersByName("parcels")[0]
values = QgsProject.instance().mapLayersByName("valuations")[0]


def describe(layer, field):
    index = layer.fields().indexOf(field)
    if index == -1:
        return f"{layer.name()}: no field '{field}'"
    qfield = layer.fields()[index]
    sample = [f[field] for f in layer.getFeatures()][:4]
    return f"{layer.name()}.{field}: {qfield.typeName()} -> {sample!r}"


print(describe(parcels, "parcel_id"))
print(describe(values, "parcel_id"))

Breakdown: typeName() reports the provider's own type name — Integer64, String, Real — which is the single most informative thing you can print here. The !r in the f-string is deliberate: it shows quotes around strings and reveals trailing whitespace, both invisible with plain str(). A sample of four rows is enough to spot padding, case differences and stray spaces.

If the types differ, cast one side before joining rather than hoping the join coerces:

import processing

values_cast = processing.run("native:fieldcalculator", {
    "INPUT": values,
    "FIELD_NAME": "parcel_key",
    "FIELD_TYPE": 1,            # 1 = integer
    "FORMULA": 'to_int(trim("parcel_id"))',
    "OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]

Breakdown: to_int() converts the text to a number, which also normalises leading zeros — "01043" and 1043 become the same value. trim() strips the whitespace that spreadsheet exports leave behind. Creating a new key field rather than overwriting the original keeps the source auditable, and gives you something to point at if the join still comes up empty.

The materialised join

native:joinattributestable copies the matched values into a new layer. The columns are real, writable and permanent, which is what you want for anything you will hand on.

import processing

joined = processing.run("native:joinattributestable", {
    "INPUT": "/data/parcels.gpkg|layername=parcels",
    "FIELD": "parcel_id",
    "INPUT_2": "/data/valuations.csv",
    "FIELD_2": "parcel_id",
    "FIELDS_TO_COPY": ["assessed", "survey_year"],
    "METHOD": 1,                 # 1 = first matching feature only
    "DISCARD_NONMATCHING": False,
    "PREFIX": "val_",
    "OUTPUT": "/data/output/parcels_valued.gpkg",
})["OUTPUT"]

print(joined.featureCount(), "feature(s) in the output")

Breakdown: FIELDS_TO_COPY restricts what comes across; leaving it empty copies everything, which usually drags in duplicate key columns. METHOD decides one-to-many behaviour — 1 keeps only the first match, 0 creates one output feature per match. DISCARD_NONMATCHING: False keeps unmatched parcels with NULL in the joined columns, which is what makes the result auditable: comparing the output count with the input count tells you whether anything was dropped. PREFIX avoids column-name collisions.

Auditing the match rate immediately is worth the three lines:

matched = sum(1 for f in joined.getFeatures() if f["val_assessed"] is not None)
print(f"{matched}/{joined.featureCount()} matched")

Breakdown: A match rate of zero means a key problem. A rate of exactly the source table's row count means the join worked but the source is incomplete. Anything in between is normal and worth recording. This check belongs in every scripted join.

The virtual join

addJoin() attaches the table as a live view. Nothing is copied; QGIS re-reads the source whenever it needs the values.

What happens when the source file movesOn the left a project holds a virtual join to a CSV. When the CSV is moved or deleted the joined columns become empty and the project shows missing values. On the right a materialised join has already copied the values into the output layer, so moving or deleting the CSV has no effect at all.A virtual join is a dependency; a materialised join is a snapshotlayer.addJoin() — liveprojectparcels + joinvaluations.csvmoved awayjoined columns go emptyread-only, prefixed, re-read on every drawjoinattributestable — copiedparcels_valuedvalues insidevaluations.csvmoved awayoutput unaffectedwritable, self-contained, safe to send

from qgis.core import QgsProject, QgsVectorLayerJoinInfo

join = QgsVectorLayerJoinInfo()
join.setJoinLayerId(values.id())
join.setJoinLayer(values)
join.setJoinFieldName("parcel_id")
join.setTargetFieldName("parcel_id")
join.setJoinFieldNamesSubset(["assessed", "survey_year"])
join.setPrefix("val_")
join.setUsingMemoryCache(True)

parcels.addJoin(join)
print([f.name() for f in parcels.fields()])

Breakdown: setUsingMemoryCache(True) loads the join table into memory once, which makes rendering and iteration far faster at the cost of not seeing later edits to the source. setJoinFieldNamesSubset() limits which columns appear, avoiding a duplicated key column. setPrefix() controls the naming — without it QGIS uses the join layer's name, which is rarely what you want in an expression. The joined columns are read-only and disappear the moment the join is removed or the source file cannot be found, which is why a virtual join belongs in an interactive project rather than a pipeline.

Handle one-to-many properly

When one parcel matches several rows in the source table, there is no single right answer — there are three, and the algorithm's METHOD parameter only offers two of them.

Three answers to a one-to-many joinA single parcel matches three valuation rows from different years. The first-match option keeps one output row and silently discards two. The one-row-per-match option produces three output features sharing the same geometry. The aggregate-first option summarises the three rows into one before joining, producing a single output row carrying a total.One parcel, three valuation rows — decide before you joinparcel 10412024 420 0002025 440 0002026 455 000METHOD 1 — first match1 row · two values silently lostMETHOD 0 — one per match3 rows · geometry duplicatedaggregate, then join1 row · nothing lostuse when the source is genuinelyone-to-one and you are asserting ituse for a per-event table whereeach row is its own recorduse when you want a total, alatest value or a mean per feature

The third option — summarise first — is usually what a report actually wants, and it keeps the output one row per feature without discarding anything:

import processing

summary = processing.run("qgis:statisticsbycategories", {
    "INPUT": "/data/valuations.csv",
    "VALUES_FIELD_NAME": "assessed",
    "CATEGORIES_FIELD_NAME": ["parcel_id"],
    "OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]

joined = processing.run("native:joinattributestable", {
    "INPUT": "/data/parcels.gpkg|layername=parcels",
    "FIELD": "parcel_id",
    "INPUT_2": summary,
    "FIELD_2": "parcel_id",
    "FIELDS_TO_COPY": ["count", "sum", "mean", "max"],
    "METHOD": 1,
    "DISCARD_NONMATCHING": False,
    "PREFIX": "val_",
    "OUTPUT": "/data/output/parcels_valued.gpkg",
})["OUTPUT"]

Breakdown: Aggregating first collapses the many side to exactly one row per key, so the subsequent join is genuinely one-to-one and METHOD: 1 discards nothing. Copying count alongside the statistics is what makes the result auditable — a parcel with val_count of 3 tells the reader the figure summarises three valuations, which a bare total does not. The aggregation approach is covered in Count and Summarise Features by Attribute in PyQGIS.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.28 LTR3.9Identical API. native:joinattributestable parameters unchanged.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Adds NON_MATCHING as a second output, making unmatched rows directly inspectable.

QgsVectorLayerJoinInfo and the join algorithm have been stable across 3.x.

Troubleshooting

  • Every joined value is NULL. A key mismatch. Print typeName() and a !r sample of both key fields — the usual culprits are integer versus text, leading zeros, and trailing whitespace.
  • The output has more features than the input. METHOD is 0, creating one feature per match. Use 1 if you want one row per input feature.
  • Features vanished from the output. DISCARD_NONMATCHING is True. Set it to False and audit the NULLs instead.
  • Joined column names are unwieldy. Set PREFIX, or setPrefix() on a virtual join; the default is derived from the source layer's name.
  • The join disappears when the project is reopened elsewhere. It is a virtual join with a relative path that no longer resolves. Materialise it before sharing.
  • A CSV loads every column as text. Add a .csvt file next to it declaring the types, or cast the key with to_int() before joining.

Conclusion

Check the key types before you join and the whole class of all-NULL results disappears. Use native:joinattributestable for anything that leaves your machine, because the result is self-contained; reserve addJoin() for interactive projects where the live link is the point. Either way, audit the match rate immediately — a join that silently matched nothing looks exactly like one that worked.

Frequently Asked Questions

Why are all my joined values NULL? The keys do not compare equal, most often because one side is an integer and the other is text. Leading zeros and trailing whitespace from spreadsheet exports cause the same symptom. Print both fields' typeName() and a repr of a few values.

What is the difference between a virtual join and native:joinattributestable? A virtual join is a live, read-only view that depends on the source file remaining in place. The algorithm copies the values into a new layer, producing real, writable columns that survive the source being moved or deleted.

How do I handle a one-to-many relationship? Set METHOD to 0 to produce one output feature per match, which duplicates the geometry. METHOD 1 keeps only the first match. If you need an aggregate instead, summarise the source table before joining.

Can I join on more than one field? Not directly — both the algorithm and the virtual join take a single key on each side. Build a composite key first with native:fieldcalculator, concatenating the parts with a separator that cannot occur in the values, then join on that.

Can I join a CSV without importing it first? Yes — pass the CSV path directly as INPUT_2. Be aware that a CSV without a companion .csvt file loads every column as text, which is the most common reason the keys then fail to match.