Densifying and segmentizing geometries in dbt

This page adds vertices to geometry on purpose — the opposite of simplification — because a long straight edge is only straight in the coordinate system it was drawn in, and every reprojection or geodesic comparison of that edge is wrong until it has intermediate points.

When to use this approach

  • A reprojected shape has the wrong outline. A boundary defined by two distant points becomes a straight line in the target projection, when the true path curves.
  • A planar and a geodesic engine disagree on a shape. This is the fix that makes them agree, as noted in warehouse-native GIS adapters.
  • A buffer or intersection along a long edge looks angular. Densifying before the operation gives it something to work with.

Prerequisites

  • ST_Segmentize on the engine, plus ST_Segmentize(geography, …) if you need geodesic segmentation.
  • A projected CRS in which to express the segment length, or geography for metres directly.
  • Storage headroom: densification multiplies vertices, and vertices are the geometry’s size, as costed in estimating storage cost of geometry columns.

Step-by-step instructions

1. Recognise the problem before applying the fix

sql
-- analyses/long_edge_audit.sql
with segments as (
    select
        zone_id,
        (st_dumpsegments(geom)).geom as seg
    from {{ ref('stg_zones') }}
)
select
    zone_id,
    count(*)                                          as segment_count,
    round(max(st_length(seg::geography))::numeric, 0) as longest_segment_m
from segments
group by zone_id
having max(st_length(seg::geography)) > {{ var('max_edge_length_metres', 5000) }}
order by longest_segment_m desc

An edge of a few hundred metres is harmless in any projection. An edge of fifty kilometres is where planar and geodesic interpretations visibly part company, and where a reprojection will produce a straight line that the real boundary does not follow.

Verify the audit finds the shapes you expect — administrative outer boundaries and coastline simplifications usually dominate the list, while urban parcels never appear.

What happens to a long edge when it is reprojected without intermediate vertices On the left, a shape with one very long edge drawn between two points in the source coordinate system. In the middle, the same shape reprojected: with only two points the edge remains a straight line and cuts across territory the true boundary curves around. On the right, the shape densified first, so the reprojected edge follows the curve correctly. The area difference between the middle and right versions is shaded. source · two points one edge, no intermediate vertices reprojected as-is straight where the truth curves densified first, then reprojected vertices give the projection something to bend Reprojection moves points, not lines — the line between them is redrawn straight.

2. Densify before the transformation, not after

Order matters and only one order is correct: add vertices in the source CRS, then transform. Densifying afterwards adds points to a line that is already wrong.

sql
-- models/intermediate/int_zones_densified.sql
{{ config(materialized = 'table') }}

select
    zone_id,
    -- segmentize in geography so the interval is real metres, then transform
    st_transform(
        st_segmentize(geom::geography, {{ var('densify_interval_metres', 1000) }})::geometry,
        {{ var('target_srid') }}
    ) as geom,
    st_npoints(geom) as vertices_before,
    st_npoints(st_segmentize(geom::geography, {{ var('densify_interval_metres', 1000) }})::geometry) as vertices_after
from {{ ref('stg_zones') }}

ST_Segmentize on geography inserts points along the great-circle path at the given interval, which is what makes the subsequent transform follow the true curve. The same function on geometry interpolates along a straight line in whatever units the SRID uses — useful for a different purpose, and wrong for this one.

Verify the vertex growth is proportionate:

sql
select
    sum(vertices_before) as before,
    sum(vertices_after)  as after,
    round(sum(vertices_after)::numeric / nullif(sum(vertices_before), 0), 2) as growth_factor
from {{ ref('int_zones_densified') }};
-- A factor of 2–5 is typical; 50 means the interval is far too small

3. Choose the interval from the error you will tolerate

The deviation between a straight chord and the true curve grows with the square of the segment length. That relationship gives an interval rather than a guess.

Segment interval Maximum chord deviation Suitable for
100 km ~200 m nothing precise
10 km ~2 m continental overviews
1 km ~2 cm almost every analytical use
100 m ~0.2 mm rendering at street zoom
Chord deviation against segment interval, and why halving the interval quarters the error A curve of maximum deviation between a straight chord and the true curved path, plotted against segment interval. The relationship is quadratic, so the curve falls steeply: at one hundred kilometres the deviation is about two hundred metres, at ten kilometres about two metres, and at one kilometre about two centimetres. Markers label those three points and a note states that each tenfold reduction in interval reduces error a hundredfold. 100 km → ~200 m off 10 km → ~2 m off 1 km → ~2 cm off deviation segment interval → The relationship is quadratic: ten times finer is a hundred times more accurate.

Verify the achieved deviation directly rather than trusting the table:

sql
select
    max(st_distance(
        st_closestpoint(d.geom, st_pointn(st_boundary(o.geom), 2)),
        st_pointn(st_boundary(o.geom), 2)
    )) as max_deviation_m
from {{ ref('int_zones_densified') }} d
join {{ ref('stg_zones') }} o using (zone_id);

4. Densify only what needs it

Densifying every geometry to satisfy a handful of long edges multiplies storage across the whole table for no benefit.

sql
-- models/intermediate/int_zones_prepared.sql
with flagged as (
    select
        zone_id,
        geom,
        (select max(st_length(s.geom::geography))
         from (select (st_dumpsegments(geom)).geom) s) as longest_segment_m
    from {{ ref('stg_zones') }}
)

select
    zone_id,
    case
        when longest_segment_m > {{ var('max_edge_length_metres', 5000) }}
        then st_segmentize(geom::geography, {{ var('densify_interval_metres', 1000) }})::geometry
        else geom
    end as geom,
    longest_segment_m > {{ var('max_edge_length_metres', 5000) }} as was_densified
from flagged

Verify the selective approach saved what you hoped:

sql
select
    was_densified,
    count(*) as zones,
    pg_size_pretty(sum(st_memsize(geom))::bigint) as geometry_size
from {{ ref('int_zones_prepared') }}
group by was_densified;
Storage cost of densifying everything against densifying only long edges Two bars comparing geometry size. Densifying every zone at a one kilometre interval grows the column from three gigabytes to eleven. Densifying only the two percent of zones with edges over five kilometres grows it to three point four gigabytes, while fixing the same reprojection problem. A note records that the selective version is the same correctness for a fraction of the cost. original 3.0 GB densify everything at 1 km 11.0 GB densify only the 2% with long edges 3.4 GB Same correctness for the shapes that needed it, at a seventh of the extra storage.

5. Keep the densified copy out of the storage layer

Densification is a preparation step for a specific operation, not a property of the data. Keep it in an intermediate model that feeds the operation, and let the mart store the original.

sql
-- The transformed output is what persists; the densified intermediate is ephemeral
{{ config(materialized = 'ephemeral') }}

An ephemeral intermediate is inlined into whichever model consumes it, so the densified vertices exist only for the duration of the query — the right trade when the densified copy has no independent consumers.

Verify nothing else depends on the densified model before making it ephemeral:

bash
dbt ls --select int_zones_densified+ --output name

Configuration reference

Parameter Where Typical value Note
densify_interval_metres project var 1000 Chord deviation grows with the square of this
max_edge_length_metres project var 5000 The threshold that decides what gets densified
ST_Segmentize on geography model SQL Follows the great circle; the correct form before reprojection
ST_Segmentize on geometry model SQL Interpolates along a straight line in SRID units
materialization intermediate model ephemeral Densified geometry rarely deserves storage
growth factor check test 2–5× A larger factor means the interval is too small

Gotchas & edge cases

  • Densifying after transforming does nothing useful. The line was already redrawn straight; adding points to it just stores the mistake at higher resolution.
  • ST_Segmentize adds points, never removes them. Applying it to already-dense geometry inflates it for no gain — hence the selective form in step 4.
  • Geography segmentize can be slow on large tables. It computes great-circle interpolations per segment; run it in a filtered intermediate rather than across a whole mart.
  • Simplification undoes it. A pipeline that densifies for correctness and then simplifies for payload has to do them in that order, in different models, or the second discards the first.
  • Antimeridian-crossing edges need care. Segmentizing across the date line can produce points on the wrong side; split such geometry first.

FAQ

Is densification ever needed for small, local data?

Rarely. Within a city, the difference between a chord and the true curve is sub-millimetre, far below any tolerance that matters, so densification adds cost and nothing else. It becomes relevant at tens of kilometres and essential at hundreds.

How does this relate to simplification?

They are inverse operations serving different layers. Densify in the analytical path so transformations and comparisons are correct; simplify in the serving path so payloads are small. A model should not do both, and the serving simplification described in simplifying geometries for map payloads is applied to the original geometry, not to a densified copy.

Does BigQuery need densification?

Not for its own computation — geography operations there are geodesic and treat edges as great circles regardless of vertex count. Densification matters when comparing BigQuery results against a planar engine, where adding vertices is what makes the two interpretations converge.

What interval should I use for rendering rather than analysis?

Tie it to the zoom band, exactly as with simplification tolerances: about one screen pixel of ground distance at the band’s maximum zoom. Densifying finer than a pixel adds bytes the renderer cannot use.

Up: Part of Geometry Transformation Pipelines.