Short answer

QGIS has two distinct join mechanisms and choosing the wrong one wastes hours. An attribute join links two tables on a shared key field (parcel ID, station code) via Layer Properties > Joins. A spatial join links features by geographic relationship (intersects, within, nearest) via Join Attributes by Location when there is no common key. The two traps that catch everyone: the Layer Properties join is temporary and virtual (it lives in the project, not the data), and joins silently produce NULLs when the key fields do not match exactly in type or content.

Below: when to use each, the key-matching rules, the temporary-join trap and how to make joins permanent, a worked CSV-to-layer and a spatial-join example, and the pitfalls.

Attribute join vs spatial join

Attribute join answers: "I have a spatial layer and a table that share an ID — attach the table's columns." Parcels with a parcel_id, joined to an ownership CSV keyed on the same ID. It is a relational join on a field, no geometry involved in the match.

Spatial join answers: "These two layers have no shared key, but they overlap in space — attach attributes based on location." Sample points joined to the geology polygon they fall within; incidents joined to the nearest road; buildings joined to the census tract they intersect.

Decision rule: if a reliable common key exists, attribute join; if only geometry relates the layers, spatial join. Forcing a spatial join when a clean key exists is slower and can mis-assign features near boundaries; forcing an attribute join when no key exists is impossible.

The attribute join, and why fields go NULL

In Layer Properties > Joins you set: the Join layer, the Join field (key in the other table), and the Target field (key in this layer). QGIS then exposes the join layer's columns as virtual fields, prefixed by default with the join layer name.

Joined fields show NULL when the keys do not match exactly. The usual causes:

  • Type mismatch. The target field is integer 123 and the join key is text "123" (or vice versa). QGIS will not match across types. Cast one side — e.g., create a field with to_int("id") or to_string("id") so both are the same type.
  • Leading zeros stripped. A code 00742 read as an integer becomes 742 and no longer matches the text "00742". Keep zero-padded codes as text on both sides.
  • Whitespace and case. Trailing spaces or "Site A" vs "site a". Normalize with trim() and lower() in a field calculator before joining.
  • Many-to-one direction. A standard join expects one matching row per target feature; if the join table has duplicates per key, QGIS takes one. For one-to-many, use Join attributes by field value in Processing with the one-to-many option.

Diagnose by opening both attribute tables and comparing the raw key values side by side, including data type (shown in the field properties), not just how they print.

The temporary-join trap and making joins permanent

A Layer Properties join is virtual: the joined columns are not written into the layer's data, only recorded in the QGIS project. Remove the join layer, send the GeoPackage to a colleague, or run a Processing tool that reads the source table, and the joined fields vanish or come back empty. This is the single most common "my join disappeared" report.

To make a join permanent, do one of:

  1. Export the joined layer. Right-click the layer (with the join active) > Export > Save Features As… > GeoPackage. The joined virtual fields are materialized into the new file. Optionally use Field selection in the export dialog to drop unwanted columns and shorten names.
  2. Processing: Join attributes by field value (native:joinattributestable). This reads two inputs and writes a new layer with the joined columns baked in — scriptable and reproducible, unlike the Layer Properties join. It also offers join type (one-to-one / one-to-many) and a Discard records which could not be joined option for QA.

For production deliverables, always materialize. Keep the virtual join only for quick exploration.

Worked example 1: CSV to a layer

You have wells.gpkg (point layer, field well_id as text, zero-padded) and chemistry.csv (column well_id, values like 00231).

  1. Add the CSV via Layer > Add Layer > Add Delimited Text Layer, set Geometry to No geometry. Check the imported field type of well_id — delimited text often imports it as text, which is what you want here for zero-padding.
  2. Verify key types match: both well_id as text. If the CSV imported it as integer (dropping leading zeros), force text by adding a .csvt sidecar file specifying "String" for that column, or re-import.
  3. Join: in wells Layer Properties > Joins > +, Join layer = chemistry, Join field = well_id, Target field = well_id. Optionally tick Custom field name prefix to keep column names tidy.
  4. Confirm: open the wells attribute table; chemistry columns are populated, NULL only where a well genuinely has no chemistry row.
  5. Make permanent: Export > Save Features As… to a new GeoPackage.

Worked example 2: spatial join

You have samples (points, no geology key) and geology (polygons with unit_name). Attach the geological unit to each sample.

  1. Open Vector > Data Management Tools > Join Attributes by Location (native:joinattributesbylocation).
  2. Base layer = samples; Join layer = geology; Geometric predicate = within (a point inside its polygon).
  3. Fields to add = unit_name (and any others needed).
  4. Join type = Take attributes of the first matching feature only — appropriate when polygons do not overlap. If they can overlap, choose accordingly and expect multiple matches.
  5. Run. The output is a new permanent layer (spatial joins in Processing write real output, unlike the virtual attribute join).

Boundary caution: a point exactly on a shared polygon edge can match ambiguously; ensure topology is clean and consider intersects vs within deliberately. For "nearest feature," use Join Attributes by Nearest (native:joinbynearest), which also adds a distance field — useful and worth keeping for QA.

Common pitfalls and why they happen

  • Joined fields all NULL. Key type/content mismatch (int vs text, leading zeros, whitespace, case). Normalize keys before joining.
  • Join vanished after sharing the project. Used a virtual Layer Properties join and never materialized it. Export or use Processing join.
  • Duplicate / inflated row counts. A one-to-many relationship through a tool that fans out matches. Pick the correct join cardinality intentionally.
  • Wrong polygon assigned in a spatial join. Edge cases with overlapping polygons or points on boundaries, or wrong predicate (intersects vs within). Choose the predicate to fit the data and clean topology.
  • CRS mismatch in spatial joins. Layers in different CRS can still "work" via on-the-fly display but the predicate runs on actual coordinates; reproject both to a common CRS first to avoid missed matches.

QA and validation

  • Count populated rows after an attribute join; NULLs should correspond to genuine missing keys, not mismatched types.
  • Spot-check both directions: pick a known key and confirm the joined values are the expected ones.
  • Row-count integrity: after a one-to-one join the feature count is unchanged; an unexpected increase signals duplicate keys.
  • Spatial-join sanity: overlay results and confirm a handful of points carry the unit of the polygon they visibly sit in, including a boundary case.
  • Materialize and reopen: save to GeoPackage, reload in a fresh project, and confirm the joined fields persist.

Bathyl perspective

A join is where tabular knowledge meets geometry, and most join failures are quiet — NULLs and vanished fields rather than errors. Bathyl normalizes key fields before joining, picks attribute vs spatial joins by whether a real key exists, and always materializes production joins into a GeoPackage so the deliverable carries its data rather than a fragile project-only link.

Related reading

Sources