DuckDB as a lightweight CI validator

This page shows you how to use in-process DuckDB spatial as a fast validation engine for dbt geometry models in CI — installing the spatial extension, running the geometry tests, and knowing precisely where DuckDB’s results can diverge from PostGIS before you promote.

When to use this approach

Validate spatial models on DuckDB in CI — rather than standing up PostGIS or paying for a BigQuery build on every push — when any of these hold:

  • Your CI feedback loop is too slow. A PostGIS service container adds startup, load, and teardown to every job. DuckDB spatial runs in-process, so the gate finishes in seconds, which is what makes the two-stage pattern in spatial testing in CI pipelines practical.
  • Your models use the common ST_* core. Validity, SRID, containment, distance, and buffer operations behave identically enough on DuckDB and PostGIS to gate on. The engine trade-offs behind this choice are compared in PostGIS vs DuckDB spatial for CI pipelines.
  • You develop locally without a database. DuckDB is the same engine described in DuckDB spatial extension integration — a single file, no server — so an engineer validates the DAG on a laptop with the identical target CI uses.

If your models lean on PostGIS-only features (topology, raster, <-> KNN laterals), DuckDB validates the portable subset and a nightly PostGIS job covers the rest.

Prerequisites

  • dbt-duckdb ≥ 1.7 installed in the CI environment.
  • The DuckDB spatial extension, fetched on first connect or cached on the runner.
  • Geometry generic tests (assert_valid_geometry, assert_srid) and, ideally, edge-case geometry fixtures so the validator has something to catch.
  • A canonical SRID agreed for the project, stored as a dbt var.

Step-by-step instructions

1. Install and load the spatial extension

DuckDB ships spatial as a loadable extension, not a built-in. The dbt-duckdb adapter loads it for you when you list it under extensions, which is equivalent to running INSTALL spatial; LOAD spatial; on connect.

yaml
# ci/profiles.yml
dbt_geospatial:
  target: ci
  outputs:
    ci:
      type: duckdb
      path: "{{ env_var('DBT_DUCKDB_PATH', 'ci.duckdb') }}"
      extensions:
        - spatial

Verify the extension is present and the spatial functions resolve:

bash
dbt run-operation debug_spatial --target ci --profiles-dir ./ci
# or, directly in the DuckDB CLI:
#   INSTALL spatial; LOAD spatial;
#   SELECT ST_IsValid(ST_GeomFromText('POLYGON((0 0,0 1,1 1,1 0,0 0))'));  -- true

2. Build the DAG on DuckDB and run the geometry tests

Run dbt build against the CI target so every model materializes into the DuckDB file and its attached geometry tests run in DAG order. Nothing external is touched.

bash
dbt build --target ci --profiles-dir ./ci
# builds every model into ci.duckdb and runs assert_valid_geometry / assert_srid inline

Verify the validator catches a real defect by pointing it at the edge-case fixtures:

bash
dbt build --target ci --profiles-dir ./ci --select tag:ci_only
# Expect a non-zero exit: the unclosed-ring and self-intersection fixtures fail ST_IsValid

3. Scope the gate to the parity subset

The gate should assert only what holds identically on DuckDB and the production engine. In practice that is validity, SRID, containment, distance, and buffered-area comparisons — the checks whose verdict does not depend on engine internals. Keep PostGIS-only assertions (topology, KNN operator plans) out of the DuckDB selection and run them separately.

The parity subset validated on DuckDB versus the PostGIS-only remainder Two panels. The left panel lists the parity subset that DuckDB spatial validates identically to PostGIS on every push: ST_IsValid validity, SRID equality, containment predicates, planar distance, and buffered area. The right panel lists the PostGIS-only remainder deferred to a nightly job: geography great-circle distance, the KNN lateral operator, topology functions, and GiST index-plan assertions. An arrow shows models flowing from the fast DuckDB gate to the slower PostGIS parity job on merge. DuckDB gate · every push in-process · seconds · required check ST_IsValid — validity ST_SRID — SRID equality ST_Contains / ST_Intersects planar ST_Distance · ST_Area GEOS-backed — verdict matches PostGIS on merge PostGIS only · nightly / merge service container · the divergent remainder ::geography great-circle distance <-> KNN lateral join topology functions GiST index-plan (EXPLAIN) no DuckDB equivalent — cannot be gated fast
bash
# DuckDB gate: portable checks only, on every push
dbt build --target ci --profiles-dir ./ci --select tag:portable

# PostGIS parity job: the engine-specific remainder, on merge or nightly
dbt build --target prod_ci --select tag:postgis_only

Verify the split by confirming the portable selection contains no model that calls a PostGIS-only function:

bash
dbt ls --target ci --select tag:portable --output path
# review the listed models; none should reference topology or the <-> operator

4. Promote only after the DuckDB gate is green

Wire the DuckDB gate as the required pull-request check and let promotion to PostGIS run only on merge. The models are unchanged between the two — only the --target differs — so a green DuckDB build is a genuine signal about the SQL.

bash
# runs on merge to main, after the DuckDB gate has already passed
dbt build --target prod

Verify parity on the first few promotions by diffing a validity summary across engines:

sql
-- run on each engine; the counts should match for the portable subset
select count(*) filter (where not ST_IsValid(geometry)) as invalid,
       count(distinct ST_SRID(geometry))                as srid_variants
from mart_service_areas;

Configuration reference

Concern DuckDB spatial (CI) PostGIS (production)
Extension load extensions: [spatial] in profile CREATE EXTENSION postgis; once per DB
Validity check ST_IsValid — GEOS-backed ST_IsValid — GEOS-backed
SRID storage Per-column, explicit ST_SetSRID Per-column, typmod-enforced
Spatial index Implicit / in-memory R-tree Explicit GiST via post_hook
Nearest neighbour No <-> lateral idiom; use ST_DWithin <-> KNN lateral supported
ST_Transform / CRS Supported; needs the PROJ data the extension bundles Full spatial_ref_sys catalog
Geography type Planar GEOMETRY only GEOMETRY and GEOGRAPHY

Gotchas & edge cases

  • GEOGRAPHY semantics are absent. DuckDB spatial is planar GEOMETRY only; a model that relies on ::geography great-circle distance cannot be validated for distance accuracy on DuckDB. Validate the query shape on DuckDB and assert distance correctness in the PostGIS parity job.
  • KNN laterals do not port. There is no <-> operator idiom in DuckDB, so a PostGIS nearest-neighbour model has no direct equivalent. Use the portable ST_DWithin radius form for anything the DuckDB gate must run; the divergence is detailed in optimizing proximity joins.
  • Validity verdicts on degenerate shapes. Both engines use GEOS, but version skew means a handful of borderline self-touching rings can be judged differently. Pin the fixture set to defects both flag, and treat any divergence as a signal to run that model through the PostGIS job.
  • Index behaviour is not reproduced. DuckDB’s in-memory R-tree does not model a PostGIS GiST post_hook, so CI cannot catch a “planner ignored the index” regression. Keep EXPLAIN-based index assertions in the PostGIS parity job.
  • Extension download on cold runners. The first INSTALL spatial reaches the network; cache the extension directory so CI does not flake on a throttled endpoint.

FAQ

Is DuckDB spatial accurate enough to trust as a gate?

For the portable subset — validity, SRID, containment, planar distance, buffered area — yes: DuckDB spatial is GEOS-backed like PostGIS, so those verdicts match. It is not a substitute for PostGIS on geography distance, KNN plans, topology, or index behaviour. Gate on the parity subset for speed and run a nightly PostGIS job for the divergent features.

Do I have to install the spatial extension separately?

DuckDB downloads the spatial extension on the first INSTALL spatial and loads it with LOAD spatial. The dbt-duckdb adapter does both for you when you list spatial under extensions in the profile. On ephemeral CI runners, cache the extension directory so it is not re-fetched every job.

Where does DuckDB diverge from PostGIS in practice?

Four places: it has no GEOGRAPHY type, so great-circle distance is not available; there is no double-arrow KNN lateral idiom; it does not reproduce PostGIS GiST index plans, so index-usage regressions are invisible; and version skew in the shared GEOS library can flip the validity verdict on a few degenerate shapes. Everything else in the common ST_ core behaves the same.

Can I develop locally against the same DuckDB target CI uses?

Yes, that is the main benefit. The DuckDB target is a single file with no server, so an engineer runs the identical dbt build --target ci on a laptop that CI runs on the hosted runner. The gate you pass locally is the gate CI enforces, with no environment drift.

Up: Part of Spatial Testing in CI Pipelines.