Backfilling geometry columns after a schema change

This page adds a geometry column to a table too large to rebuild: a batched backfill that can stop and resume, index creation timed so it does not slow every batch, and a progress model that tells you where the migration is rather than leaving it to a log file.

When to use this approach

  • A full refresh would exceed the maintenance window. Below that, --full-refresh is simpler and safer; this is for tables where it is not an option.
  • The new column derives from data already in the table. A projected copy, a grid key, a simplified geometry, a centroid — all derivable, all backfillable.
  • The change must be reversible mid-flight. A batched backfill can be paused and the old column kept, which a rebuild cannot. The versioning discipline this fits into is versioning spatial schemas in dbt.

Prerequisites

  • An incremental model with a unique_key, since the backfill writes through the same path.
  • A monotonic or at least orderable key to batch on — an id range or a date.
  • Free storage equal to the new column’s size, plus its index, before you start.
  • Agreement on what consumers see during the migration: a half-populated column is visible unless you hide it.

Step-by-step instructions

1. Add the column without computing it

sql
{{ config(
    materialized = 'incremental',
    unique_key = 'ping_id',
    on_schema_change = 'append_new_columns'
) }}

select
    ping_id,
    trip_id,
    observed_at,
    geom,
    cast(null as text) as h3_cell        -- declared now, populated later
from {{ ref('stg_trip_pings') }}
{% if is_incremental() %}
where observed_at > (select max(observed_at) from {{ this }})
{% endif %}

on_schema_change = 'append_new_columns' is what lets dbt add the column to the existing table instead of failing or rebuilding. New rows arriving from this point can populate it immediately; the history is the part that needs backfilling.

Verify the column exists and the incremental path still runs:

sql
select column_name, data_type
from information_schema.columns
where table_name = 'int_pings_zoned' and column_name = 'h3_cell';
The three populations a table holds during a backfill A table timeline split into three regions. Old rows already backfilled hold the new column populated. A middle region of old rows not yet reached holds nulls. New rows arriving after the schema change hold the column populated by the normal incremental path. A moving boundary marks the backfill's progress through history, and a note says consumers must tolerate the middle region or be shielded from it. backfilled history column populated not yet reached NULL — visible to consumers arriving rows populated on insert backfill boundary, moving right schema change oldest ————————————————— newest either consumers tolerate nulls, or a view hides the unfinished region until it closes

2. Backfill in batches, driven by a range

sql
-- macros/backfill_h3_cell.sql
{% macro backfill_h3_cell(batch_days=7, max_batches=20) %}
  {% for i in range(max_batches) %}
    {% set sql %}
      with target as (
          select min(observed_at::date) as batch_start
          from {{ ref('int_pings_zoned') }}
          where h3_cell is null
      )
      update {{ ref('int_pings_zoned') }} p
      set h3_cell = {{ h3_cell('p.geom') }}
      from target t
      where p.h3_cell is null
        and p.observed_at >= t.batch_start
        and p.observed_at <  t.batch_start + interval '{{ batch_days }} days'
    {% endset %}
    {% set result = run_query(sql) %}
    {{ log("backfill batch " ~ (i + 1) ~ " complete", info=True) }}
  {% endfor %}
{% endmacro %}
bash
dbt run-operation backfill_h3_cell --args '{batch_days: 7, max_batches: 20}'

Batching by a date range rather than by LIMIT matters: an unbounded UPDATE on a large table takes one long transaction, holds locks for its duration, and produces dead tuples equal to the rows it touched. Seven-day batches keep each transaction short and let autovacuum keep pace — the tuning for which is in clustering and vacuuming large PostGIS tables.

Verify progress between runs:

sql
select
    count(*) filter (where h3_cell is null) as remaining,
    count(*)                                as total,
    round(100.0 * count(*) filter (where h3_cell is not null) / count(*), 2) as pct_done,
    min(observed_at) filter (where h3_cell is null) as next_batch_start
from {{ ref('int_pings_zoned') }};

3. Index after the backfill, not before

An index on a column being written to slows every batch and bloats as it goes. Create it once, when the column is complete.

bash
# After the last batch reports zero remaining
psql -c "CREATE INDEX CONCURRENTLY IF NOT EXISTS int_pings_zoned_h3_idx
         ON analytics.int_pings_zoned (h3_cell);"
psql -c "ANALYZE analytics.int_pings_zoned;"

CONCURRENTLY avoids the write lock that a plain CREATE INDEX takes, at the cost of a slower build and a second pass — the right trade on a table that is still receiving inserts.

Verify the index is valid, because a concurrent build that fails leaves an invalid index behind:

sql
select indexrelid::regclass as index_name, indisvalid
from pg_index where indrelid = 'analytics.int_pings_zoned'::regclass;
-- indisvalid must be true; drop and rebuild any index where it is false

4. Make the migration resumable and observable

sql
-- models/ops/int_backfill_progress.sql
{{ config(materialized = 'view') }}

select
    'int_pings_zoned.h3_cell'                            as migration,
    count(*) filter (where h3_cell is null)              as rows_remaining,
    count(*)                                             as rows_total,
    round(100.0 * count(*) filter (where h3_cell is not null) / nullif(count(*), 0), 2) as pct_complete,
    min(observed_at) filter (where h3_cell is null)      as oldest_unfilled,
    max(observed_at) filter (where h3_cell is null)      as newest_unfilled
from {{ ref('int_pings_zoned') }}

Because every batch selects its own start from the data, the operation is idempotent and resumable: interrupt it, run it again, and it continues from wherever it stopped. There is no cursor to keep and nothing to reset after a failure.

Verify resumability deliberately — run five batches, stop, and confirm the next invocation picks up at oldest_unfilled rather than starting over.

One long update compared with batched updates on the same table Two timelines. The single update runs for four hours as one transaction, holding locks throughout, producing dead tuples equal to the whole table, and losing all progress if it fails. The batched version runs twenty short transactions, each releasing locks and allowing autovacuum between them, with progress preserved at every boundary. A failure marker on each shows what is lost. one UPDATE — a single four-hour transaction failure here loses all four hours twenty batches — short transactions, preserved progress failure here loses one batch; the rest is done

5. Retire the old column deliberately

Once the new column is complete and consumers have moved, the old one is dead weight — but dropping it is the irreversible step, so it gets its own change.

sql
-- Only after: backfill complete, consumers migrated, one full retention period elapsed
alter table analytics.int_pings_zoned drop column legacy_geohash;
yaml
models:
  - name: int_pings_zoned
    columns:
      - name: legacy_geohash
        description: >
          DEPRECATED 2026-08-11, removal planned 2026-11-11. Superseded by h3_cell.
          Consumers: operations map (migrated), export job (pending).

Verify nothing still reads it before dropping — check exposures, check query history for the column name, and give the deprecation a date rather than an intention.

The migration sequence, with the irreversible step last Five steps left to right: add the column, backfill in batches, build the index, migrate consumers, drop the old column. The first four are marked reversible, with a return arrow showing the migration can be abandoned at any of them. The final drop is marked irreversible and separated by a gap, with a note that it belongs in its own change after a full retention period. add column null everywhere backfill batches resumable index it concurrently move consumers one at a time drop old column irreversible abandon at any point up to here and nothing is lost The dashed gap is deliberate: the drop is a separate change, after a full retention period.

Configuration reference

Setting Where Typical value Note
on_schema_change model config append_new_columns Adds the column without a rebuild
batch size backfill macro 3–14 days of data Short enough to keep transactions brief
CREATE INDEX CONCURRENTLY after backfill Avoids the write lock; check indisvalid afterwards
progress view ops model rows remaining, oldest unfilled Makes the migration observable and resumable
deprecation window column description one retention period The old column outlives the migration on purpose
ANALYZE after backfill The new column has no statistics until you ask

Gotchas & edge cases

  • on_schema_change does not backfill. It adds the column and leaves it null for existing rows; that is the entire reason this page exists.
  • An UPDATE rewrites the whole row, geometry included. Backfilling a small text column on a table with a large geometry column still moves the geometry, which is why the batches matter and why storage grows during the migration.
  • A batch that finds no rows still costs a scan. Bound the loop with max_batches and check the progress view rather than looping until empty.
  • Consumers see nulls in the middle region. Either state that in the column description with the migration window, or expose a view filtered to the completed range until it closes.
  • sync_all_columns will drop columns you removed from the model. Use append_new_columns during a migration; the stricter setting belongs after it, when the shape is stable.

FAQ

Why not just do a full refresh?

Do, if the table is small enough. A full refresh is simpler, leaves no half-populated state and needs no macro. This page is for tables where a rebuild exceeds the window or the storage headroom, which in practice means hundreds of millions of rows with geometry.

Should the backfill run inside dbt or as a separate job?

As a run-operation invoked by the orchestrator, not as a model. It is a migration, not a transformation: it mutates existing rows, it runs once, and it must not be part of the nightly graph. Keeping it out of the DAG also keeps it out of --full-refresh.

How do I know the backfilled values are right?

Test the new column on the completed region only, with a where clause that excludes the unfilled part, and tighten the test to the whole table once the migration ends. A test that fails for the duration of a migration gets disabled and then forgotten.

What if the backfill computation itself is expensive?

Then batch smaller and run over more nights rather than making the transactions longer. A geometry-heavy backfill — reprojection, simplification, grid indexing — costs per row, so the total is fixed; only the transaction length and the lock duration are under your control.

Up: Part of Versioning Spatial Schemas in dbt.