Point-in-polygon joins at scale in dbt

This page builds a point-in-polygon assignment model that stays predictable as the point table grows into the hundreds of millions: one row per point, one zone per point, an incremental strategy that only touches new points, and tests that fail the build if either invariant breaks.

When to use this approach

  • Your point table grows continuously and your polygon table barely changes. That asymmetry is what makes incremental assignment worth the extra configuration; if both sides churn, a full table build is simpler and often faster.
  • Downstream consumers assume one zone per point. Dashboards that count trips per zone break silently under fanout, so the grain policy below matters more than the raw speed.
  • A full rebuild no longer fits the build window. Before reaching for incremental complexity, confirm the join itself is tuned — an untuned join is slow at every scale, and the fix is in tuning ST_Intersects joins with bounding-box prefilters.

The decision is really about which curve your build sits on. A full rebuild grows with the whole history; an incremental assignment grows with the daily slice, and pays a fixed overhead for the merge.

Build time against accumulated points for a full rebuild versus incremental assignment Two lines plotted against accumulated point volume. The full rebuild line rises steadily and steeply with history. The incremental line starts higher because of merge overhead but stays almost flat, since it processes only the new slice. The two cross at roughly forty million accumulated points, marked as the point where incremental starts paying off. build minutes accumulated points → crossover full rebuild incremental merge overhead Below the crossover a full rebuild is simpler and no slower — do not pay for incremental early.

Prerequisites

  • Points and polygons in one SRID, both validated in staging.
  • A GiST index on the polygon geometry, created in a post-hook.
  • A monotonic column on the point table — an ingest timestamp or an append-only id — for the incremental filter.
  • dbt-utils installed, for the row-count and expression tests used at the end.
  • A stable natural key on each polygon; the tie-break depends on it, and a key that changes between builds will reshuffle historical assignments.

Step-by-step instructions

1. Prepare the polygon side once

Assignment quality is decided here. Validate, transform to the canonical SRID, subdivide, and index — in that order.

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

with valid as (
    select
        zone_id,
        zone_name,
        zone_priority,
        st_transform(geom, {{ var('canonical_srid') }}) as geom
    from {{ source('ops', 'zones') }}
    where st_isvalid(geom)
)

select
    zone_id,
    zone_name,
    zone_priority,
    st_subdivide(geom, 256) as geom
from valid

Verify the pieces cover the same area as the originals — subdivision must not drop slivers:

sql
select
    abs(sum(st_area(geom)) - (select sum(st_area(geom)) from {{ source('ops', 'zones') }}))
      / nullif((select sum(st_area(geom)) from {{ source('ops', 'zones') }}), 0) as area_drift
from {{ ref('stg_zones') }};
-- Expect a value near zero (floating point noise only)

2. Write the assignment model with an explicit grain

The join emits one row per point per matching piece. Collapse it to one row per point with a total ordering, so the answer is identical on every run.

sql
-- models/intermediate/int_point_zone_assignment.sql
{{ config(
    materialized = 'incremental',
    unique_key = 'ping_id',
    incremental_strategy = 'delete+insert',
    post_hook = "ANALYZE {{ this }}"
) }}

with new_points as (
    select ping_id, trip_id, observed_at, geom
    from {{ ref('stg_trip_pings') }}
    {% if is_incremental() %}
    where observed_at > (select coalesce(max(observed_at), '1900-01-01') from {{ this }})
    {% endif %}
),

matched as (
    select distinct on (p.ping_id)
        p.ping_id,
        p.trip_id,
        p.observed_at,
        z.zone_id,
        z.zone_name
    from new_points as p
    join {{ ref('stg_zones') }} as z
      on st_intersects(p.geom, z.geom)
    order by p.ping_id, z.zone_priority asc, z.zone_id asc
)

select * from matched

distinct on with order by ping_id, zone_priority, zone_id is the whole grain policy: first row per point wins, ties broken by priority then by identifier, both deterministic. Dropping either tie-break column reintroduces run-to-run drift.

Verify the grain immediately after the first build:

sql
select count(*) as duplicate_pings
from (select ping_id from {{ ref('int_point_zone_assignment') }} group by ping_id having count(*) > 1) d;
-- Expect 0
Incremental point-in-polygon assignment: only new points meet the full zone table A wide bar represents the full point history, of which only a narrow slice at the right is new since the last run. That slice joins against the complete subdivided zone table, passes through a deterministic first-match rule, and is appended to the assignment table. A note records that a zone boundary change requires a full refresh because historical rows keep their previous assignment. already assigned · 412M points new 3.1M stg_trip_pings, ordered by observed_at stg_zones (all pieces) subdivided · GiST indexed first match wins DISTINCT ON (ping_id) ORDER BY priority, zone_id int_point_zone_assignment one row per ping · appended boundary change ⇒ full refresh Cost scales with the new slice, not the history — until the zones themselves change.

3. Handle points that match nothing

A left join is not the answer — it would reintroduce fanout. Instead, assign unmatched points a sentinel zone in a second pass so the model still holds one row per point, and so the count of unmatched points is measurable rather than invisible.

sql
matched as ( ... as above ... ),

unmatched as (
    select
        p.ping_id, p.trip_id, p.observed_at,
        null::text as zone_id,
        'outside_service_area' as zone_name
    from new_points as p
    where not exists (
        select 1 from {{ ref('stg_zones') }} as z
        where st_intersects(p.geom, z.geom)
    )
)

select * from matched
union all
select * from unmatched

Verify that the two branches partition the input exactly:

sql
select
    (select count(*) from {{ ref('stg_trip_pings') }}) as input_rows,
    (select count(*) from {{ ref('int_point_zone_assignment') }}) as output_rows;
-- Expect the two numbers to be equal on a full refresh

4. Add the tests that hold the grain

yaml
# models/intermediate/schema.yml
models:
  - name: int_point_zone_assignment
    description: One row per ping, with its assigned zone or an explicit outside-area marker.
    tests:
      - dbt_utils.equal_rowcount:
          compare_model: ref('stg_trip_pings')
    columns:
      - name: ping_id
        tests: [unique, not_null]
      - name: zone_name
        tests:
          - not_null
          - accepted_values:
              values: ['outside_service_area']
              quote: true
              config:
                where: "zone_id is null"

The equal_rowcount test is the important one: it is the assertion that the join did not change the grain, and it runs in seconds regardless of geometry size.

5. Refresh when boundaries move

Incremental assignment freezes history by design. When a zone boundary changes, every historical point in the affected area still carries its old assignment — correct if you want assignment-as-of-ingest, wrong if you want current boundaries applied retroactively. Make the choice explicit and automate it.

bash
# Boundary release day: rebuild the assignment from scratch
dbt build --select stg_zones+ --full-refresh

Verify the rebuild changed what you expected and nothing else:

sql
select zone_name, count(*) as pings
from {{ ref('int_point_zone_assignment') }}
group by zone_name
order by pings desc;
-- Compare against the previous release's counts; movement should be confined to changed zones
Three ways a point-in-polygon model loses its one-row-per-point grain Three failure paths shown side by side. Overlapping zones without a tie-break produce two rows for one point. Subdivided polygon pieces without deduplication produce one row per piece. A left join added to keep unmatched points reintroduces every duplicate the deduplication removed. Each path is annotated with the test that catches it. overlapping zones one point sits in two zones 2 rows per ping caught by: unique(ping_id) subdivided pieces one zone became nine rows 1 row per piece hit caught by: equal_rowcount left join for coverage keeps unmatched, and every dupe fanout returns use NOT EXISTS instead All three are invisible in a preview and obvious in a row-count test. Assert the grain in schema YAML before optimizing anything else.

Configuration reference

Parameter Where Default Spatial note
incremental_strategy model config append Use delete+insert or merge so a late-arriving point cannot be assigned twice
unique_key model config none Must be the point key; without it, re-runs duplicate rows
ST_Subdivide vertex cap staging model 256 Trades row multiplication for tighter bounding boxes
zone_priority polygon source none The deterministic tie-break; add it as a column rather than relying on insertion order
on_schema_change model config ignore Set to sync_all_columns if the zone attributes you carry may change
full_refresh CLI / config false Required after any boundary release that should apply retroactively

Gotchas & edge cases

  • A point on a shared boundary intersects both neighbours. That is correct behaviour, not a bug; the tie-break decides. Never rely on ST_Within to resolve it, because a boundary point is within neither polygon under some implementations.
  • max(observed_at) in the incremental filter drops late arrivals. If pings can arrive out of order, use a lookback window (> max(observed_at) - interval '3 days') with delete+insert so re-processed rows replace rather than duplicate.
  • The unmatched branch must use the same column list and order. A union all with mismatched types compiles and then fails at run time on the warehouse, often only in production where the sentinel branch first becomes non-empty.
  • A distinct on over a wide row is expensive. Select only the columns the mart needs before deduplicating; carrying geometry through the sort is a common and avoidable cost.
  • Zone attributes copied into the assignment freeze with it. If zone_name changes, historical rows keep the old label. Carry only zone_id and join names at the mart layer when labels are volatile.

FAQ

Should the assignment table carry geometry?

Usually not. It is an assignment fact — a point key and a zone key — and marts aggregate on those keys, not on shapes. Leaving geometry out shrinks the table dramatically, removes the need for an index on it, and makes the deduplicating sort cheap. Keep geometry only if a downstream consumer genuinely renders these points.

Why NOT EXISTS rather than a left join for unmatched points?

A left join preserves unmatched rows but also preserves every duplicate from the matched side, so it undoes the deduplication you just did. NOT EXISTS asks a boolean question the index answers directly and returns each unmatched point once. It is also the cheaper plan, because it can stop at the first matching piece.

How do I know whether to freeze historical assignments or recompute them?

Ask what the mart claims. “Trips in zone X during Q1” means the boundaries as they were during Q1, which argues for freezing. “Trips currently in zone X” means today’s boundaries applied to history, which requires a full refresh on every boundary release. Write the answer in the model description so the next person does not have to guess.

Does this pattern work on DuckDB for local development?

Yes, with two adjustments: there is no persistent GiST index to create in a post-hook, and distinct on is spelled the same but the incremental strategies available to dbt-duckdb differ. Keep the SQL portable by expressing the tie-break with a window function when you need both engines — the dispatch approach in cross-engine UDF portability covers the pattern.

Up: Part of Spatial Joins & Predicate Tuning.