Warehouse-Native GIS Adapters

PostGIS is the reference implementation of spatial SQL, but a growing share of dbt geospatial projects never touch it: the warehouse already has a spatial type, the data already lives there, and moving hundreds of gigabytes of geometry into Postgres to run a point-in-polygon join is not a serious proposal. This topic covers what actually changes when the execution engine is BigQuery, Snowflake or Redshift rather than PostGIS — and it is more than a function-name mapping. You lose the index as a tuning lever, you gain a cost model measured in bytes scanned, and in BigQuery’s case you inherit spherical geometry semantics that quietly return different answers from planar PostGIS for the same inputs.

The decision of which engine to target belongs upstream of this page, in choosing the right spatial adapter. What follows assumes the choice is made — often by the platform team, not the analytics engineers — and concentrates on building a project that is correct and affordable on the engine you have.

Prerequisites checklist

  • A dbt adapter matching the warehouse: dbt-bigquery ≥ 1.7, dbt-snowflake ≥ 1.7, or dbt-redshift ≥ 1.7. Spatial support is in the warehouse, not the adapter, so no spatial-specific plugin is required.
  • Warehouse permissions to create clustered tables, since clustering replaces indexing as the physical tuning lever on every engine in this topic.
  • A clear decision on the storage typeGEOGRAPHY everywhere, or GEOMETRY with a projected SRID where the engine supports it. Mixing them across models is the most expensive mistake available here.
  • A cost guardrail before the first large model runs. A single careless spatial cross join on a consumption-priced warehouse is a memorable invoice; the controls are in spatial observability and cost control.
  • A local engine for development, normally DuckDB, so iteration does not bill per query — see DuckDB spatial extension integration.

Architecture context: what moves when the engine changes

The dbt project structure is unchanged — staging validates, intermediate joins, marts serve. What changes is which layer carries the tuning, and which levers exist at all.

Tuning levers available on PostGIS compared with warehouse-native spatial engines Two columns of levers. The PostGIS column lists a GiST index, ST_Subdivide on polygons, ANALYZE statistics, work_mem and parallel workers. The warehouse-native column lists clustering on the spatial column, partition pruning by date, S2 or H3 grid keys, materialization choice and slot or warehouse sizing. Arrows connect the equivalent pairs, showing that clustering replaces indexing and grid keys replace subdivision. PostGIS levers CREATE INDEX … USING GIST ST_Subdivide(geom, 256) ANALYZE · work_mem parallel workers warehouse-native levers CLUSTER BY (geog) S2 / H3 grid key join partition pruning by date slot / warehouse size Every lever has an equivalent — but none of them is created in a post-hook after the load.

The consequence for dbt is concrete: the post-hook that creates a GiST index has no counterpart. Physical layout on these engines is declared in the model’s config() block and applied by the warehouse as it writes the table. That is better in one respect — it cannot drift out of sync with the model — and worse in another, because you cannot add it to an existing table without a rewrite.

Engine comparison

Capability BigQuery GIS Snowflake Redshift PostGIS (reference)
Spatial types GEOGRAPHY only GEOGRAPHY and GEOMETRY GEOMETRY and GEOGRAPHY geometry and geography
Coordinate systems WGS84 only, spherical edges 4326 for geography; any SRID for geometry SRID-tagged geometry any SRID
User-managed index none none none GiST, SP-GiST, BRIN
Physical tuning clustering on the geography column clustering keys sort keys and distribution style indexes and subdivision
Predicate acceleration S2 coverings, applied automatically automatic pruning from clustering zone maps explicit index scan
Distance semantics geodesic metres geodesic for geography, planar for geometry both, by type both, by type
Cost driver bytes scanned warehouse seconds cluster time CPU and I/O on your own server
dbt materializations table, view, incremental all, plus dynamic tables all all

The rows that cause real incidents are the coordinate-system row and the distance-semantics row. BigQuery has no SRID: every GEOGRAPHY is WGS84 and every edge is a geodesic on a sphere. Load a dataset in a projected CRS and BigQuery will not warn you — it will interpret the easting and northing as longitude and latitude, and produce results that look like data rather than errors. The staging layer therefore has a harder job on BigQuery than on PostGIS, and the governance rules in CRS governance policy become load-bearing rather than advisory.

Configuration walkthrough

A BigQuery spatial model declares its physical layout inline; there is nothing to add afterwards.

sql
-- models/marts/mart_zone_activity.sql
{{ config(
    materialized = 'table',
    partition_by = {'field': 'activity_date', 'data_type': 'date'},
    cluster_by = ['zone_geog']
) }}

select
    activity_date,
    zone_id,
    any_value(zone_geog) as zone_geog,
    count(*) as ping_count
from {{ ref('int_pings_zoned') }}
group by activity_date, zone_id

Clustering on a GEOGRAPHY column is meaningful: BigQuery orders the data by an S2 space-filling curve so that spatially near rows land in the same blocks, and a later spatial predicate reads fewer of them. Combine it with date partitioning and a spatial mart usually reads a small fraction of the table.

Snowflake expresses the same intent with a clustering key, and adds a genuinely different option — a search optimization service entry for point lookups:

sql
{{ config(
    materialized = 'table',
    cluster_by = ['to_geography(zone_geog)'],
    post_hook = "alter table {{ this }} add search optimization on geo(zone_geog)"
) }}

Redshift’s lever is the sort key, declared through the adapter’s sort config, and it works on the same principle: co-locate rows that will be scanned together.

sql
{{ config(materialized = 'table', sort = ['zone_id'], dist = 'zone_id') }}

Keep the profile itself boring. Everything spatial belongs in model configuration, so switching targets does not rewrite the project:

yaml
# profiles.yml
dbt_geospatial:
  target: bq_dev
  outputs:
    bq_dev:
      type: bigquery
      method: service-account
      project: "{{ env_var('GCP_PROJECT') }}"
      dataset: "{{ env_var('BQ_DATASET', 'analytics_dev') }}"
      location: EU
      threads: 8
      maximum_bytes_billed: 200000000000   # a hard stop before an expensive mistake

maximum_bytes_billed is the single most valuable line in a BigQuery spatial profile. A spatial join written without a bounding predicate will otherwise scan everything, and the first sign will be the bill.

Core implementation: writing SQL that survives an engine change

Function names overlap heavily but not completely, and the differences cluster in exactly the places spatial pipelines depend on. Wrap them once, in a macro, rather than in every model.

sql
-- macros/spatial/geo_dwithin.sql
{% macro geo_dwithin(a, b, metres) %}
  {{ return(adapter.dispatch('geo_dwithin', 'spatial')(a, b, metres)) }}
{% endmacro %}

{% macro default__geo_dwithin(a, b, metres) %}
  st_dwithin({{ a }}, {{ b }}, {{ metres }})
{% endmacro %}

{% macro bigquery__geo_dwithin(a, b, metres) %}
  st_dwithin({{ a }}, {{ b }}, {{ metres }})
{% endmacro %}

{% macro postgres__geo_dwithin(a, b, metres) %}
  st_dwithin({{ a }}::geography, {{ b }}::geography, {{ metres }})
{% endmacro %}

The cast in the Postgres implementation is the whole point of the macro: on PostGIS a geometry column in EPSG:4326 measures ST_DWithin in degrees, while BigQuery’s GEOGRAPHY always measures in metres. Without the cast, the same model silently uses two different radii on two engines. The dispatch mechanism itself is covered in cross-engine UDF portability.

Three more differences worth wrapping the same way:

  • Validity. PostGIS has ST_IsValid and ST_MakeValid; BigQuery has neither, because its GEOGRAPHY constructor rejects invalid input at load time. On BigQuery the equivalent check runs at ingestion, using SAFE.ST_GEOGFROMTEXT and a null test.
  • Geometry construction from text. ST_GeomFromText on PostGIS, ST_GEOGFROMTEXT on BigQuery, TO_GEOGRAPHY on Snowflake — same intent, three spellings, and different behaviour on malformed input.
  • Area and length units. Geodesic square metres on BigQuery regardless of location; on PostGIS, whatever the SRID’s units are, which for 4326 is square degrees and is almost never what anyone wants.
Planar versus geodesic edges between two points, and why area answers differ Two points at the same latitude are connected in two ways. A planar straight line in projected space runs directly between them, while a geodesic great-circle arc bows toward the pole. The enclosed polygons therefore differ, and a table beneath records that BigQuery always uses the geodesic interpretation while PostGIS geometry uses the planar one, producing different area and distance results for identical input coordinates. planar edge · PostGIS geometry geodesic edge · BigQuery GEOGRAPHY same two coordinates, two different lines what the difference costs you a long east–west boundary areas disagree a point near that boundary assignment flips a CI check on DuckDB passes anyway densify long edges to make the two agree The divergence is largest for long edges at high latitude — and invisible on a city-scale test extent.

Validation and testing

The tests that matter on warehouse engines differ from the PostGIS set, because the failure modes differ.

yaml
models:
  - name: stg_zones
    columns:
      - name: zone_geog
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: "st_area(zone_geog) between 1000 and 5000000000"
              config:
                where: "zone_geog is not null"

An area range test does on BigQuery what an SRID assertion does on PostGIS: it catches coordinates that were interpreted as longitude and latitude when they were actually eastings and northings. Projected coordinates read as degrees produce areas that are absurd by many orders of magnitude, so a loose range with the right units catches the whole class of mistake.

Add a null-after-parse test at the load boundary, since BigQuery’s safe constructors return null rather than raising:

sql
-- tests/assert_geography_parsed.sql
select count(*) as unparsed
from {{ ref('stg_zones') }}
where zone_geog is null
having count(*) > 0

Finally, guard the cost. A dbt test can assert against the warehouse’s own metadata:

sql
-- tests/assert_spatial_model_bytes_bounded.sql
select job_id, total_bytes_processed
from `region-eu`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
where creation_time > timestamp_sub(current_timestamp(), interval 1 day)
  and statement_type = 'CREATE_TABLE_AS_SELECT'
  and total_bytes_processed > 5e11

One more test earns its place on any engine without an SRID column: a bounding-extent assertion per source. Every real dataset has a known footprint — a city, a country, a service territory — and a row outside it is either a data error or a coordinate-order mistake, the classic latitude-and-longitude swap that produces a point in the Indian Ocean. Expressing the expected extent as a constant in the test makes both failures loud on the day they arrive rather than on the day someone opens a map.

sql
-- tests/assert_zones_within_expected_extent.sql
select zone_id
from {{ ref('stg_zones') }}
where not st_within(
    zone_geog,
    st_geogfromtext('POLYGON((5.8 47.2, 15.1 47.2, 15.1 55.1, 5.8 55.1, 5.8 47.2))')
)

A note on Databricks, which sits slightly outside this comparison: its spatial support has historically come from Apache Sedona or the built-in H3 functions rather than from a native geometry type, so a dbt project targeting it looks closer to the grid-key approach than to the type-driven approach used on the three engines above. The pipeline shape is the same — validate, join, serve — but the portable layer has to be the grid key rather than the geometry column, because the geometry representation itself differs.

Advanced patterns

Join on grid keys, not geometry, when the engine has no index. An S2 or H3 cell id turns the first pass of a spatial join into an equality join the warehouse can hash and prune, with geometry used only to resolve boundary cells. On BigQuery this routinely turns a query that scans a whole table into one that scans a fraction of it; the macro layer is in discrete global grid macros.

Bytes scanned by the same spatial join under three physical layouts Three horizontal bars showing bytes scanned for one join. An unclustered table scans the full 1.8 terabytes. Adding date partitioning and geography clustering reduces it to 240 gigabytes. Adding a grid-key equality join in front of the geometry predicate reduces it further to 31 gigabytes. A note explains that on consumption pricing the layout, not the SQL, is the cost. no clustering, no partition 1.8 TB partitioned by date, clustered on geography 240 GB plus a grid-key equality join before the geometry test 31 GB Identical SQL, identical results — the layout is the invoice.

Materialize the expensive geometry once and carry keys downstream. Bytes scanned is the cost driver, and geometry columns are wide. A mart that stores zone_id and joins the shape only when a map needs it is cheaper on every query than one that carries geometry everywhere — the serving patterns in serving spatial data to consumers develop this.

Let the warehouse do the clustering, but verify it happened. Clustering is declared, not enforced; a table written by a path that ignores the config is silently unclustered. Query the information schema after the build and assert the clustering columns are what the model declared.

Keep DuckDB as the development engine, but never as the correctness oracle. DuckDB uses planar geometry, so a model that passes locally can still be wrong on BigQuery’s spherical edges. Run parity checks on a small, representative extent against the real engine before release.

Troubleshooting

Symptom Root cause Fix
Areas in the billions of square metres for a city block Projected coordinates loaded as GEOGRAPHY and read as degrees Transform to WGS84 before load; add the area-range test above
ST_DWithin returns nothing on BigQuery, everything on PostGIS Radius in metres on one engine, degrees on the other Wrap the predicate in a dispatched macro that casts to geography on PostGIS
Query cost jumped after adding one column Geometry column added to a wide mart, so every query now scans it Move geometry to a narrow lookup model keyed by id
Clustering appears to do nothing Predicate is not sargable against the clustered column, or the table was written by a non-clustered path Confirm in INFORMATION_SCHEMA; re-create the table through dbt
Results differ between the CI run and production CI runs DuckDB planar, production runs spherical GEOGRAPHY Add a parity test on a fixed extent; densify long edges before comparing
ST_MakeValid not found Function does not exist on the target engine Validate at the load boundary instead; the constructor rejects invalid input

FAQ

Does BigQuery really have no way to store a projected coordinate system?

Correct — GEOGRAPHY is WGS84 with geodesic edges and there is no SRID parameter. If a projected CRS is required for an analysis, either compute in a projected space upstream and store the results as numbers, or accept geodesic metres, which is what most distance and area work actually wants. Storing projected coordinates in a GEOGRAPHY column is the mistake to avoid; it is accepted silently and every downstream number is wrong.

Is clustering on a geography column actually useful, or is it cosmetic?

It is useful, and measurably so on tables above a few gigabytes. BigQuery orders clustered rows along an S2 curve, so spatially adjacent rows share blocks and a bounded spatial predicate reads fewer of them. The gain disappears if the query has no spatial or partition predicate at all, which is why clustering and a date partition are usually declared together.

Can I keep one dbt project that runs on both PostGIS and BigQuery?

Yes, if every spatial call goes through a dispatched macro and the models avoid engine-specific SQL such as distinct on. The parts that resist portability are validity handling and unit semantics, so wrap those first. Expect the project to be portable and the results to need a parity test — spherical and planar engines can agree to within a tolerance, not exactly.

Where do spatial indexes go on these engines?

Nowhere — there is no user-managed spatial index on BigQuery, Snowflake or Redshift. The acceleration comes from how the data is laid out (clustering, sort keys, partitions) and from grid-key joins you construct yourself. A dbt project ported from PostGIS should delete its index post-hooks rather than translate them.

How do I stop an expensive spatial query before it runs?

Set maximum_bytes_billed in the BigQuery profile, a statement timeout and warehouse size ceiling on Snowflake, and a query monitoring rule on Redshift. Then add the information-schema test above so a model that starts scanning more than expected fails the build rather than the budget.

Up: Part of Core Fundamentals & Architecture for dbt Geospatial.

Explore this section