Incremental Materialization for Large Geometry Tables

This page shows you how to materialize a large geometry table incrementally in dbt so each run recomputes only new or changed rows — using an is_incremental() watermark, a partition-pruning predicate, a bounding-box pre-filter, and a lookback window that catches late-arriving geometry without a full rebuild.

When to use this approach

Incremental materialization pays off once a full table rebuild costs more than the freshness you gain from it. Reach for it when:

  • A rebuild scans tens of millions of geometries or more. Geometry columns are heavy and topology is expensive, so re-materializing the whole table nightly wastes warehouse time. This is the temporal half of the scaling controls in handling large geospatial datasets; combine it with partitioning geospatial tables with H3 and dbt so each run also touches only the relevant cells.
  • New rows arrive on a monotonic key. An event_timestamp, loaded_at, or ascending id lets is_incremental() filter the source to just the delta. Without such a watermark, incremental logic has nothing to gate on.
  • The expensive work is a spatial join or transform. If a proximity or containment predicate dominates cost, restricting it to changed rows is the single biggest win — the exact-predicate tuning lives in incremental spatial models with bounding-box filters and the broader pattern in optimizing proximity joins.

If your table is small, changes are unpredictable across the whole history, or correctness demands a deterministic full recompute every run, a plain table materialization is simpler and safer.

Prerequisites

  • dbt Core >= 1.7 for current incremental_strategy, on_schema_change, and incremental_predicates support.
  • A spatial engine with a stable merge/delete+insert path: PostGIS >= 3.3, the DuckDB spatial extension for CI, or BigQuery GIS / Snowflake GEOGRAPHY. Pick per choosing the right spatial adapter.
  • A canonical SRID enforced in staging so the incremental predicate never compares mismatched projections — see spatial reference system management.
  • A reliable watermark column (event_timestamp or loaded_at) that is indexed and monotonic enough to gate the delta.
  • Project vars for the canonical SRID, the lookback window, and the incremental bounding box:
yaml
# dbt_project.yml
vars:
  canonical_srid: "{{ env_var('DBT_SPATIAL_CANONICAL_SRID', '4326') }}"
  incremental_lookback_hours: 48   # re-scan window for late-arriving rows

Step-by-step instructions

An incremental run has a distinct shape from a full refresh: it reads only rows past the watermark (minus a lookback), applies the same spatial filters as a full build, and merges the delta on a stable key. The diagram traces both paths.

Full-refresh path versus incremental delta path for a large geometry table Two paths from the same staging source into one target table. The full-refresh path, taken when is_incremental is false, reads the entire source, applies the spatial filters, and replaces the target. The incremental path, taken when is_incremental is true, reads only rows whose event_timestamp is greater than the current max minus a lookback window, applies the same bounding-box pre-filter and exact spatial predicate, then merges the delta into the target on the unique_key so re-processed late-arriving rows update in place rather than duplicating. A caption notes that the lookback window is what recovers rows that arrived out of order. Same filters, two entry points — is_incremental() decides how much source is read Staging validated geom canonical SRID is_incremental() false → full, true → delta Full refresh read all rows bbox + exact predicate Incremental delta ts > max(ts) - lookback && bbox pre-filter exact ST_ predicate only changed rows Target table MERGE on unique_key late rows update in place, no dupes The lookback window (max(ts) − N hours) is what recovers geometry that arrived out of order

1. Configure the incremental model with a unique_key

Declare incremental with a merge strategy and a genuinely unique key. The key is what lets a re-processed late row overwrite its earlier version instead of duplicating it.

sql
-- models/marts/fct_events_enriched.sql
{{ config(
    materialized='incremental',
    unique_key='event_id',
    incremental_strategy='merge',
    on_schema_change='append_new_columns',
    post_hook="CREATE INDEX IF NOT EXISTS {{ this.name }}_geom_gist ON {{ this }} USING GIST (geom)"
) }}

SELECT
    e.event_id,
    e.event_timestamp,
    e.geom,
    z.zone_id
FROM {{ ref('stg_events') }} e
LEFT JOIN {{ ref('dim_zones') }} z
  ON ST_Intersects(e.geom, z.geom)

Verify the key is actually unique before you trust the merge:

sql
SELECT event_id, count(*) AS n
FROM {{ ref('stg_events') }}
GROUP BY event_id HAVING count(*) > 1;
-- must return zero rows; a non-unique key silently drops or double-writes on merge

2. Gate the source with an is_incremental() watermark and lookback

Filter the driving table to rows newer than the current maximum, minus a lookback window. The lookback is the cheap insurance that recovers rows whose timestamp landed behind the watermark.

sql
{% if is_incremental() %}
  WHERE e.event_timestamp > (
      SELECT MAX(event_timestamp) - INTERVAL '{{ var("incremental_lookback_hours") }} hours'
      FROM {{ this }}
  )
{% endif %}

Verify the delta is bounded, not the whole table:

sql
-- compile the model, then inspect the row estimate of the incremental branch
EXPLAIN
SELECT count(*) FROM stg_events
WHERE event_timestamp > (SELECT MAX(event_timestamp) - INTERVAL '48 hours' FROM fct_events_enriched);
-- expect an estimate near one window of arrivals, not the full source cardinality

3. Push the delta filter into the join with incremental_predicates

A merge still scans the target to find matching keys. On large targets, restrict that scan too, so the merge does not read the entire history to update a day’s worth of rows.

sql
{{ config(
    materialized='incremental',
    unique_key='event_id',
    incremental_strategy='merge',
    incremental_predicates=[
      "DBT_INTERNAL_DEST.event_timestamp > current_date - interval '3 days'"
    ]
) }}

Verify the merge touches a bounded slice of the target:

sql
EXPLAIN (ANALYZE, BUFFERS)
-- run dbt with --log-level debug and read the emitted MERGE plan;
-- the destination scan should be date-bounded, not a full Seq Scan of the target.
SELECT 1;

4. Add a bounding-box pre-filter before the exact predicate

The spatial join is the costly part. Gate it behind a cheap && bounding-box test so the GiST index prunes candidates before any exact ST_Intersects runs — the same two-phase discipline used across the large-dataset patterns.

sql
FROM {{ ref('stg_events') }} e
LEFT JOIN {{ ref('dim_zones') }} z
  ON e.geom && z.geom               -- index-eligible bounding-box pre-filter
 AND ST_Intersects(e.geom, z.geom)  -- exact topology on survivors only
{% if is_incremental() %}
  WHERE e.event_timestamp > (
      SELECT MAX(event_timestamp) - INTERVAL '{{ var("incremental_lookback_hours") }} hours'
      FROM {{ this }}
  )
{% endif %}

Verify the planner uses the index for the join:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT e.event_id, z.zone_id
FROM stg_events e JOIN dim_zones z
  ON e.geom && z.geom AND ST_Intersects(e.geom, z.geom);
-- expect "Index Scan using ..._geom_gist", not "Seq Scan", on the zone side

5. Handle late-arriving geometry explicitly

Some feeds deliver corrections — a geometry restated hours after its event time. Because the merge keys on event_id and the lookback re-scans the recent window, a corrected row overwrites the old one. Prove it with a targeted test rather than trusting it.

sql
-- tests/assert_no_stale_geometry.sql
-- Returns rows where the mart geometry disagrees with the freshest source geometry.
SELECT f.event_id
FROM {{ ref('fct_events_enriched') }} f
JOIN {{ ref('stg_events') }} s USING (event_id)
WHERE NOT ST_Equals(f.geom, s.geom)
  AND s.event_timestamp > current_date - interval '{{ var("incremental_lookback_hours") }} hours' / 24

Verify by running a full refresh occasionally and diffing against the incremental output:

bash
dbt run --select fct_events_enriched --full-refresh
# then compare row counts and a geometry checksum against the incremental build;
# a mismatch means the watermark or lookback is dropping corrections.

Configuration reference

Parameter Accepted values Default Spatial notes
materialized incremental Required for delta processing; falls back to a full build on first run and --full-refresh
unique_key column or list Must be truly unique in the source or merge drops/duplicates rows; verify before trusting it
incremental_strategy merge, delete+insert, append merge merge handles corrections in place; append never dedupes and will double-write late rows
incremental_predicates list of SQL strings Bounds the destination scan of the merge; date-bound it on large targets
on_schema_change append_new_columns, sync_all_columns, fail, ignore ignore Use append_new_columns so a new attribute does not silently drop; type changes to geometry deserve a versioned migration
incremental_lookback_hours integer 48 Re-scan window that recovers late-arriving rows; set wider than the worst observed arrival delay
canonical_srid any EPSG code 4326 The delta predicate must not compare mixed SRIDs; enforce it upstream

Gotchas & edge cases

  • A non-unique unique_key corrupts the merge. If duplicates exist in the source, merge either drops all but one or double-writes non-deterministically. Assert uniqueness in staging before the first incremental run.
  • No lookback drops out-of-order rows forever. A strict ts > max(ts) watermark permanently skips any row whose timestamp arrives behind the high-water mark. The lookback window trades a little rescan for correctness; size it to the worst arrival delay you have observed.
  • Geometry equality is not byte equality. Comparing corrected geometries with = can miss logically equal shapes with different vertex ordering or precision. Use ST_Equals for the topological comparison, and remember it is more expensive than a scalar check.
  • Merge without incremental_predicates scans the whole target. On a billion-row table the destination scan, not the source delta, becomes the bottleneck. Bound it with a date predicate on DBT_INTERNAL_DEST.
  • append strategy silently duplicates. It is fine for pure event logs that never restate, but any feed that corrects geometry needs merge or delete+insert; append will accumulate stale duplicates.
  • Schema drift on geometry columns. A source that changes a column from GEOMETRY to GEOGRAPHY mid-stream breaks the merge type contract. Treat it as a schema event under versioning spatial schemas in dbt, not an on_schema_change auto-fix.

FAQ

Why do late-arriving geometries go missing from my incremental table?

A strict event_timestamp > (SELECT MAX(event_timestamp) FROM this) watermark permanently skips any row whose timestamp is older than the current high-water mark, which is exactly what a delayed or restated feed produces. Subtract a lookback window from the watermark so each run re-scans the recent period and the merge overwrites the earlier version on the unique_key. Size the lookback to the largest arrival delay you have observed, and run an occasional --full-refresh to confirm nothing is slipping through.

Should I use merge, delete+insert, or append for a geometry table?

Use merge when rows can be corrected or restated, because it updates the existing row in place on the unique_key. Use delete+insert when merge is unsupported or slow on your adapter and you can delete a bounded partition first. Avoid append for anything that restates geometry — it never dedupes, so a late correction lands as a second row and your table accumulates stale duplicates. Only pure, immutable event logs are safe with append.

Does the bounding-box pre-filter change which rows I keep?

No, provided you keep the exact predicate after it. The && operator only compares envelopes, so it is a superset filter: it can let through a candidate whose bounding box overlaps but whose true shape does not, and the exact ST_Intersects then discards it. Dropping the exact predicate and keeping only && would change results by admitting bounding-box false positives, so always run both, && first for the index, then the exact test.

How do I make the merge itself fast on a very large target?

The merge has to locate matching keys in the destination, and on a billion-row target that scan dominates. Add incremental_predicates that date-bound the destination side (for example, only the last few days), so the engine scans a slice instead of the whole history. Keep the merge key indexed, and ensure the same date column is clustered or partitioned so the bounded scan is cheap.

Up: Part of Handling Large Geospatial Datasets.