Migrating a PostGIS OSM Schema Without Downtime Jump to heading
An OSM table that a map service reads from cannot be rebuilt in place, because the rebuild takes hours and holds a lock for all of them. The way round it is to build the new shape beside the old one and switch which name points at it.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
The migration turns on one idea: readers resolve a name, and the name can be made to resolve somewhere else atomically. Postgres gives two mechanisms for this, and the choice between them is the main design decision.
A schema switch uses search_path. Readers query unqualified planet_osm_polygon; the new version lives in osm_v2 and the old in osm_v1; changing the role’s search_path changes which one an unqualified name finds. It is a catalogue update, so it takes microseconds, and it applies to new sessions or at the next SET.
A view switch keeps the tables named distinctly and points a view at whichever is current. CREATE OR REPLACE VIEW takes a brief ACCESS EXCLUSIVE lock on the view, which is fine because nothing holds a long lock on a view definition, and the switch is transactional so it can be rolled back with the transaction.
The view is the better default: it is explicit, it is transactional, and it does not depend on per-role session state that is easy to get wrong. The schema switch wins when the new version has to differ in more than one object at a time, because a whole schema swaps as a unit.
Runnable solution Jump to heading
-- 1. Build the new shape in its own schema. Nothing here touches the live table.
CREATE SCHEMA IF NOT EXISTS osm_next;
CREATE TABLE osm_next.polygon (
osm_id bigint PRIMARY KEY,
osm_type char(1) NOT NULL,
-- new in v2: tags as jsonb rather than a wide column set
tags jsonb NOT NULL DEFAULT '{}'::jsonb,
-- new in v2: an explicit area, so consumers stop calling ST_Area ad hoc
area_m2 double precision,
geom geometry(MultiPolygon, 3857) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 2. Backfill in bounded batches so no single statement holds a long snapshot.
-- Run this repeatedly until it reports zero rows moved.
INSERT INTO osm_next.polygon (osm_id, osm_type, tags, area_m2, geom)
SELECT p.osm_id,
CASE WHEN p.osm_id < 0 THEN 'r' ELSE 'w' END,
jsonb_strip_nulls(jsonb_build_object(
'building', p.building, 'landuse', p.landuse,
'natural', p."natural", 'name', p.name)),
ST_Area(p.way::geography),
ST_Multi(p.way)
FROM osm_live.planet_osm_polygon AS p
WHERE p.osm_id > COALESCE((SELECT max(osm_id) FROM osm_next.polygon), -1)
ORDER BY p.osm_id
LIMIT 200000
ON CONFLICT (osm_id) DO NOTHING;
-- 3. Index AFTER the bulk load, and CONCURRENTLY so it never blocks.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_next_polygon_geom
ON osm_next.polygon USING GIST (geom);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_next_polygon_tags
ON osm_next.polygon USING GIN (tags jsonb_path_ops);
ANALYZE osm_next.polygon;
-- 4. Verify before switching. Counts, and a geometry equality sample.
SELECT (SELECT count(*) FROM osm_live.planet_osm_polygon) AS live_rows,
(SELECT count(*) FROM osm_next.polygon) AS next_rows;
SELECT count(*) AS mismatched
FROM osm_live.planet_osm_polygon AS l
JOIN osm_next.polygon AS n USING (osm_id)
WHERE NOT ST_Equals(ST_Multi(l.way), n.geom)
LIMIT 1000;
-- 5. Cut over. Transactional, milliseconds, reversible by rolling back.
BEGIN;
CREATE OR REPLACE VIEW public.osm_polygon AS
SELECT osm_id, osm_type, tags, area_m2, geom FROM osm_next.polygon;
COMMENT ON VIEW public.osm_polygon IS 'v2 since 2026-09-17; v1 in osm_live';
COMMIT;
-- 6. Keep the old table until rollback stops being plausible. Then, separately:
-- DROP TABLE osm_live.planet_osm_polygon;
"""Drive the batched backfill until it converges, with a lock-time guard."""
from __future__ import annotations
import logging
import time
import psycopg
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.postgis.migrate")
BACKFILL = open("backfill.sql", encoding="utf-8").read()
def backfill(dsn: str, batch_pause_s: float = 0.5) -> int:
total = 0
with psycopg.connect(dsn, autocommit=True) as conn:
# A statement that cannot get its lock quickly should fail, not queue:
# a queued ACCESS EXCLUSIVE request blocks every reader behind it.
conn.execute("SET lock_timeout = '3s'")
conn.execute("SET statement_timeout = '10min'")
while True:
with conn.cursor() as cur:
cur.execute(BACKFILL)
moved = cur.rowcount
total += moved
logger.info("backfilled %d rows (%d total)", moved, total)
if moved == 0:
return total
time.sleep(batch_pause_s) # let autovacuum and readers breathe
if __name__ == "__main__":
logger.info("backfill, verify, then switch the view in a transaction")
Step-by-step walkthrough Jump to heading
- Create the new schema, not a new column. Adding columns to the live table is the in-place path in disguise, and a rewrite-triggering
ALTERholds the same lock as any other. - Backfill in bounded batches. A single
INSERT ... SELECTover a continental table holds one snapshot for hours, which bloats the source and blocks vacuum. - Set
lock_timeout. A migration statement that cannot acquire its lock immediately should fail rather than queue, because a queued exclusive request blocks every reader arriving behind it. - Index after loading, and concurrently. Building indexes during the load slows it and a non-concurrent build blocks writes, neither of which the switch needs.
- Verify before switching, not after. Compare counts and sample geometry equality between the two tables while both exist, which is the only window in which that comparison is possible.
- Switch inside a transaction.
CREATE OR REPLACE VIEWis transactional, so an unexpected result rolls the switch back with the transaction rather than leaving a half-migrated name. - Repoint the loader separately. Readers and the writer switch at different times, and a writer still filling the old table after the switch produces a silently stale view.
- Keep the old table. Dropping it in the same change removes the only cheap rollback, and disk is a better thing to spend than an outage.
Verification Jump to heading
- Counts match. Live and shadow row counts should agree, or differ only by rows added since the backfill started.
- Geometry survives. A sample
ST_Equalscomparison across joined identifiers should find no mismatches. - Query plans are sane.
EXPLAINa representative query against the view and confirm it uses the new indexes. - The switch is fast. Time the cutover transaction; anything beyond a few milliseconds means something held a lock.
- Rollback works. Before dropping anything, point the view back at the old table and confirm readers recover.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Readers block for hours | In-place ALTER rewriting the table |
Build a shadow table and switch a view |
| Backfill bloats the source | One statement holding a long snapshot | Batch with LIMIT and commit between batches |
| Cutover hangs | No lock_timeout, request queued behind a reader |
SET lock_timeout = '3s' and retry |
| View shows stale data | Loader still writing to the old table | Repoint the writer as a separate, explicit step |
| Index build blocks writes | CREATE INDEX without CONCURRENTLY |
Build concurrently, after the bulk load |
| Queries slow after cutover | Statistics never gathered | ANALYZE the new table before switching |
| No way back | Old table dropped in the same change | Keep it for days; drop it as its own change |
Specification reference Jump to heading
CREATE OR REPLACE VIEWreplaces the definition of an existing view. The new query must generate the same columns in the same order with the same types, though columns may be added at the end. The operation is transactional.CREATE INDEX CONCURRENTLYbuilds an index without taking any locks that prevent concurrent inserts, updates or deletes on the table, at the cost of two table scans. See the PostgreSQL documentation forCREATE VIEW,CREATE INDEXandlock_timeout.
Frequently Asked Questions Jump to heading
Why not just add a column to the existing table?
Adding a nullable column with no default is instant and perfectly safe. The trouble is that OSM schema changes are rarely that: they change a type, add a NOT NULL with a computed default, or restructure wide columns into jsonb, and each of those rewrites the whole table under an exclusive lock. On a continental polygon table that is hours of blocked readers. The shadow approach costs disk and buys the ability to do it during the day.
Should the loader write to both schemas during the migration?
Only if the backfill will run long enough that the gap matters. Dual-writing doubles the write path’s failure surface and introduces the question of what to do when one side succeeds and the other does not. For a backfill measured in hours against data that updates daily, a simpler answer is to run the backfill, switch, and then let the next normal load populate the new table’s recent changes.
How long should the old table be kept?
Until a full cycle of every consumer has run against the new one — which usually means at least one of whatever your slowest periodic job is, plus a working week. The cost is disk, and the thing it buys is that a problem discovered on day three has a one-statement remedy rather than a re-migration. Drop it as its own deliberate change, never as the tail of the migration.
Does the same approach work for a GeoParquet export?
It works better, because object storage has no locks at all. Write the new-shape files to a new prefix, verify, and repoint whatever resolves the dataset path — a catalogue entry, a manifest, or a symlink-like alias. The structure of the migration is identical; only the atomic switch mechanism differs.
Related Jump to heading
- Exporting OSM to GeoParquet and PostGIS — the parent topic.
- Loading OSM into PostGIS with osm2pgsql Flex — the loader that has to be repointed.
- Keeping GeoParquet and PostGIS Exports Consistent — the other sink that moves with the schema.
- Modelling OSM for Analytics Warehouses — where the target shape is decided.
- Propagating OSM Diffs Into a GeoParquet Lake — the same switch idea applied to files.
Up one level: Exporting OSM to GeoParquet and PostGIS.