Migrating a Live Spatial Table to Partitions

Convert a 700 GB PostGIS table to declarative partitioning without an outage: shadow parent, backfill in batches, dual-write cutover and the rollback that stays available throughout.

← Back to Table Partitioning for Large Spatial Datasets

This page walks through converting a large, continuously written PostGIS table into a partitioned one, with the API serving traffic throughout and a rollback that stays one rename away.

Context & When to Use

PostgreSQL cannot convert a table to a partitioned table in place. The strategy is fixed when the parent is created, so a migration means building a new hierarchy and moving the data — which on a 700 GB tracking table is hours of I/O that cannot happen inside a maintenance window. The design goal is therefore not speed but interruptibility: every step must be resumable, and the table must stay readable and writable while it runs.

There is a shortcut worth knowing. If you only need future data partitioned and are content to leave history as one lump, create the parent and ATTACH the existing table as a single catch-all partition. That takes seconds. It gives you cheap partitioning going forward and no pruning benefit on the historical rows, which is often exactly the right trade for a table whose queries are all recent-window anyway.

The full migration below is for the case where history matters: retention needs to expire month by month, or the historical index is what no longer fits in memory. It assumes the partition design from Table Partitioning for Large Spatial Datasets is already settled — key, width and retention.

Runnable Implementation

-- 1. Shadow parent: same shape, composite PK, indexes defined on the parent
CREATE TABLE positions_new (
    LIKE positions INCLUDING DEFAULTS INCLUDING CONSTRAINTS,
    PRIMARY KEY (id, observed_at)
) PARTITION BY RANGE (observed_at);

CREATE INDEX positions_new_geom_gix     ON positions_new USING GIST (geom);
CREATE INDEX positions_new_vehicle_time ON positions_new (vehicle_id, observed_at DESC);

-- 2. Backfill one month at a time, as a STANDALONE table, then attach it.
--    Building the index off-hierarchy avoids holding locks on the live parent.
CREATE TABLE positions_2026_03 (LIKE positions_new INCLUDING DEFAULTS);

INSERT INTO positions_2026_03 (id, vehicle_id, observed_at, geom, speed_kph)
SELECT id, vehicle_id, observed_at, geom, speed_kph
FROM   positions
WHERE  observed_at >= '2026-03-01' AND observed_at < '2026-04-01';

-- The CHECK lets ATTACH skip its validation scan entirely
ALTER TABLE positions_2026_03
  ADD CONSTRAINT positions_2026_03_range
  CHECK (observed_at >= '2026-03-01' AND observed_at < '2026-04-01');

CREATE INDEX ON positions_2026_03 USING GIST (geom);
CREATE INDEX ON positions_2026_03 (vehicle_id, observed_at DESC);
ALTER TABLE positions_2026_03 ADD PRIMARY KEY (id, observed_at);

ALTER TABLE positions_new ATTACH PARTITION positions_2026_03
  FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');   -- instant, no scan

The CHECK constraint before ATTACH is the difference between a millisecond catalogue update and a full sequential scan under an ACCESS EXCLUSIVE lock. PostgreSQL uses the constraint to prove every row already satisfies the partition bound, so it skips validation.

Migration timeline — the table stays available throughoutA five-phase timeline. Phase one creates the shadow parent, taking seconds with no locks. Phase two turns on dual-write so every new row lands in both tables, adding about 0.3 milliseconds per write. Phase three backfills history in month-sized batches over roughly six hours, during which reads still come from the original table. Phase four verifies row counts and checksums per month. Phase five renames both tables inside one transaction, holding an exclusive lock for about 40 milliseconds. A rollback arrow shows that the reverse rename remains available for as long as the old table is kept.Cutover timeline — total downtime measured in milliseconds1 · shadowparent + indexesseconds2 · dual-write on+0.3 ms per writebefore the backfill3 · backfill, month by monthresumable · reads still hit the old table~6 h for 700 GB4 · verifycounts per month5 · swap~40 ms lockrollback: rename back — available until the old table is droppedDual-write must start BEFORE the backfill, or rows written during the copy are lost from the new table.Every phase before the swap is interruptible and resumable without data loss.

Key Parameters & Options

StepSettingWhy
Dual-writetrigger on the old table, or application-levelA trigger cannot be forgotten by a code path; the application version is easier to remove later
Batch size200k–500k rowsCommits in seconds; avoids long-lived snapshots that stall autovacuum
CHECK before ATTACHmandatoryTurns a full validation scan into a catalogue update
Index buildon the standalone tableAvoids ACCESS EXCLUSIVE on the live hierarchy
Swaptwo ALTER TABLE … RENAME in one transactionAtomic from the application’s point of view
Old tablekeep for one retention cycleThe rollback path, and the arbiter in any count dispute

The dual-write trigger is short enough to read in one go:

CREATE OR REPLACE FUNCTION positions_dual_write()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    INSERT INTO positions_new (id, vehicle_id, observed_at, geom, speed_kph)
    VALUES (NEW.id, NEW.vehicle_id, NEW.observed_at, NEW.geom, NEW.speed_kph)
    ON CONFLICT DO NOTHING;      -- backfill may already have copied this row
    RETURN NEW;
END $$;

CREATE TRIGGER positions_dual_write_trg
    AFTER INSERT ON positions
    FOR EACH ROW EXECUTE FUNCTION positions_dual_write();

ON CONFLICT DO NOTHING matters because the backfill and the trigger overlap at the boundary of the current month; without it the migration aborts on a duplicate key the first time a row is written to a range the copy has already reached.

What the swap actually costs

Lock duration by cutover strategyThree approaches compared by how long the table is unavailable. Copying everything inside one maintenance transaction locks the table for two hours and twenty minutes. Attaching the old table as a single catch-all partition locks it for about 900 milliseconds. The shadow-plus-rename approach described here locks it for roughly 40 milliseconds, short enough that connection-level retries absorb it entirely.How long the table is unavailable, by strategy (log scale)100 ms1 s1 min1 h+copy inside one transaction2 h 20 mattach old table as catch-all900 msshadow + rename (this page)40 msAt 40 ms the swap is shorter than a normal statement timeout, so in-flight requests retry rather than fail.The catch-all attach is a legitimate middle option when historical pruning is not needed.

The swap itself is four statements in one transaction:

BEGIN;
ALTER TABLE positions     RENAME TO positions_old;
ALTER TABLE positions_new RENAME TO positions;
DROP TRIGGER positions_dual_write_trg ON positions_old;
COMMIT;

Tracking backfill progress

A six-hour backfill needs a progress signal, or the only way to know whether it is halfway or stuck is to watch disk usage. Record each completed month in a small control table, and the job becomes both resumable and reportable.

CREATE TABLE migration_progress (
    month        date PRIMARY KEY,
    rows_copied  bigint,
    finished_at  timestamptz DEFAULT now()
);

The backfill loop checks that table before each month and skips what is already done, which is what makes an interrupted run safe to restart. It also gives operations a straight answer to “how long left” — months remaining multiplied by the observed rate per month.

Backfill progress across 24 monthly batchesA step chart of months completed against elapsed hours. The first twelve months complete in about ninety minutes because early data is sparse. Progress slows through the middle as monthly row counts grow, and the last six months, which hold the densest data, take nearly three hours between them. A projection line based on the average rate would have predicted four hours; the actual total is six, which is why progress should be measured in rows rather than months.Backfill progress — months are not equal units of work24 m12 m0naive linear projection: 4 hactual: 6 h 10 m1 h3 h5 h6 hRecent months hold far more rows than old ones, so estimate remaining time from rows copied, not months done.The control table makes both numbers available without inspecting the target table.

Gotchas & Failure Modes

  • Dual-write started after the backfill. Rows written during the copy never reach the new table and are silently missing after the swap. Always enable dual-write first, then backfill.
  • ERROR: duplicate key value violates unique constraint during backfill — the trigger already inserted the row. ON CONFLICT DO NOTHING on the trigger insert, not on the backfill, is the right place to absorb it.
  • Sequence left behind. id keeps its sequence through the rename because the sequence is owned by the column, but confirm with SELECT last_value FROM positions_id_seq before and after; a mismatch means the new table got its own sequence from LIKE INCLUDING DEFAULTS.
  • Foreign keys pointing at the old table. They follow the rename, so a child table now references positions_old. Drop and recreate them against the new parent, and note that a foreign key to a partitioned table needs PostgreSQL 12+.
  • Views and functions with search_path surprises. A view defined on positions binds to the OID, not the name, so after the rename it still reads the old table. Recreate every dependent view — list them with pg_depend before starting.
  • Backfill starving autovacuum. Long batches hold snapshots that prevent cleanup on the live table, and bloat accumulates exactly while you are trying to migrate. Keep batches short and watch n_dead_tup, as described in Observability for Spatial Endpoints.

Verification Snippet

-- Per-month reconciliation before the swap; every row must match
SELECT date_trunc('month', observed_at) AS month,
       count(*) FILTER (WHERE src = 'old') AS old_rows,
       count(*) FILTER (WHERE src = 'new') AS new_rows
FROM (
    SELECT observed_at, 'old' AS src FROM positions
    UNION ALL
    SELECT observed_at, 'new' AS src FROM positions_new
) t
GROUP BY 1 ORDER BY 1;

-- Geometry checksum per month catches a truncated or reprojected copy
SELECT date_trunc('month', observed_at) AS month,
       md5(string_agg(ST_AsBinary(geom)::text, '' ORDER BY id)) AS digest
FROM   positions_new
GROUP  BY 1 ORDER BY 1;
# After the swap: the API should be unchanged, and the plan should prune
psql -c "EXPLAIN SELECT count(*) FROM positions WHERE observed_at >= now() - interval '2 days'" \
  | grep -c positions_20
# 1  → one partition in the plan

← Back to Table Partitioning for Large Spatial Datasets