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.
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.
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.
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 version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Identical API. native:joinattributestable parameters unchanged. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Adds 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!rsample of both key fields — the usual culprits are integer versus text, leading zeros, and trailing whitespace. - The output has more features than the input.
METHODis0, creating one feature per match. Use1if you want one row per input feature. - Features vanished from the output.
DISCARD_NONMATCHINGisTrue. Set it toFalseand audit theNULLs instead. - Joined column names are unwieldy. Set
PREFIX, orsetPrefix()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
.csvtfile next to it declaring the types, or cast the key withto_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.