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
-- 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') }}
-- 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:
for t in postgres_bench duckdb_bench bigquery_bench; do
dbt compile --select tag:bench --target "$t" >/dev/null && echo "$t compiles"
done
3. Run each target the same way, three times
#!/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.
select target_name, count(*) from ops.bench_input_counts group by 1;
4. Check correctness, not only speed
-- 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.
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.
# 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-refreshon 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.
Related
- Choosing the Right Spatial Adapter — the decision this informs.
- PostGIS vs DuckDB Spatial for CI Pipelines — the same comparison for a narrower purpose.
- Monitoring Spatial Model Run Times in dbt — the artifact capture this harness reuses.
Up: Part of Choosing the Right Spatial Adapter.