Estimating storage cost of geometry columns

This page measures what geometry actually costs to store: a per-vertex estimate you can apply before loading anything, an ST_MemSize accounting of what landed, the compression and TOAST effects that make the on-disk number differ from both, and a monthly snapshot model that turns growth into a forecast.

When to use this approach

  • Before ingesting a new source. A vendor’s “1.2 million features” says nothing about size; the same count can be 200 MB or 40 GB depending on vertex density.
  • When storage is growing and row counts are not. That is a geometry problem by definition, and the accounting here locates it.
  • When deciding what a mart may carry. Geometry duplicated across five marts costs five times, which is the argument for keys-plus-lookup described in serving spatial data to consumers.

Prerequisites

  • PostGIS with ST_MemSize and ST_NPoints, or an equivalent size function on your engine.
  • Access to table-size metadata: pg_total_relation_size on PostGIS, INFORMATION_SCHEMA.TABLE_STORAGE on BigQuery, TABLE_STORAGE_METRICS on Snowflake.
  • A schema for snapshot history, as set up in spatial observability and cost control.
  • Rough vertex counts for the source, obtainable from a sample rather than the whole file.

Step-by-step instructions

1. Estimate before you load

PostGIS stores a geometry as a small header plus one coordinate pair per vertex, each ordinate a double. The arithmetic is simple enough to do on a napkin and accurate enough to plan with.

text
bytes ≈ header (about 8–12) + 16 × vertices          (2D)
bytes ≈ header + 24 × vertices                       (3D, Z)
sql
-- Estimate from a sample of the source before committing to a load
select
    count(*)                                   as sample_features,
    round(avg(st_npoints(geom)))               as avg_vertices,
    round(avg(st_memsize(geom)))               as avg_bytes,
    round(avg(st_memsize(geom)) * 1200000 / 1024.0 / 1024 / 1024, 2) as projected_gb_for_1_2m
from {{ ref('stg_sample_features') }}

Verify the estimate against the formula — if avg_bytes is far from 12 + 16 × avg_vertices, the geometry is likely 3D or a collection type, both of which change the arithmetic.

Storage per feature by geometry type, at the same feature count Four geometry types compared at one million features each. Points take about twenty-eight bytes per feature and twenty-eight megabytes in total. Simplified administrative polygons at roughly two hundred vertices take three point two kilobytes each and three gigabytes in total. Full-detail boundaries at four thousand vertices take sixty-four kilobytes each and sixty-one gigabytes. A note records that the feature count is identical in every case. one million features, four shapes of the same data points · 1 vertex 28 B/feature · 28 MB total simple polygons · 40 vertices 652 B/feature · 622 MB total simplified boundaries · 200 vertices 3.2 KB/feature · 3.0 GB total full-detail boundaries · 4,000 vertices 64 KB/feature · 61 GB total Row counts are identical across all four. Only the vertices differ.

2. Account for what actually landed

ST_MemSize reports the uncompressed in-memory size, which is the right number for reasoning about geometry but not the number on the disk. Measure both and keep the ratio.

sql
-- models/ops/int_geometry_storage.sql
{{ config(materialized = 'table') }}

with per_table as (
    select
        'mart_zones'                                  as table_name,
        count(*)                                      as feature_count,
        sum(st_memsize(geom))                         as geometry_bytes_uncompressed,
        sum(st_npoints(geom))                         as total_vertices,
        max(st_npoints(geom))                         as max_vertices
    from {{ ref('mart_zones') }}
)

select
    p.*,
    pg_total_relation_size('analytics.mart_zones')                       as on_disk_bytes,
    round((p.geometry_bytes_uncompressed::numeric
           / nullif(pg_total_relation_size('analytics.mart_zones'), 0)), 3) as geometry_share_of_table
from per_table p

Verify the ratio is plausible — on PostGIS with default settings, expect the on-disk figure to be somewhere between half and twice the uncompressed geometry, depending on how well the coordinates compress and how much index and row overhead the table carries.

3. Understand where the number moves

Three effects make the on-disk size differ from the arithmetic, and knowing which one is acting tells you which lever to pull.

Effect Direction What it means
TOAST compression smaller, sometimes much Large geometry is compressed and stored out of line; repetitive coordinates compress well
TOAST overhead larger for medium geometry A value just over the threshold pays a pointer plus a chunk row
Index size larger A GiST index on a geometry column is typically 10–30 per cent of the column’s size
Bloat from updates larger, and grows Updated rows leave dead tuples until vacuum reclaims them
Coordinate precision either Rounded coordinates compress dramatically better than full-precision ones

The last row is the actionable one. Reducing stored precision to what the use case needs — 11 cm rather than a fraction of a micron — reduces entropy in the coordinate stream, and the compressor then does the rest. This is separate from simplification, which removes vertices; precision reduction keeps every vertex and shortens each one.

sql
-- Measure the compression effect of precision reduction on your own data
select
    pg_size_pretty(sum(st_memsize(geom))::bigint)                                  as raw,
    pg_size_pretty(sum(st_memsize(st_reduceprecision(geom, 0.000001)))::bigint)    as reduced
from {{ ref('mart_zones') }};
What a geometry table's on-disk bytes are actually made of A single table's storage broken into layers: raw coordinate bytes are the largest share, then the GiST index at roughly a fifth, then row and page overhead, then dead tuples awaiting vacuum, with TOAST compression shown as a reduction applied to the coordinate layer. Labels note that only two of the layers respond to precision reduction and simplification. one 42 GB geometry table, by layer coordinate bytes after compression · 24 GB GiST index · 8 GB rows · 6 GB dead responds to simplification and precision reduction shrinks with the column it indexes responds only to vacuum Halving the coordinate layer takes the index with it — which is why the geometry, not the table, is the unit to measure.

4. Snapshot monthly and forecast

sql
-- models/ops/snap_geometry_storage.sql
{{ config(materialized = 'incremental', unique_key = ['snapshot_month', 'table_name']) }}

select
    date_trunc('month', current_date)::date as snapshot_month,
    table_name,
    feature_count,
    geometry_bytes_uncompressed,
    on_disk_bytes,
    total_vertices
from {{ ref('int_geometry_storage') }}
{% if is_incremental() %}
where date_trunc('month', current_date)::date
      not in (select snapshot_month from {{ this }})
{% endif %}
sql
-- analyses/storage_forecast.sql
select
    table_name,
    max(on_disk_bytes) filter (where snapshot_month = (select max(snapshot_month) from {{ ref('snap_geometry_storage') }}))
        as current_bytes,
    round(avg(monthly_growth), 3) as avg_monthly_growth_ratio,
    round(max(on_disk_bytes) * power(avg(monthly_growth), 12) / 1024^4, 2) as projected_tb_in_12_months
from (
    select
        table_name, snapshot_month, on_disk_bytes,
        on_disk_bytes::numeric / nullif(lag(on_disk_bytes) over (
            partition by table_name order by snapshot_month), 0) as monthly_growth
    from {{ ref('snap_geometry_storage') }}
) g
group by table_name
order by projected_tb_in_12_months desc

Verify the forecast against the last twelve months before trusting it forward: a table that grew steadily is forecastable, one that jumped on a single reload is not, and the difference is visible in the growth ratios.

Two storage histories that look similar today and forecast very differently Two series of monthly storage snapshots ending at the same size. One grows smoothly at about six percent per month and extrapolates cleanly to roughly double in a year. The other is flat except for two large steps caused by reloads, so extrapolating its average growth would be misleading. Annotations mark which series is forecastable and which needs its steps explained instead. reload reload steady growth step changes on-disk bytes twelve monthly snapshots → Same size today. Only one of them can be extrapolated — the other needs its steps explained.

5. Set a budget and test it

yaml
models:
  - name: int_geometry_storage
    columns:
      - name: geometry_share_of_table
        tests:
          - dbt_utils.accepted_range:
              min_value: 0
              max_value: 0.9
sql
-- tests/assert_storage_budget.sql
select table_name, on_disk_bytes
from {{ ref('int_geometry_storage') }}
where on_disk_bytes > {{ var('table_storage_budget_bytes') }}

A budget test is worth more than a dashboard because it makes the conversation happen at the right time: when a model first exceeds the agreed size, rather than at the quarterly cost review.

Configuration reference

Parameter Where Typical value Note
bytes per 2D vertex estimation formula 16 Two 8-byte doubles; 24 with a Z ordinate
geometry header estimation formula 8–12 bytes Small, but not zero for point-heavy tables
precision grid ST_ReducePrecision 0.000001 Roughly 11 cm; large compression gains
GiST index overhead planning 10–30% of column size Budget for it; it is not free
snapshot cadence snapshot model monthly Daily adds noise without adding signal
table_storage_budget_bytes project var per table The number that turns a chart into a decision

Gotchas & edge cases

  • ST_MemSize is not disk size. It ignores compression, TOAST overhead, indexes and dead tuples. Use it to compare geometry against geometry, and table-size functions to compare against a bill.
  • Bloat masquerades as growth. A table that is updated frequently accumulates dead tuples; measure after a vacuum before concluding the data grew.
  • 3D geometry costs 50 per cent more per vertex and is often carried accidentally, because the source had a Z ordinate nobody uses. Check with ST_NDims and drop it in staging if so.
  • Collections multiply headers. A MULTIPOLYGON of 200 small parts carries 200 sub-geometry headers as well as its vertices; splitting or dissolving can reduce size materially.
  • Copies are the real cost. One geometry column carried through five marts costs five times over, and the fix is architectural rather than an encoding trick.

FAQ

Is it worth storing geometry in a compressed binary format instead?

Rarely inside a database — the engine already compresses large values, and a custom encoding gives up the ability to query the geometry. It is a reasonable choice for archival copies in object storage, where GeoParquet compresses well and is still queryable by tools like DuckDB, as covered in reading GeoParquet from object storage with DuckDB.

How much does reducing coordinate precision really save?

On typical boundary data, a third of the uncompressed size and often more after compression, because rounded coordinates repeat digits that the compressor can exploit. The saving is largest for dense geometry with many nearby vertices and negligible for point tables, where the header dominates.

Should the geometry live in the mart or in a lookup table?

In a lookup keyed by feature id, referenced by the marts, whenever more than one mart needs it. The marts then carry a key, aggregate cheaply, and stay narrow; the geometry is stored once and joined only when something needs to draw it. The exception is a mart whose sole purpose is rendering, which should carry the simplified serving geometry directly.

Do these numbers transfer to BigQuery or Snowflake?

The vertex arithmetic does, since a coordinate pair is two doubles everywhere. The storage accounting does not: both engines compress columnar storage aggressively and report logical rather than physical bytes, so use their own storage metadata views and treat ST_MemSize-style figures as a relative measure between tables rather than a bill estimate.

Up: Part of Spatial Observability & Cost Control.