PostGIS vs DuckDB Spatial for CI Pipelines

This page helps you decide whether a pull-request CI job should validate your dbt spatial models against an in-process DuckDB spatial database or a running PostGIS service — and shows the hybrid that uses both.

When to use this approach

The choice is not “which engine is better” but “which engine should gate a merge”. The two are not interchangeable, and the right answer usually depends on how close your CI must sit to production behavior versus how fast it must return.

  • Reach for DuckDB spatial in CI when you want sub-minute feedback on every push, no service container to provision, and no credentials to inject. It runs in-process through the DuckDB spatial extension integration, so a job spins up, builds the DAG, runs every geometry test, and tears down without a network hop.
  • Reach for a PostGIS service in CI when production is PostGIS and you must catch engine-specific behavior — GiST planner decisions, GEOGRAPHY spheroidal math, post_hook index creation, or functions DuckDB does not implement identically. This is the only way to reproduce the plan the warehouse will actually choose.
  • Use both — the recommended default — when you want the speed of DuckDB as a fast gate on every commit and the fidelity of PostGIS as a slower promotion check before merge. The full engine trade-off matrix lives in choosing the right spatial adapter; this page narrows it to the CI-specific decision.

If your production warehouse is neither engine — BigQuery or Snowflake — DuckDB is still an excellent fast gate for the portable subset of your logic, but a nightly build against the real warehouse remains the only trustworthy fidelity check.

Prerequisites

  • dbt Core 1.7+ with both adapters installed in the CI image: dbt-duckdb for the fast lane and dbt-postgres for the fidelity lane.
  • DuckDB spatial extension available offline or via the extension repository; configured through DuckDB spatial extension integration.
  • A PostGIS 3.3+ service reachable from CI — as a GitHub Actions services: container or an ephemeral cloud instance. Setup mirrors setting up PostGIS with dbt.
  • A single canonical SRID agreed for the project so both engines validate against the same coordinate frame; policy lives in spatial reference system management.
  • Secrets wired through env_var() so the PostGIS lane reads credentials from the CI secret store, never from a committed profiles.yml.
yaml
# dbt_project.yml — shared vars both CI lanes consume
vars:
  canonical_srid: "{{ env_var('DBT_CANONICAL_SRID', '4326') }}"

The core comparison

The two engines differ on the five axes that actually decide a CI strategy: how long a job takes to become usable, how faithfully it reproduces production semantics, how it handles indexes, what it costs in secrets and surface area, and what it costs in money and runner minutes.

Two-lane CI comparison: DuckDB in-process gate versus PostGIS service fidelity check Two horizontal CI lanes triggered by the same pull request. The upper DuckDB lane runs in-process: pip install, build the DAG, run geometry tests, and finish in roughly under a minute with no service and no secrets. The lower PostGIS lane must first start a PostGIS service container and inject credentials before it can build and test, taking several minutes but reproducing production planner and geography behavior. The recommendation is to gate every commit on the fast DuckDB lane and run the slower PostGIS lane before merge. One pull request, two lanes — fast feedback first, high fidelity before merge pull request opened / push DuckDB · in-process gate pip install dbt-duckdb dbt build DAG + tests green gate ≈ under 1 min no serviceno secrets PostGIS · service fidelity check start service + inject secrets dbt build real planner promote check ≈ several min GiST plansGEOGRAPHY Every commit runs the top lane; only merge candidates pay for the bottom lane
Axis DuckDB Spatial (in-process) PostGIS (running service)
Startup cost Seconds — pip install dbt-duckdb, extension loads in-process, no container Minutes — provision a service container, wait for health, run migrations
Function parity Wide, GEOS-backed, planar GEOMETRY; some ST_* and GEOGRAPHY semantics differ from PostGIS The production reference; exact ST_* behavior, topology, and GEOGRAPHY math
Index behavior R-tree is implicit/in-memory; no post_hook GiST to exercise, so index-plan bugs hide Real GiST/SP-GiST via post_hook; EXPLAIN reproduces the planner’s choices
Secrets & surface None — a local file DB, nothing to authenticate, minimal attack surface Host, port, user, password via env_var(); a network service to secure and reach
Cost Effectively free; only the runner minutes it consumes (few) Runner minutes plus service container/instance; slower jobs cost more per PR
Best role Fast gate on every commit Fidelity check before promotion/merge

The single most important row is index behavior. A model that passes on DuckDB can still choose a sequential scan on PostGIS, because DuckDB never exercised the post_hook that builds the GiST index. That is precisely the class of regression the PostGIS lane exists to catch, and why DuckDB alone is not a sufficient gate for a PostGIS-backed production system.

Step-by-step: build the hybrid gate-then-promote CI

The pattern that gives you both speed and fidelity is a two-job workflow: DuckDB gates every commit, PostGIS validates only when a merge is imminent.

1. Define two dbt targets, one per engine

Keep both lanes reading the same models and the same env_var()-driven secrets. Only the connection block differs.

yaml
# ci/profiles.yml
dbt_geospatial:
  target: duckdb_ci
  outputs:
    duckdb_ci:
      type: duckdb
      path: "{{ env_var('DBT_DUCKDB_PATH', 'ci.duckdb') }}"
      extensions: [spatial]
    postgis_ci:
      type: postgres
      host: "{{ env_var('DBT_PG_HOST') }}"
      port: "{{ env_var('DBT_PG_PORT', '5432') | int }}"
      user: "{{ env_var('DBT_PG_USER') }}"
      password: "{{ env_var('DBT_PG_PASSWORD') }}"
      dbname: "{{ env_var('DBT_PG_DBNAME', 'gis') }}"
      schema: "{{ env_var('DBT_PG_SCHEMA', 'dbt_ci') }}"
      threads: 4

Verify both targets resolve before wiring CI:

bash
dbt debug --target duckdb_ci    # expects "Connection test: OK"
dbt debug --target postgis_ci   # expects "Connection test: OK"

2. Make the DuckDB gate the required check

This job runs on every push. It installs one package, builds the DAG, and runs every geometry test in-process — no services: block, no secrets.

yaml
# .github/workflows/spatial-ci.yml
name: spatial CI
on: [pull_request]
jobs:
  duckdb-gate:
    runs-on: ubuntu-latest
    env:
      DBT_PROFILES_DIR: ./ci
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.11" }
      - run: pip install dbt-duckdb
      - run: dbt build --target duckdb_ci   # DAG + all generic tests

Verify the gate is fast and self-contained: the job should complete without a network dependency and, on a modest project, finish in well under a minute. If it stalls, the spatial extension is being downloaded on the fly — pre-bake it into the runner image.

3. Add the PostGIS fidelity lane as a service job

This job only needs to run before merge. It brings up a PostGIS container, injects credentials from the secret store, and builds against the real planner so GiST post_hook indexes and GEOGRAPHY math are exercised exactly as in production.

yaml
  postgis-promote:
    runs-on: ubuntu-latest
    needs: duckdb-gate
    env:
      DBT_PROFILES_DIR: ./ci
      DBT_PG_HOST: localhost
      DBT_PG_USER: ${{ secrets.PG_USER }}
      DBT_PG_PASSWORD: ${{ secrets.PG_PASSWORD }}
    services:
      postgis:
        image: postgis/postgis:16-3.4
        env:
          POSTGRES_USER: ${{ secrets.PG_USER }}
          POSTGRES_PASSWORD: ${{ secrets.PG_PASSWORD }}
          POSTGRES_DB: gis
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U ${{ secrets.PG_USER }}"
          --health-interval 5s --health-timeout 5s --health-retries 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.11" }
      - run: pip install dbt-postgres
      - run: dbt build --target postgis_ci

Verify the fidelity lane actually exercised an index rather than silently passing on a sequential scan:

sql
-- run inside the PostGIS lane against a proximity model
EXPLAIN ANALYZE
SELECT a.id, b.id
FROM stg_points a
JOIN stg_zones b ON ST_DWithin(a.geom, b.geom, 500);
-- expect "Index Scan using ..._gist"; a "Seq Scan" means the post_hook index did not build

4. Isolate engine differences behind macros

The hybrid only stays green on both engines if every dialect difference is routed through an adapter-aware macro rather than branched by hand in model SQL. A KNN <-> lateral join has no DuckDB equivalent, and GEOGRAPHY casting differs; dispatch on target.type so one model compiles correctly on both lanes. The construction pattern is covered in choosing the right spatial adapter and its downstream macro guides.

sql
-- macros/spatial/dwithin_ci.sql
{% macro dwithin_ci(a, b, meters) %}
  {%- if target.type == 'duckdb' -%}
    ST_DWithin({{ a }}, {{ b }}, {{ meters }})
  {%- else -%}
    ST_DWithin({{ a }}::geography, {{ b }}::geography, {{ meters }})
  {%- endif -%}
{% endmacro %}

Verify the macro emits the right dialect on each target:

bash
dbt compile --select fct_proximity --target duckdb_ci
dbt compile --select fct_proximity --target postgis_ci
# diff target/compiled/... — the geography cast should appear only in the PostGIS output

Configuration reference

Setting DuckDB lane PostGIS lane Notes
dbt target duckdb_ci postgis_ci Same models, different outputs: block
Trigger every push before merge (needs: duckdb-gate) Fail fast on the cheap lane first
Secrets none PG_USER, PG_PASSWORD via env_var() Never commit credentials to profiles.yml
Service none postgis/postgis:16-3.4 container Add a --health-cmd so dbt waits for readiness
Index coverage implicit R-tree, not post_hook real GiST/SP-GiST via post_hook Only the PostGIS lane validates index plans
Typical duration seconds minutes Budget runner minutes accordingly

Gotchas & edge cases

  • DuckDB green is not PostGIS green. A post_hook GiST index never runs in the in-memory DuckDB lane, so an index-plan regression sails through the gate and only surfaces in the PostGIS lane. Treat the DuckDB job as a correctness-of-logic gate, not a performance gate.
  • GEOGRAPHY semantics diverge. Spheroidal distance on PostGIS GEOGRAPHY will not match planar GEOMETRY results in DuckDB. If a model depends on great-circle distance, its numeric assertions belong in the PostGIS lane; the trade-off is detailed in geometry vs geography type trade-offs.
  • Extension download inside the job. If DuckDB fetches the spatial extension at runtime, the “fast” lane inherits a flaky network dependency. Pre-install or vendor the extension into the CI image.
  • SRID drift between lanes. Seed the same fixture SRIDs into both engines. A fixture tagged SRID 0 may pass planar checks in DuckDB and then fail a ::geography cast in PostGIS.
  • Service never became ready. Without a --health-cmd, dbt connects before PostGIS finishes initializing and the job fails with a confusing connection error rather than waiting. Always gate the build step on a health check.

FAQ

Can DuckDB fully replace a PostGIS service in CI?

Only if production is not PostGIS, or if you accept that index-plan and GEOGRAPHY regressions will not be caught until later. DuckDB validates SQL correctness, geometry validity, and the portable subset of ST_* behavior extremely fast, but it cannot reproduce the PostGIS planner’s GiST decisions because the post_hook index never runs in-process. For a PostGIS production system, keep DuckDB as the fast gate and PostGIS as the fidelity check.

Why run the DuckDB gate before the PostGIS lane rather than in parallel?

Because the DuckDB gate catches the overwhelming majority of failures — syntax errors, invalid geometries, broken refs, failing generic tests — in seconds, before you spend runner minutes standing up a PostGIS container. Making the PostGIS job depend on the DuckDB job with needs: fails fast on the cheap lane and only pays for the expensive lane when the logic is already known good.

How do I keep one model compiling on both engines?

Route every dialect difference through an adapter-aware macro that branches on target.type, rather than writing engine-specific SQL in the model. GEOGRAPHY casts, KNN <-> operators, and index hints differ between DuckDB and PostGIS; a dispatched macro emits the correct form per target so the same model file builds on both lanes. Compile against each target in CI and diff the output to catch drift.

Do I need secrets for the DuckDB lane?

No. DuckDB spatial runs against a local file database in-process, so there is nothing to authenticate and no network service to secure. Only the PostGIS lane needs credentials, and those should be read through dbt’s env_var() from the CI secret store, never committed to profiles.yml.

What about a BigQuery or Snowflake production warehouse?

DuckDB still works as a fast gate for the portable subset of your logic, but neither DuckDB nor PostGIS reproduces those warehouses’ GEOGRAPHY-only semantics. Add a nightly or pre-merge build against the real warehouse as the fidelity lane, keeping DuckDB as the per-commit gate to hold cloud cost and latency down.

Up: Part of Choosing the Right Spatial Adapter.