Spatial Joins & Predicate Tuning

A spatial join is where most dbt geospatial projects first meet real pain. The model compiles, the SQL is legible, the results look plausible in a preview — and then the job that took eleven seconds against a sample takes forty minutes against production, or worse, it returns 4.1 million rows where the mart is supposed to hold 3.8 million. Both symptoms come from the same place: a join predicate the planner cannot serve from an index, joined against geometry whose bounding boxes overlap far more than the geometries themselves do. This topic covers how to write spatial joins that stay index-eligible, how to reshape the geometry so the index actually helps, and how to prove the join did not quietly change the grain of your model.

Spatial joins sit in the intermediate layer of the architecture described in core fundamentals and architecture for dbt geospatial — after staging has validated and normalized geometry, before marts serve it. That position matters for tuning: by the time a join runs, every input should already carry one canonical SRID and a valid geometry, so the join can be optimized purely as a query problem rather than as a data-cleanliness problem. If your join is slow and your inputs are dirty, fix the inputs first in geometry validation and data quality; a ST_MakeValid call inside a join condition defeats every index on the table.

Prerequisites checklist

  • PostGIS 3.1+ or DuckDB spatial 0.10+. Both ship the bounding-box operators this topic depends on; PostGIS additionally exposes ST_Subdivide, which does the heavy lifting for oversized polygons.
  • A GiST index on every geometry column that appears on the right-hand side of a join, created in a dbt post-hook. Index strategy is covered in index hints for spatial queries.
  • A single canonical SRID across both join inputs, enforced per enforcing a canonical SRID across dbt models. A mixed-SRID join either errors or silently disables the index.
  • ANALYZE after every full refresh. The planner’s choice between a nested loop with an index scan and a hash join comes from row-count and selectivity statistics that do not exist until the table is analyzed.
  • Permission to create indexes in the target schema, since the fastest join is usually the one preceded by an index that dbt built as part of the model.

Architecture context: where the join belongs in the DAG

A spatial join should be a named intermediate model, not an inline subquery inside a mart. Materializing it gives you somewhere to hang an index, a row-count test, and an incremental strategy — and it means the expensive work runs once per build rather than once per downstream consumer.

Where a spatial join sits in a dbt DAG, and what each stage contributes to its speed Two staging models — points and polygons — each carry a canonical SRID and a GiST index. They feed a materialized intermediate join model that applies an index-eligible predicate and controls fanout, which in turn feeds a mart. Below the flow, three annotations attribute the join's performance to the staging indexes, the predicate form, and the post-hook analyze step. stg_trip_pings POINT · SRID 4326 GiST indexed stg_service_zones POLYGON · SRID 4326 GiST indexed · subdivided int_pings_zoned ST_Intersects(p.geom, z.geom) materialized: table fanout controlled · row test mart_zone_activity aggregates only no geometry work indexes are built here… …the predicate decides if they are used… …and the mart inherits the result

The arrows are ref() edges, so this is also the lineage graph — the same structure discussed in spatial model dependency graphs. The important property is that geometry work stops at the intermediate layer. A mart that joins geometry again, to add one attribute, pays the whole cost a second time.

Which predicates the index can serve

PostGIS spatial indexes store bounding boxes, not geometries. Every index-accelerated predicate therefore works in two stages: an index scan that finds candidate rows whose boxes overlap, then an exact recheck that runs the real predicate on those candidates. A predicate is index-eligible when PostGIS can derive a box search from it. This is the single most useful table to internalize.

Predicate Index-eligible Notes
ST_Intersects(a, b) Yes Expands internally to a && b AND _ST_Intersects(a, b)
ST_DWithin(a, b, d) Yes Expands to a box search widened by d; the correct way to write proximity
ST_Contains / ST_Within / ST_Covers Yes Same two-stage form as ST_Intersects
ST_Distance(a, b) < d No Computed per candidate pair; forces a nested loop over the full table
ST_Intersects(ST_Buffer(a, d), b) No The buffer is computed per row, so no stored box matches it
ST_Intersects(ST_Transform(a, 3857), b) No Transform per row defeats the index on a
a && b alone Yes Box overlap only — fast but approximate, never the final answer

Two of these deserve emphasis because they are the mistakes that appear most often in review. ST_Distance(a, b) < 500 and ST_DWithin(a, b, 500) return identical rows and have wildly different plans: the first computes an exact distance for every pair in the cross product, the second asks the index for the boxes within 500 units and computes exact distances only for those. The second is the one to write, every time. Likewise, buffering inside a join condition looks harmless but converts an index scan into a sequential scan plus a per-row buffer construction — if you need a buffered geometry repeatedly, materialize it as a column in staging and index that column instead.

The two-stage spatial join: index box filter, then exact geometry recheck On the left, a set of candidate polygons and one query point. The bounding-box stage returns four candidates whose rectangles contain the point. The exact recheck stage then tests true geometry and keeps only one of the four. Numbers underneath show the row counts falling from ten million pairs to four candidates to one match. 1 · index scan on boxes dashed boxes are what the index stores 2 · exact recheck on geometry only the true containing polygon survives what each stage costs pairs considered 10,000,000 box candidates 4 exact matches 1 a non-indexable predicate pays row one The recheck is cheap only because the box stage handed it four rows instead of ten million.

Configuration walkthrough

Give the join model its own configuration block. Two settings do most of the work: a materialization that persists the result, and a post-hook that indexes and analyzes it.

sql
-- models/intermediate/int_pings_zoned.sql
{{ config(
    materialized = 'table',
    post_hook = [
      "CREATE INDEX IF NOT EXISTS {{ this.name }}_geom_idx ON {{ this }} USING GIST (ping_geom)",
      "ANALYZE {{ this }}"
    ]
) }}

select
    p.ping_id,
    p.trip_id,
    p.observed_at,
    p.geom as ping_geom,
    z.zone_id,
    z.zone_name
from {{ ref('stg_trip_pings') }} as p
join {{ ref('stg_service_zones') }} as z
  on st_intersects(p.geom, z.geom)

The ANALYZE in the post-hook is not decoration. Without fresh statistics the planner assumes a default selectivity for the spatial predicate and will frequently choose a hash join over the index-driven nested loop, which is exactly the wrong plan when one side is small and selective.

For the polygon side, the single highest-leverage preparation step is subdivision. A country boundary or a utility service area can be a polygon with tens of thousands of vertices whose bounding box covers half the map; every point in that box becomes a candidate, and every candidate pays an expensive exact test against a huge ring. ST_Subdivide cuts it into many small pieces whose boxes are tight.

sql
-- models/staging/stg_service_zones.sql
{{ config(
    materialized = 'table',
    post_hook = "CREATE INDEX IF NOT EXISTS {{ this.name }}_geom_idx ON {{ this }} USING GIST (geom)"
) }}

select
    zone_id,
    zone_name,
    st_subdivide(geom, 256) as geom   -- max 256 vertices per piece
from {{ source('ops', 'service_zones') }}
where st_isvalid(geom)

Subdivision multiplies the row count of the polygon table, so the join now returns one row per point per polygon piece. Deduplicate downstream with distinct on the natural key, or aggregate with group by. That trade — more rows in staging, a distinct in the join model, and an order-of-magnitude faster exact test — is almost always worth taking for polygons above a few thousand vertices.

Core implementation: controlling fanout

A spatial join is a many-to-many relation. Where zones overlap, or where a point falls on a shared boundary, one ping matches several zones and the model’s grain silently changes from “one row per ping” to “one row per ping per zone”. Downstream aggregates then double-count without any error being raised.

Decide the grain explicitly. The three usual policies:

sql
-- Policy A: keep every match, and say so in the model name and its unique key.
select p.ping_id, z.zone_id, ...
from pings p join zones z on st_intersects(p.geom, z.geom)

-- Policy B: one row per ping, choose the zone with the largest overlap.
select distinct on (p.ping_id)
       p.ping_id, z.zone_id,
       st_area(st_intersection(p.geom, z.geom)) as overlap_area
from pings p join zones z on st_intersects(p.geom, z.geom)
order by p.ping_id, overlap_area desc, z.zone_id

-- Policy C: one row per ping, deterministic tie-break by priority then id.
select distinct on (p.ping_id)
       p.ping_id, z.zone_id
from pings p join zones z on st_intersects(p.geom, z.geom)
order by p.ping_id, z.zone_priority asc, z.zone_id asc
How overlapping zones change a model's grain, and what each grain policy returns One ping falls inside three overlapping service zones. Policy A keeps all three matched rows, so the model grain becomes one row per ping per zone. Policy B keeps a single row, the zone with the largest overlap area. Policy C keeps a single row chosen by a priority column with an identifier tie-break. A caption notes that only policies B and C preserve one row per ping. one ping, three zones zones overlap; the ping is inside all three Policy A · keep all 3 rows grain becomes ping × zone aggregates double-count Policy B · largest overlap 1 row ranked by ST_Area(intersection) areal inputs only Policy C · priority 1 row ranked by zone_priority, zone_id works for points Only B and C preserve one row per ping — and only with a deterministic tie-break.

Policy B is the honest choice for overlapping administrative areas but only makes sense for areal geometry — ST_Area of a point intersection is always zero, so for point inputs use Policy C. Whichever you pick, the tie-break must be deterministic: distinct on without a total ordering produces a different answer on every run, which turns into an unexplainable diff in the mart and a failing snapshot test.

For point-in-polygon specifically, prefer ST_Intersects over ST_Within even when containment is what you mean. They use the index identically, but boundary semantics differ: a point exactly on a shared edge intersects both neighbouring polygons and is within neither in some implementations. Pair ST_Intersects with an explicit tie-break and you get a defined answer instead of a silent drop.

Validation and testing

Two dbt tests catch the overwhelming majority of spatial-join regressions, and neither requires a spatial extension to run.

yaml
# models/intermediate/schema.yml
models:
  - name: int_pings_zoned
    description: One row per ping per matched service zone.
    tests:
      - dbt_utils.equal_rowcount:
          compare_model: ref('stg_trip_pings')
          config:
            severity: warn   # only equal when the join is guaranteed 1:1
    columns:
      - name: ping_id
        tests:
          - not_null
      - name: zone_id
        tests:
          - not_null
          - relationships:
              to: ref('stg_service_zones')
              field: zone_id

For a join you intend to be one-to-one, assert the grain directly with a uniqueness test on the key; for a join you intend to fan out, assert an upper bound instead, so an accidental cross join fails the build:

sql
-- tests/assert_zone_fanout_bounded.sql
select ping_id, count(*) as zone_matches
from {{ ref('int_pings_zoned') }}
group by ping_id
having count(*) > 8

A third check worth adding is a coverage assertion — the count of input rows that matched nothing. Silent non-matching is how a CRS mistake hides: reproject one side by a few metres and the join still runs, returns fewer rows, and nobody notices until a dashboard drops.

sql
-- tests/assert_ping_zone_coverage.sql
select count(*) as unmatched
from {{ ref('stg_trip_pings') }} p
left join {{ ref('int_pings_zoned') }} z using (ping_id)
where z.ping_id is null
having count(*) > 1000

Advanced patterns

Prefilter with the box operator when you must use a non-indexable predicate. If a business rule genuinely needs ST_Distance, keep the index in play by adding an ST_DWithin guard first; the planner uses the index for the guard and evaluates the exact distance only on survivors.

sql
where st_dwithin(a.geom, b.geom, 1000)      -- index-eligible, cuts the candidate set
  and st_distance(a.geom, b.geom) < 1000    -- exact, runs on candidates only

Join on a grid key before joining on geometry. Attaching an H3 or geohash cell to both sides turns the first pass into an equality join the planner can hash, with geometry used only to resolve cells that straddle a boundary. That pattern is developed in discrete global grid macros and pairs well with the partitioning approach in partitioning geospatial tables with H3 and dbt.

Make the join incremental on the point side. Points accumulate; zones change rarely. An incremental model that processes only new pings against the full zone table turns a nightly full join into a minutes-long append, following the pattern in incremental spatial materializations. Remember to trigger a full refresh when zone boundaries change, or historical rows keep their old assignment.

Watch the parallel plan. PostGIS marks most spatial functions as parallel-safe, but a nested loop over a GiST index will not be parallelized unless max_parallel_workers_per_gather is above zero and the outer relation is large enough to justify it. When a join is CPU-bound on the exact recheck rather than I/O-bound, raising the worker count is often a bigger win than any rewrite.

Troubleshooting

Symptom Root cause Fix
Join runs for hours, EXPLAIN shows Seq Scan on both sides Predicate is not index-eligible (ST_Distance, buffered or transformed geometry in the condition) Rewrite to ST_DWithin / ST_Intersects; move ST_Transform and ST_Buffer into staging columns
Plan uses the index, but each candidate is slow One side holds huge multi-ring polygons ST_Subdivide the polygon side in staging, then distinct downstream
Row count grows on every build with unchanged inputs Fanout from overlapping zones plus an append-only incremental strategy Set a unique_key, or apply a distinct on grain policy
Correct rows locally, far fewer in production Mixed SRIDs between environments Assert SRID in staging tests; see detecting SRID mismatches with dbt tests
Plan flips between builds with no code change Stale statistics after a full refresh Add ANALYZE {{ this }} to the model’s post-hook
ST_Intersects returns rows that visually do not touch Geometry is invalid, self-intersecting, or in degrees while the tolerance is in metres Validate in staging; use a projected CRS for metric predicates

FAQ

Is ST_DWithin really faster than ST_Distance, or is that folklore?

It is structural, not folklore. ST_DWithin is defined so that PostGIS can rewrite it into a bounding-box search expanded by the distance, which a GiST index answers directly; ST_Distance produces a number that must be computed for a pair before it can be compared, so every pair must be formed first. On a table of any size the difference is between an index scan over a few candidates and a nested loop over the cross product.

Should I subdivide polygons even when the join is already fast?

No. ST_Subdivide multiplies rows and forces deduplication downstream, so it pays for itself only when the exact recheck is the bottleneck — typically polygons above a few thousand vertices, or any geometry whose bounding box covers a large share of the data extent. Measure first with EXPLAIN (ANALYZE, BUFFERS); if most of the time is in the index scan rather than the filter, subdivision will not help.

How do I stop a spatial join from changing my model's grain?

Declare the grain in the model’s schema YAML and test it. If the model is meant to be one row per input record, add a unique test on that key so any fanout fails the build immediately. If fanout is expected, add a bounded-fanout test with a realistic ceiling. The failure mode this prevents — a mart that quietly double-counts after a boundary file is updated — is invisible without a test.

Does any of this apply to DuckDB spatial, or is it PostGIS-only?

The predicate rules apply to both: DuckDB’s spatial extension also implements ST_Intersects and ST_DWithin with a bounding-box prefilter and will use its spatial join operator when the predicate is expressed that way. What differs is index management — DuckDB has no persistent GiST index to create in a post-hook, so the equivalent tuning lever is ordering the data and letting the optimizer build its structures per query. See PostGIS vs DuckDB spatial for CI pipelines for where the two engines diverge.

Where should the join live if several marts need it?

In one intermediate model that all of them ref(). Repeating the join in each mart pays the cost once per mart and gives you several places for the grain to drift apart. A single materialized intermediate model is also the only place you can usefully attach an index, a row-count test and an incremental strategy.

Up: Part of Core Fundamentals & Architecture for dbt Geospatial.

Explore this section