Snowflake GEOGRAPHY vs PostGIS geometry in dbt

This page compares the two spatial type systems a dbt project meets when it moves between Snowflake and PostGIS, shows which column type to declare for which workload, and gives the parity tests that prove the two engines agree before you rely on either.

When to use this approach

  • You are porting a PostGIS dbt project to Snowflake and need to know which models will return different numbers rather than merely different SQL.
  • You are starting on Snowflake and must pick a column type. Snowflake offers both GEOGRAPHY and GEOMETRY, and the choice determines your units, your edge semantics, and whether reprojection is available at all.
  • You run both engines — PostGIS for serving, Snowflake for analytics — and need the two to agree within a stated tolerance. The wider engine comparison is in warehouse-native GIS adapters.

Prerequisites

  • dbt-snowflake ≥ 1.7 and a role with CREATE TABLE on the target schema.
  • A warehouse sized for the comparison queries; parity tests scan both sides fully.
  • PostGIS 3.1+ on the other side, with the same source data loaded from the same file.
  • dbt-utils for the comparison tests.

Step-by-step instructions

1. Decide the column type before writing a model

The decision is not stylistic. It fixes the unit system and the meaning of every edge in your data.

Question Snowflake GEOGRAPHY Snowflake GEOMETRY PostGIS geometry
Coordinate system WGS84 only any SRID, default 0 any SRID
Edge between two points geodesic (great circle) straight line in the plane straight line in the plane
ST_DISTANCE unit metres SRID units SRID units (degrees for 4326)
ST_AREA unit square metres square SRID units square SRID units
Reprojection not available ST_TRANSFORM ST_Transform
Validity repair on parse, via TRY_TO_GEOGRAPHY ST_MAKEVALID ST_MakeValid

The row that decides most projects is reprojection. GEOGRAPHY cannot reproject, so if a downstream consumer needs a national grid projection, either store GEOMETRY with that SRID or compute the projected values upstream. The row that causes most bugs is the distance unit: a PostGIS model written against geometry in EPSG:4326 measures in degrees, and the same numeric literal on Snowflake GEOGRAPHY means metres.

Choosing a Snowflake spatial column type from the workload's requirements A decision path. The first question asks whether the workload needs a specific projected coordinate system: yes leads to the GEOMETRY type with an explicit SRID. If no, the second question asks whether distances and areas should be true earth measurements: yes leads to GEOGRAPHY in metres, no leads to GEOMETRY in EPSG 4326 for compatibility with a planar PostGIS model. Need a specific projected CRS? GEOMETRY with that SRID ST_TRANSFORM available Want true earth metres and areas? GEOGRAPHY metres · geodesic edges GEOMETRY, SRID 4326 matches planar PostGIS exactly yes no yes no The type is a contract about units and edges — not a storage detail to revisit later.

2. Write the staging model against the chosen type

sql
-- models/staging/stg_zones.sql   (Snowflake, GEOGRAPHY variant)
{{ config(materialized = 'table', cluster_by = ['zone_id']) }}

select
    zone_id,
    zone_name,
    try_to_geography(geom_wkt) as zone_geog
from {{ source('ops', 'zones_raw') }}
where try_to_geography(geom_wkt) is not null

TRY_TO_GEOGRAPHY is the counterpart of PostGIS’s validate-then-quarantine step: it returns null instead of raising, so one malformed row cannot abort the build. Count the nulls and report them rather than dropping them silently — the pattern is the same one used in quarantining invalid geometries in staging.

Verify that parsing did not lose rows:

sql
select
    count(*) as raw_rows,
    count(try_to_geography(geom_wkt)) as parsed_rows,
    count(*) - count(try_to_geography(geom_wkt)) as rejected
from {{ source('ops', 'zones_raw') }};

3. Express distance predicates through a macro

The same model must not mean two different radii on two engines. Wrap it once.

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

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

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

Verify the macro compiles to the intended SQL on each target:

bash
dbt compile --select int_nearby_zones --target snowflake_dev
dbt compile --select int_nearby_zones --target postgres_dev
grep -n "st_dwithin" target/compiled/dbt_geospatial/models/intermediate/int_nearby_zones.sql

4. Prove the two engines agree

Parity is a measurement, not an assumption. Export a fixed sample from both engines and compare with a tolerance that reflects the geodesic-versus-planar difference.

sql
-- models/intermediate/int_engine_area_parity.sql
select
    s.zone_id,
    s.area_sqm       as snowflake_area,
    p.area_sqm       as postgis_area,
    abs(s.area_sqm - p.area_sqm) / nullif(s.area_sqm, 0) as relative_diff
from {{ ref('stg_zone_areas_snowflake') }} as s
join {{ source('parity', 'zone_areas_postgis') }} as p using (zone_id)
yaml
models:
  - name: int_engine_area_parity
    columns:
      - name: relative_diff
        tests:
          - dbt_utils.accepted_range:
              max_value: 0.01

A one per cent tolerance is generous for city-scale polygons and too tight for country-scale ones with long east–west edges. Set it from the data: measure the observed spread first, then set the threshold just above it, so the test catches a regression rather than the known difference.

Relative area difference between geodesic and planar engines by polygon extent A rising curve plots the relative difference in computed area between a geodesic engine and a planar engine against the east-west extent of the polygon. City blocks under one kilometre show a difference near zero. Metropolitan areas of tens of kilometres show a fraction of a percent. Regional polygons spanning hundreds of kilometres exceed several percent. A shaded band marks a suggested one percent tolerance and where it stops being appropriate. 1% tolerance band city block metro area region relative area difference Set the parity threshold from the extent of your own polygons — a fixed 1% fails on regional data.

5. Cluster, and confirm the clustering is real

sql
{{ config(
    materialized = 'table',
    cluster_by = ['zone_id', 'activity_date']
) }}

Verify with Snowflake’s own clustering metrics rather than trusting the config:

sql
select system$clustering_information('analytics.mart_zone_daily_activity', '(zone_id, activity_date)');
-- Expect a low average_depth; a high value means the table needs reclustering

The clustering check closes the loop on the port: everything else in this sequence is about numbers agreeing, and this one is about the physical layout actually existing. A declared clustering key that Snowflake has not yet applied looks identical in the model file and behaves like an unclustered table in every query.

What carries over unchanged when porting a PostGIS dbt project to Snowflake, and what must be rewritten Three groups. Carries over unchanged: model structure, ref and source graph, generic tests, and incremental strategies. Needs a wrapper: distance and area predicates, validity handling, and geometry construction from text. Must be deleted or replaced: index post-hooks, ANALYZE calls, ST_Subdivide tuning and any reliance on planar edges. carries over unchanged model layering and DAG ref() and source() graph generic tests and schema YAML incremental strategies needs a dispatched wrapper distance and area predicates validity handling on parse construction from WKT unit conversions delete rather than translate GiST index post-hooks ANALYZE calls ST_Subdivide tuning assumptions about planar edges

Configuration reference

Setting Where Values Note
cluster_by model config column list Snowflake reclusters automatically; the config only declares the key
TRY_TO_GEOGRAPHY staging SQL Returns null on malformed input instead of failing the build
ST_TRANSFORM model SQL GEOMETRY only Unavailable on GEOGRAPHY; plan the type accordingly
query_tag profile or model config any string Attributes warehouse credits to a model in the query history
warehouse model config warehouse name Route heavy spatial joins to a larger warehouse without resizing everything
tolerance parity test 0.001 – 0.05 Derive from polygon extent, not from a default

Gotchas & edge cases

  • GEOGRAPHY silently accepts projected coordinates the same way BigQuery does, and produces areas that are wrong by orders of magnitude. Add a range test.
  • PostGIS geography and Snowflake GEOGRAPHY are close but not identical on edge cases such as antimeridian crossing; test rather than assume.
  • ST_AREA on a GEOMETRY in SRID 4326 returns square degrees on both engines — a number with no physical meaning that nonetheless looks plausible in a dashboard.
  • Clustering keys with high cardinality cost more than they save. A geography column clusters usefully; a raw point identifier does not.
  • Warehouse size changes plan shape, not correctness. If a spatial join gets faster on a larger warehouse but returns different counts, the difference is in the SQL, not the compute.

FAQ

Should a ported PostGIS project use GEOGRAPHY or GEOMETRY on Snowflake?

If the PostGIS models use geometry with a projected SRID and depend on ST_Transform, port to GEOMETRY and keep the SRID — the SQL and the numbers both carry over. If they cast to geography for distance work, port to GEOGRAPHY and delete the casts. Mixing the two across a project is what produces unit bugs.

Why do my areas differ by a fraction of a percent after the port?

Because geodesic and planar engines integrate area differently, and the gap grows with the polygon’s extent. A fraction of a percent on metro-scale polygons is expected, not a defect. What is not expected is a difference of several orders of magnitude, which means one side is reading degrees as metres.

Is there a Snowflake equivalent of a GiST index?

No. The equivalents are clustering keys and, for point-lookup workloads, the search optimization service. Both are declared rather than built, and both are verified from Snowflake’s own metadata rather than from a query plan — which is why the verification step above uses SYSTEM$CLUSTERING_INFORMATION.

Can I run the same dbt tests on both engines?

Generic tests, yes — not_null, unique, accepted_range and relationship tests are engine-agnostic. Spatial tests need the dispatch treatment, because the function names and unit semantics differ; the pattern is described in cross-engine UDF portability.

Up: Part of Warehouse-Native GIS Adapters.