CMD Guide
HomeDatabasesDatabase Engine Internals

Online Schema Change at Scale — Expand-Contract, Batched Backfill & Shadow Tables

An online schema change works because you never ask the database to do one big, blocking thing on a live table. You decompose the change into steps that are each either metadata-only (a catalog edit that touches no rows) or bounded and interruptible (a small batch, or a scan that does not block writes). The naive single statement — ALTER TABLE users ADD COLUMN ... NOT NULL DEFAULT (something) ; CREATE INDEX ... — fails not because it is slow, but because of how it is slow: it grabs a lock that sits at the head of the table's lock queue and blocks every reader and writer behind it while it rewrites or scans hundreds of millions of rows. The whole discipline below exists to keep every lock either instant or short, and every scan off the critical path.

The mechanism you must be able to draw: lock-queue amplification

A rewriting or scanning DDL statement in PostgreSQL takes an ACCESS EXCLUSIVE lock on the table — the strongest lock there is, conflicting with every other lock mode, including the ACCESS SHARE that a plain SELECT takes. On its own that would only block for as long as the rewrite runs. The real danger is subtler and is what turns a "quick migration" into an outage.

Lock requests are granted roughly FIFO, and — this is the crucial rule — a new request must wait behind any already-waiting request that conflicts with it. So picture a reporting SELECT that has been scanning users for 40 seconds, holding ACCESS SHARE. Your ALTER arrives, requests ACCESS EXCLUSIVE, conflicts with that SELECT, and parks at the head of the queue. Now every query that arrives after your ALTER — even a 1 ms primary-key lookup that would happily run alongside the reporting SELECT — must queue behind the waiting ALTER, because it conflicts with that. One long-running read plus one DDL statement silently freezes the entire table. Connections pile up, the pool exhausts, and the application starts erroring everywhere, even though your DDL "should" have been instantaneous.

Two independent failure axes fall out of this, and you must separate them: (1) how long the lock is held (a table rewrite or non-concurrent index build holds it for the whole operation — minutes), and (2) how long the DDL waits to acquire the lock (which, if unbounded, lets it park at the head of the queue and amplify). A safe migration attacks both: pick statement forms that hold the lock only momentarily, and always cap the wait with lock_timeout so a blocked DDL fails fast and retries rather than freezing the table.

Expand–contract (parallel change): the shape of every safe migration

The governing idea is to keep the old and new schema shapes coexisting so that at no instant does a running application version depend on a change that hasn't fully landed. You only ever add before you remove, and you sequence it so each step is independently deployable and reversible:

  1. Expand — make purely additive schema changes: add the new nullable column (or the new table shape), add the new index. Nothing reads the new structure yet; old code is untouched.
  2. Backfill — populate the new column for existing rows in bounded, throttled batches (below). This is the long part, but it runs entirely off the lock critical path.
  3. Dual-write / start populating — deploy application code that writes both old and new (or a DB default / trigger keeps the new column current), so new rows and updates stay correct while the backfill catches the tail. Once backfill + dual-write overlap, the new column is fully trustworthy; now you can validate constraints and flip NOT NULL.
  4. Contract — swap reads to the new column, then, in a later deploy, drop the old column / old table / temporary constraint. Destructive steps come last, after you are certain nothing reads the old shape.

The reason this is safe is that every arrow between phases is a separate, small, reversible deploy. If phase 3 misbehaves you roll back the app without touching the schema; if you abort entirely, the additive column is harmless.

PostgreSQL specifics — which statement form is cheap, and why

The whole game in native Postgres is choosing forms that stay metadata-only or off the write path. The four you must know cold:

Adding NOT NULL without a full-table lock

Setting NOT NULL on an existing column naively (ALTER COLUMN ... SET NOT NULL) forces a full sequential scan to prove no nulls exist — under ACCESS EXCLUSIVE the whole time. On a 500M-row table that is a multi-minute outage. The safe path (PG 12+) launders the scan through a validated check constraint:

-- 1. cheap: only new/changed rows are checked, brief lock
ALTER TABLE users ADD CONSTRAINT ev_not_null
  CHECK (email_verified IS NOT NULL) NOT VALID;

-- 2. slow scan, but SHARE UPDATE EXCLUSIVE — reads & writes keep flowing
ALTER TABLE users VALIDATE CONSTRAINT ev_not_null;

-- 3. now SET NOT NULL can reuse the proven constraint and SKIP the scan;
--    it still takes ACCESS EXCLUSIVE, but only for an instant
ALTER TABLE users ALTER COLUMN email_verified SET NOT NULL;

-- 4. optional: drop the now-redundant check constraint
ALTER TABLE users DROP CONSTRAINT ev_not_null;

Batched backfill: the template that won't take the database down

The backfill is where an unwary engineer causes the most damage — not with a lock, but with one enormous UPDATE. A single UPDATE users SET email_verified = ... over 500M rows is one giant transaction that (a) generates enormous WAL and table bloat (MVCC leaves 500M dead tuples), (b) holds the xmin horizon open for its entire duration, which stalls autovacuum cluster-wide so dead tuples pile up everywhere, and (c) ships as one huge chunk to replicas, blowing replication lag into minutes. It may also just run out of disk on WAL.

The fix is to walk the primary key in bounded ranges — keyset pagination, never OFFSET (OFFSET re-scans and re-discards a growing prefix each batch, turning an O(n) job into O(n²)). Each batch is its own short transaction; between batches you sleep and check replication lag so the backfill yields to production traffic and lets autovacuum reclaim the dead tuples you just made:

-- one batch: bounded, resumable from :last_id, idempotent
WITH batch AS (
    SELECT id
    FROM   users
    WHERE  id > :last_id
      AND  email_verified IS NULL      -- only untouched rows → re-runnable
    ORDER  BY id
    LIMIT  5000                        -- bound txn size, not by OFFSET
)
UPDATE users u
SET    email_verified = (u.confirmed_at IS NOT NULL)
FROM   batch b
WHERE  u.id = b.id
RETURNING u.id;                        -- driver takes MAX(id) as next :last_id
last_id = 0
loop:
    rows = run_batch(last_id)          # the query above
    if rows is empty: break            # done — no rows > last_id remain
    last_id = max(r.id for r in rows)  # keyset cursor advances
    sleep(50ms)                        # throttle: give prod traffic air
    while replication_lag() > 5s:      # bound lag; let replicas catch up
        sleep(1s)

Four properties make this safe and operable: bounded transaction size (5k rows → small WAL chunks, short-lived snapshots, autovacuum keeps up), keyset cursor (index-seek to id > :last_id, no re-scan), idempotent + resumable (the IS NULL guard means a crashed backfill restarts from last_id and re-running a batch is a no-op), and throttled (the sleep + lag check turn a stampede into a background trickle you can pause or speed up by tuning the two knobs).

Worked example: add NOT NULL email_verified BOOLEAN + an index to a 500M-row users table, at 3pm, no downtime

Say email_verified must be true where the existing confirmed_at is set, and you need an index on (email_verified, created_at) for a new query.

The naive plan and exactly how it fails:

ALTER TABLE users ADD COLUMN email_verified boolean NOT NULL DEFAULT false;
UPDATE users SET email_verified = true WHERE confirmed_at IS NOT NULL;
CREATE INDEX idx_users_ev ON users (email_verified, created_at);

The safe plan, step by step, with the lock each step holds:

#StatementLock & duration
1SET lock_timeout='2s'; ALTER TABLE users ADD COLUMN email_verified boolean; (nullable, no default)ACCESS EXCLUSIVE, but metadata-only → milliseconds; lock_timeout aborts+retries if a read is in the way
2Deploy app that dual-writes email_verified on every insert/update; set a DB default for brand-new rows: ALTER TABLE users ALTER COLUMN email_verified SET DEFAULT false;metadata-only; no rewrite. New rows now stay correct on their own.
3Batched backfill loop (template above): email_verified = (confirmed_at IS NOT NULL) for old rowsper-batch row locks only; runs for minutes-to-hours off the critical path, throttled by replica lag
4CREATE INDEX CONCURRENTLY idx_users_ev ON users (email_verified, created_at);SHARE UPDATE EXCLUSIVE — reads/writes continue; two scans; verify indisvalid after
5ADD CONSTRAINT ev_not_null CHECK (email_verified IS NOT NULL) NOT VALID;brief lock; only new rows checked
6VALIDATE CONSTRAINT ev_not_null;SHARE UPDATE EXCLUSIVE — slow scan, does not block writes
7ALTER COLUMN email_verified SET NOT NULL; then DROP CONSTRAINT ev_not_null;ACCESS EXCLUSIVE, but reuses the validated constraint → skips the scan → instant

No step holds a strong lock for more than milliseconds, and no step is one giant transaction. The 500M-row work (backfill, index build, validation) all happens under weak locks that let production keep serving.

Shadow-table tools: gh-ost and pt-online-schema-change

MySQL's native online DDL is patchier than Postgres's (some ALTERs still copy the table under lock, behavior varies by engine and version), so the ecosystem built external tools that make the copy explicit and controllable. Both pt-online-schema-change (Percona Toolkit) and gh-ost (GitHub) follow the same shape:

  1. Create an empty shadow (ghost) table with the new schema already applied.
  2. Keep it in sync with live writes to the original — this is where they differ: pt-osc installs triggers (AFTER INSERT/UPDATE/DELETE) on the original that mirror every change into the shadow table synchronously; gh-ost tails the binlog instead, applying changes asynchronously with no triggers on the hot path.
  3. Backfill existing rows into the shadow table in chunks, throttling on replica lag.
  4. When the shadow table has caught up, do an atomic RENAME cutover (original → _old, shadow → original in one statement), then drop the old table.

The difference matters operationally. pt-osc's triggers add synchronous write amplification to every production write for the whole migration and can interact badly with existing triggers; gh-ost's binlog approach keeps the hot path clean, throttles more gracefully, and can be paused/tested against a replica — but requires row-based binlog and a bit more setup. Both have foreign-key limitations: FKs referencing the table being changed are notoriously fragile under the rename swap, and often need manual handling or an --alter-foreign-keys-method workaround. You reach for these when you are on MySQL, or when even Postgres's native path is awkward (e.g., a type change that would rewrite, on a table too large to babysit).

Pitfalls checklist (each has caused a real outage)

Selection & trade-offs: native expand–contract vs shadow tools vs a maintenance window

Native expand–contract (Postgres) is the default and the cheapest operationally: no extra moving parts, no trigger overhead, no second copy of the table's disk footprint, and full control. It wins whenever the engine gives you metadata-only and concurrent forms for what you need — which on modern Postgres is almost everything except a handful of in-place type changes. Cost: it's a multi-step choreography you must sequence and deploy carefully, and a few operations (some type changes) still have no non-rewriting native form.

Shadow-table tools (gh-ost / pt-osc) win when the native engine can't do the change online — the MySQL case, or a Postgres type change on a table too big to rewrite. They give you a rebuilt table with any schema, plus pause/throttle/test-on-replica controls. Costs: an entire extra copy of the table (double the disk during the migration), trigger write-amplification (pt-osc) or binlog plumbing (gh-ost), fragile foreign-key handling, and a genuinely scary atomic cutover. More power, much more operational surface.

Just take a maintenance window — run the blocking ALTER during a planned downtime — is not always the wrong answer. If the table is small enough that the rewrite is seconds, or the product genuinely tolerates a 2am window, a single locking statement is far simpler and less error-prone than orchestrating expand–contract, and simplicity has real value. It loses the moment the table is large (the window blows past its budget) or the system is 24/7 with no acceptable downtime — which is exactly the 500M-row-at-3pm scenario, where a window isn't on the table and you must use one of the online approaches above.

Takeaways

🎯 Drill Ladder — survive the follow-ups

L0 · you decompose every schema change into metadata-only or bounded-and-interruptible steps, and cap every lock wait, so no single statement can freeze the table.

L1 · ① Concurrency — "your ADD COLUMN has a constant default, so PG 11+ says it's metadata-only — why did it hang prod for 40s?"
Trap: "DEFAULT false is instant, so the ALTER can't be the cause — must be an unrelated slow query."
Bar: the ALTER's ACCESS EXCLUSIVE request still has to queue, and it parks at the head of the table's FIFO lock wait queue behind any already-waiting conflicting lock (e.g. a 40s reporting SELECT's ACCESS SHARE); every request that arrives after it — even a 1ms PK lookup that would gladly run beside that SELECT — must wait behind the parked ALTER, not behind the SELECT. That's why lock_timeout is non-negotiable, not optional polish. DDL locking & online schema change

L2 · ② Failure — "your keyset backfill loop dies mid-run at row 62M after a bad deploy — how do you resume without corrupting or double-writing?"
Trap: "It's idempotent, so just restart the whole loop from last_id = 0 — simplest and safest."
Bar: restarting from 0 is correct but wasteful — the WHERE email_verified IS NULL guard makes every already-backfilled batch a no-op (index seek returns nothing, one wasted scan of a checked range), so persist last_id externally (a checkpoint row, not just in-process memory) and resume from there; the NULL guard is what makes an at-least-once retry safe even if the checkpoint itself is stale or lost. recovery, REDO/UNDO & MVCC internals

L3 · ③ Scale — "same migration, now 200 MySQL shards behind gh-ost feeding 12 read replicas each — what breaks that didn't break on one primary?"
Trap: "Run gh-ost identically per shard in parallel with the same --max-lag-millis — it's the same schema everywhere."
Bar: a single global throttle is wrong because shard skew means one hot shard's replica lag stalls gh-ost on every other shard if you share a throttle signal; each shard needs its own gh-ost process reading its own replica set's lag, and cutovers must be staggered rather than fired simultaneously, or 200 concurrent atomic RENAMEs spike the proxy/connection-pool layer at once. replication lag & failover

L4 · ④ Time/Lifecycle — "expand–contract runs old and new app code concurrently for days — what's the actual compatibility contract between them?"
Trap: "As long as the new column has a DB default, old code can just ignore it — nothing extra needed."
Bar: a DB column default only helps for a plain INSERT that omits the column; if old code's ORM issues an upsert or full-row UPDATE that explicitly writes all mapped columns, it can stomp the new column back to a stale value — so backward+forward compatibility means both binaries must either dual-write the new column or the migration must tolerate the old binary's writes until it is fully retired, not just "new code populates it." evolving APIs & event schemas without breaking consumers

L5 · ⑤ Adversary/Edge — "gh-ost's atomic RENAME cutover just hangs — RENAME is supposed to be instant, so what's actually happening?"
Trap: "RENAME is atomic, so it can't hang — must be a network partition or gh-ost bug."
Bar: RENAME still has to acquire a metadata lock on the original table, which queues behind any open transaction that has ever touched that table — including a stale "idle in transaction" connection nobody closed; gh-ost bounds this with --cut-over-lock-timeout-seconds and can kill the blocking session, but an operator who doesn't know this watches the cutover hang indefinitely and assumes the tool is broken. DDL locking & recursive CTEs

The floor keeps dropping: staff+ perturbation beyond L5 — "this is a 50k-row config table, nightly batch job, 3am maintenance window already exists — why are you proposing expand–contract and gh-ost at all?" The bar-raiser answer is recognizing that the choreography itself is the cost: a single ALTER under ACCESS EXCLUSIVE for 200ms on a small table, inside an existing off-peak window, is strictly simpler and lower-risk than orchestrating four separate reversible deploys — staff judgment is knowing when not to reach for the machinery, not just knowing the machinery.

Self-locate: died at L1 → mid-level; L4+ → staff signal.

Facing any new concept? Hit it with the six: concurrent? failing? at 100×? over time? adversarial? worth the cost? — that's the interviewer's whole playbook.

Sources

🤖 Don't fully get this? Learn it with Claude

Stuck on Online Schema Change at Scale — Expand-Contract, Batched Backfill & Shadow Tables? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Online Schema Change at Scale — Expand-Contract, Batched Backfill & Shadow Tables** (Databases) and want to truly understand it. Explain Online Schema Change at Scale — Expand-Contract, Batched Backfill & Shadow Tables from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Online Schema Change at Scale — Expand-Contract, Batched Backfill & Shadow Tables** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Online Schema Change at Scale — Expand-Contract, Batched Backfill & Shadow Tables** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Online Schema Change at Scale — Expand-Contract, Batched Backfill & Shadow Tables** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes