Benchmarking spatial adapters with a dbt harness

This page builds a small dbt project whose only purpose is to answer “which engine should we use for this workload” with numbers from your own data: five representative spatial operations, run identically on every candidate, with correctness checked alongside speed.

When to use this approach

  • The adapter decision is contested and the arguments are about general reputation rather than your data. A day of benchmarking settles it better than a week of discussion.
  • A workload changed materially. An engine chosen for point-in-polygon work may be the wrong one now that the heaviest query is a nearest-neighbour search.
  • You are considering a migration. Benchmark before porting, because the port is the expensive part and the benchmark is the cheap part. The decision framework is in choosing the right spatial adapter.

Prerequisites

  • Two or more configured targets in one profiles.yml, so the same project runs against each.
  • A representative data sample — the same rows everywhere, loaded the same way.
  • Dispatched macros for any function whose name differs, per cross-engine UDF portability.
  • Somewhere to record results; the run-history table from monitoring spatial model run times in dbt works unchanged.

Step-by-step instructions

1. Pick five queries that represent the real workload

A benchmark of one query measures one query. Five is enough to cover the shapes that behave differently, and few enough to keep the harness maintainable.

Benchmark model Operation What it stresses
bench_01_point_in_polygon ST_Intersects join, points to zones Index use and exact-test cost
bench_02_nearest_neighbour k nearest depots per ping Ordered search, KNN support
bench_03_dissolve ST_Union over a group Aggregate geometry construction
bench_04_transform_sweep ST_Transform over every row PROJ throughput, per-row function cost
bench_05_validity_sweep ST_IsValid over every row Full-scan predicate cost

Verify the mix matches your workload rather than a textbook. If your pipeline never dissolves, replace bench_03 with whatever it does instead — the point is representativeness, not comparability with anyone else’s numbers.

2. Write each benchmark as one model, portable across targets

sql
-- models/bench/bench_01_point_in_polygon.sql
{{ config(materialized = 'table', tags = ['bench']) }}

select
    p.ping_id,
    z.zone_id
from {{ ref('bench_pings') }} as p
join {{ ref('bench_zones') }} as z
  on {{ st_intersects('p.geom', 'z.geom') }}
sql
-- models/bench/bench_02_nearest_neighbour.sql
{{ config(materialized = 'table', tags = ['bench']) }}

select
    p.ping_id,
    n.depot_id,
    n.distance_m
from {{ ref('bench_pings') }} as p
cross join lateral (
    select d.depot_id, {{ st_distance_m('p.geom', 'd.geom') }} as distance_m
    from {{ ref('bench_depots') }} as d
    order by {{ knn_operator('p.geom', 'd.geom') }}
    limit 3
) as n

The knn_operator macro is where engines diverge most: PostGIS has the <-> distance operator backed by a GiST index, and most other engines have nothing equivalent, so their implementation is an ordered scan. That difference is the benchmark’s most valuable finding rather than an inconvenience to hide.

Verify every benchmark compiles on every target before running any of them:

bash
for t in postgres_bench duckdb_bench bigquery_bench; do
  dbt compile --select tag:bench --target "$t" >/dev/null && echo "$t compiles"
done
Five benchmark shapes and which engine capability each one exposes Five rows, each naming a benchmark and the capability it exposes. Point in polygon exposes spatial index quality. Nearest neighbour exposes whether an indexed distance operator exists. Dissolve exposes aggregate geometry construction. Transform sweep exposes per-row projection throughput and is marked unavailable on engines without reprojection. Validity sweep exposes full-scan predicate cost and is marked not applicable where geometry cannot be invalid. benchmark capability it exposes bench_01_point_in_polygon spatial index quality and recheck cost bench_02_nearest_neighbour indexed distance operator, or its absence bench_03_dissolve aggregate geometry construction bench_04_transform_sweep reprojection throughput — absent on some engines bench_05_validity_sweep full-scan predicate cost

3. Run each target the same way, three times

bash
#!/usr/bin/env bash
# scripts/run_benchmark.sh
for target in postgres_bench duckdb_bench bigquery_bench; do
  for run in 1 2 3; do
    dbt build --select tag:bench --target "$target" --full-refresh
    python scripts/load_artifacts.py \
      --run-results target/run_results.json \
      --manifest target/manifest.json \
      --table ops.bench_history \
      --label "${target}-run${run}"
  done
done

Three runs, full refresh each time, and the median reported. A single run measures cache state as much as engine capability; the first run against a cold cache is usually the outlier.

Verify the runs are comparable by checking input row counts on each target — a benchmark against different data is not a benchmark.

sql
select target_name, count(*) from ops.bench_input_counts group by 1;

4. Check correctness, not only speed

sql
-- tests/assert_bench_results_agree.sql
with pg as (select ping_id, zone_id from {{ ref('bench_01_point_in_polygon') }}),
     ref_ as (select ping_id, zone_id from {{ source('bench', 'expected_point_in_polygon') }})
select 'missing' as side, * from (select * from ref_ except select * from pg) a
union all
select 'extra', * from (select * from pg except select * from ref_) b

An engine that is twice as fast and disagrees on 0.1 per cent of rows has not won; it has raised a question about spherical versus planar edges, tie-breaking on boundaries, or validity handling. Answer that question before comparing times.

Verify the disagreement count is zero or explained. A handful of boundary rows on a geodesic engine is expected and should be written down as such rather than discovered later.

Benchmark results read as a profile rather than a single winner A grid of three engines against five benchmarks, with relative speed shown by bar length. One engine is fastest at point-in-polygon and nearest neighbour, another at the transform and validity sweeps, and the third cannot run the transform benchmark at all. A caption notes that the fastest engine differs by workload, which is why the choice depends on which benchmark dominates the real pipeline. benchmark PostGIS DuckDB cloud warehouse point in polygon nearest neighbour dissolve transform sweep not supported validity sweep not applicable Why three runs and a median, rather than one measurement Three runs of the same benchmark on one engine. The first is markedly slower because caches are cold and, on a cloud warehouse, the cluster is starting. The second and third agree closely. A marker shows the median falling on the second run, and a note warns that reporting the first run would overstate the engine's cost by a factor of three. run 1 · cold 142 s run 2 47 s run 3 49 s median 47 s — the number to report Reporting run one would have cost this engine the comparison by a factor of three.

5. Write down the decision and its expiry

A benchmark result is true for a data size, an engine version and a workload mix. Record all three, so the next person knows when it stopped being evidence.

yaml
# models/bench/schema.yml
models:
  - name: bench_01_point_in_polygon
    description: >
      Benchmark run 2026-08-11. 42M pings × 18k subdivided zones.
      PostGIS 3.4.2 median 41 s; DuckDB 0.10.2 median 96 s; warehouse median 154 s.
      Results identical across engines. Decision: PostGIS for the nightly join.
      Re-run when ping volume doubles or any engine has a major upgrade.

Configuration reference

Element Where Value Note
runs per target harness script 3 Report the median; the first run is usually cold
--full-refresh build command always Incremental state makes runs incomparable
sample size benchmark seeds large enough to exceed cache A benchmark that fits in memory measures memory
tag:bench model config Keeps benchmarks out of every other schedule
correctness reference source table one agreed engine’s output Speed without agreement is not a result
result expiry model description data size, versions, date Turns a number into evidence with a shelf life

Gotchas & edge cases

  • Warm caches flatter whichever engine ran last. Restart the service or clear the cache between runs where you can, and always report the median of three.
  • A cloud warehouse’s first query pays for cluster start. Exclude it or note it; comparing a warm PostGIS against a cold warehouse is not a comparison.
  • Sample size changes the ranking. Engines that lose on small data can win on large, because fixed overheads amortise. Benchmark near production scale or state the caveat prominently.
  • --full-refresh on a cloud engine can be expensive. Set a byte ceiling before running the harness, not after.
  • Do not benchmark what you will not run. A dissolve benchmark on a pipeline that never dissolves adds a number that will eventually be used to justify a decision it has nothing to do with.

FAQ

How large should the benchmark sample be?

Large enough that the working set exceeds the machine’s cache, and ideally within an order of magnitude of production. Below that, you measure fixed overheads; above it, the harness becomes too slow to iterate on. A tenth of production volume is a reasonable compromise for most projects.

Should the benchmark include index build time?

Separately, yes. Index creation is a real cost that a full refresh pays, and engines differ in it substantially — some have no index to build at all. Report it as its own line rather than folding it into the query time, or an engine with no index looks artificially good.

What if the engines disagree on results?

Stop and explain the disagreement before reading any timing. The usual causes are geodesic versus planar edges, boundary tie-breaks and differing validity handling — all discussed in warehouse-native GIS adapters. A documented, understood difference is fine; an unexplained one invalidates the benchmark.

Is a benchmark worth it for a small project?

The five-model version here takes about a day including data preparation, which is cheap relative to a wrong adapter choice on a project of any size. For a genuinely small project, run just the two benchmarks matching your dominant operations — the harness is worth having even when reduced.

Up: Part of Choosing the Right Spatial Adapter.