Short answer

A spatial join in QGIS attaches attributes from one layer to another based on where the geometries sit relative to each other, not on a shared key field. Use Processing > Vector general > Join attributes by location for a row-level join, or Join attributes by location (summary) when you want one aggregated row per base feature. The two decisions that determine whether the result is correct are the geometric predicate (intersects, within, contains, touches, overlaps) and the join type (one-to-one vs one-to-many).

Both layers must be in the same CRS. QGIS does not silently reproject inside the algorithm, so a join between an EPSG:4326 layer and an EPSG:3857 layer will return either zero matches or wrong ones.

What a spatial join is, precisely

A relational (attribute) join links rows by a common value, for example a region_id. A spatial join links rows by a geometric relationship evaluated with the DE-9IM model that underlies OGC Simple Features. QGIS exposes these relationships as plain-language predicates:

  • intersects - geometries share at least one point, including boundaries. The broadest and most-used predicate.
  • contains - the base feature wholly encloses the join feature.
  • within - the base feature lies wholly inside the join feature (the inverse of contains).
  • equals - geometries are identical.
  • touches - they share a boundary but no interior.
  • overlaps - interiors intersect but neither contains the other, and they are the same dimension.
  • crosses - interiors intersect at a lower dimension (a line crossing a polygon).

Picking the predicate is the heart of the task. "Which monitoring wells fall inside each licence block?" is a within (points within polygons) or, from the polygon's side, a contains. "Which fault traces pass through this map sheet?" is intersects or crosses.

One-to-one vs one-to-many

In the Join attributes by location dialog, the Join type dropdown controls multiplicity:

  • Create separate feature for each matching feature - one-to-many. If a base polygon contains 14 points, the base feature is written 14 times, once per match. This is the right choice when you genuinely need every pair, but it is the usual reason people see "my polygon count exploded."
  • Take attributes of the first matching feature only - one-to-one. The base feature appears once with the first match's attributes. Note that "first" is the first by feature order, which is rarely meaningful, so use this only when you expect at most one match.

When you want a single row per base feature but need to combine many join features, do not use the row-level tool. Use Join attributes by location (summary) and choose statistics per field.

Worked example: points per polygon, two ways

Suppose boreholes (points, EPSG:32630) and concessions (polygons, EPSG:32630). Both are already in the same UTM zone, which you must confirm.

Goal A - count and average depth per concession. Run Join attributes by location (summary):

  • Base layer: concessions
  • Join layer: boreholes
  • Predicate: contains (or run it the other way with within)
  • Summaries: count on any field, mean and max on depth_m

The output has one row per concession with boreholes_count, depth_m_mean, depth_m_max. In the PyQGIS or Processing equivalent the algorithm id is native:joinbylocationsummary.

Goal B - tag each borehole with its concession name. Run the standard join the other direction:

  • Base layer: boreholes
  • Join layer: concessions
  • Predicate: within
  • Join type: take first matching feature only
  • Fields to copy: concession_name, licence_id

Algorithm id native:joinattributesbylocation. Each borehole now carries its concession attributes.

For database-scale work the same logic in PostGIS is:

SELECT c.id, count(b.*) AS n, avg(b.depth_m) AS mean_depth
FROM concessions c
LEFT JOIN boreholes b
  ON ST_Contains(c.geom, b.geom)
GROUP BY c.id;

A GiST spatial index (CREATE INDEX ON boreholes USING gist (geom);) makes this fast, which matters once layers exceed a few hundred thousand features and the QGIS in-memory join slows down.

Common pitfalls and why they happen

  • Zero matches between two layers that clearly overlap on the map. Different CRS. The on-screen overlay is reprojected for display, but the algorithm compares raw coordinates. Reproject one layer first with Reproject layer (native:reprojectlayer) so both share a CRS.
  • Points on a polygon boundary matched twice. With intersects, a point sitting exactly on a shared edge between two polygons matches both. Use within for interior-only logic, or snap and clean the boundaries first.
  • Duplicated base features you did not expect. One-to-many join type. Switch to summary or first-match.
  • Slivers causing spurious overlaps. Digitising gaps and overshoots create tiny overlaps relationships. Run topology cleaning before the join.
  • NULL attributes after the join. A left join keeps unmatched base features with NULLs; that is correct behaviour, not a bug. Check whether you actually wanted only matched features (which the standard tool also offers via a "discard records which could not be joined" option).

Validation

  • Compare the joined feature count against the base count. For a one-to-one join they must be equal; for one-to-many the output count equals the number of matching pairs.
  • Spot-check a handful of features against the canvas with the Identify tool: does the copied attribute match the polygon the feature actually sits in?
  • For summary joins, sum the per-polygon counts and confirm the total equals (or is less than, for unmatched points) the original point count.
  • Watch for features that fall outside all polygons; a count of zero or a NULL is the honest signal that they are unmatched.

Bathyl perspective

We choose the predicate from the geological question, not from habit. "Inside" and "touching" are different claims about the ground, and a join that uses intersects where it should use within quietly inflates counts and misallocates samples. We record the predicate, the join type, and the CRS alongside every joined layer so the result can be reproduced and audited.

Related reading

Sources