Module O-4 — Deploying and Migrating Without Downtime
What this module covers: The decoupling that makes everything else safe — deploy is not release — and feature flags as the mechanism. Rolling, blue-green, canary and shadow deployments with their real trade-offs, and the constraint every one of them imposes and everyone forgets: two versions run simultaneously, so every change must be compatible with the version before it. Expand/contract as the only schema-change pattern that is reversible at each step, worked through a column rename. Then the centrepiece, which is Postgres-specific and contradicts the common belief: a multi-terabyte migration does not take an outage because it rewrote the table — it takes an outage because an ACCESS EXCLUSIVE request queues behind a long-running read and every subsequent query queues behind it, so a two-millisecond DDL statement blocks every reader for the duration of somebody's report. The one setting that prevents most of these outages. Which operations are metadata-only and which rewrite. CREATE INDEX CONCURRENTLY and its sharp edges. View-based migration tooling, and why the well-known MySQL shadow-table tools are the wrong reach on Postgres. And backfills that do not take the database down.
Deploy Is Not Release
The single most useful reframing in this module:
Deploy — the new code is running in production.
Release — users are exposed to the new behaviour.
Coupling them means every deploy is a product event: it must happen when the team is available, when traffic is low, when marketing is ready, and any problem requires a rollback of everything in that build.
Decoupling them, with feature flags, means you deploy the code dark at 10 a.m., verify it is healthy, and turn it on at 2 p.m. with everyone watching — and turning it off is a configuration change measured in seconds rather than a deploy measured in minutes. That single property is what makes the rest of this module's risk manageable, because your fastest rollback stops being a redeploy.
Analogy: a stage production. Deploying is building the set, hanging the lights and running the technical rehearsal — all of it done in the theatre, with the curtain down, while the audience is elsewhere. Releasing is raising the curtain. Doing both in one motion means the first time anyone discovers the lighting rig is wrong is with four hundred people watching, and the only remedy is to stop the show. A theatre that separates them can also drop the curtain on one scene without cancelling the evening, which is what a kill switch is.
Four Strategies, and the Constraint All of Them Share
Rollback speed
Cost
Risk detection
Needs schema compatible with both versions
Rolling
minutes (redeploy)
none
after full rollout
yes
Blue-green
seconds (switch back)
2× capacity
after switch, all at once
yes — they share a database
Canary
seconds (shift traffic)
small
during rollout, on a slice
yes
Shadow / dark
n/a — no user traffic
duplicate compute
before any user is affected
yes, and side effects must be safe
Rolling replaces instances gradually and is the default almost everywhere. Cheap, simple, and slow to roll back because it is another rollout.
Blue-green runs two complete environments and switches traffic at the load balancer. Rollback is a switch back, which is the fastest available. The costs: double capacity during the switch, and — the point that matters — it does not solve database migrations, because both environments share one database. The schema must work with both versions regardless, so blue-green buys you fast code rollback and nothing at all on the data side.
Canary sends a small traffic fraction to the new version and compares metrics before ramping. It is the best risk detection available because problems are found on 1% of users instead of 100%. Two real traps: at low traffic, 1% for ten minutes is not enough events to be statistically meaningful, so you conclude "no difference" from noise; and a user whose requests bounce between versions can see inconsistent behaviour, which needs either session affinity or genuine version compatibility.
Shadow (dark) traffic duplicates real requests to the new version and discards the responses — excellent for validating performance against production traffic shape, and dangerous unless the path is read-only or the side effects are genuinely idempotent, because otherwise you have just double-charged every customer (Module P-16).
The N−1 compatibility rule
Every strategy above has one moment in common: both versions are running at the same time. During a rolling deploy that lasts minutes; during a canary it can last hours; during a blue-green switch, requests in flight span both.
So the rule that governs all schema and contract changes:
Version N must be compatible with version N−1, in both directions, for everything they share.
Which means:
API responses: additive only. Removing or renaming a field breaks the old client still running.
API requests: the new version must accept the old shape, because old instances are still generating it.
Message and event formats: tolerant readers, additive changes, and a schema registry that rejects incompatible changes (Module P-23).
Cache entries: a changed value shape needs a versioned key, or the new code reads an old-format entry and crashes (Module P-13).
Session and cookie data: the same problem, with a longer tail.
The database schema: both versions must be able to read and write it, which is the whole of expand/contract below.
Almost every "the deploy broke production for four minutes and then healed" incident is a violation of this rule.
Feature Flags, and the Debt They Are
Four kinds, with different lifetimes:
Kind
Lifetime
Example
Release toggle
days — delete it
new checkout flow
Experiment
weeks
A/B on ranking
Operational toggle
permanent
kill switch for recommendations (Module A-7)
Permission / entitlement
permanent
enterprise-only feature
Release toggles are debt with a due date. Each one doubles the number of code paths, and n flags give 2ⁿ combinations that nobody tests. Track flag age, alert on flags older than a threshold, and treat removing them as part of the work rather than a follow-up nobody schedules. A codebase with two hundred permanent release toggles has branching that no one can reason about, which is a worse position than shipping without flags.
And the flag system must not be a hard dependency (Module A-7): cache values locally with a generous TTL and compile in defaults, so an unreachable flag service means "last known values," never "no features."
Schema Migrations: Expand/Contract Is the Only Reversible Pattern
The pattern, four phases, each independently deployable and reversible except the last:
text
That last line is Module A-3's pivot transaction, arriving in a new setting: everything up to the contract can be rolled back, and after it cannot, so the contract step happens only when you are certain — usually days or weeks later, not in the same release.
Worked: renaming a column
The tempting one-liner destroys production:
sql
Every running instance of the previous version references email, and there is no moment when both names exist. The correct sequence is six steps across several deploys:
text
Tedious, and every step is safe with both versions running. Note that steps 2, 4 and 5 are separate deploys deliberately: collapsing 4 and 5 means a rollback to step 4's code finds rows written only to the new column.
Two more common cases
Adding a NOT NULL column — do not add the constraint directly, because validating it requires a full table scan under a strong lock:
sql
The NOT VALID then VALIDATE split is the technique to remember: it separates the lock from the scan, so neither is held long and strong at the same time.
Removing a column: stop reading it, deploy; stop writing it, deploy; then drop it. In Postgres DROP COLUMN is metadata-only and instant — but the space is not reclaimed until a table rewrite, so a dropped 200-byte column keeps costing storage until pg_repack or a natural rewrite occurs.
The Real Cause of Migration Outages: Lock Queueing, Not the Rewrite
Here is the belief to correct, because it sends teams to the wrong solution:
"The migration took the site down because it rewrote a multi-terabyte table."
Sometimes true. Usually not. The far more common cause is lock queueing, and the mechanism is worth understanding precisely because it is counter-intuitive and because one setting prevents it.
ALTER TABLE requires an ACCESS EXCLUSIVE lock — conflicting with everything, including plain SELECTs, which hold ACCESS SHARE. So:
text
text
The DDL did not need to be slow to cause an outage. It only needed to wait.
The one setting that prevents most of this
sql
With a short lock_timeout, the DDL either acquires the lock quickly or gives up before a queue forms behind it. Failing the migration is enormously preferable to blocking production, and the retry loop will succeed the moment there is a gap between long queries. This is the highest-value line in any migration script, and its absence explains most migration incidents.
Three companion practices:
Check for long transactions first. Query pg_stat_activity for anything running longer than a few seconds — including idle in transaction, which holds locks while executing nothing (Module P-4) — and either wait or terminate it.
Do not migrate during a reporting window. An analytics workload on the primary is a supply of long ACCESS SHARE holders, which is one more argument for keeping analytics off the OLTP instance (Module P-22).
Set statement_timeout on the migration role too, so a genuinely long rewrite cannot run for an hour unnoticed, and run migrations as a separate role from the application (Module A-13).
Which operations are cheap and which rewrite
Operation
Cost
Lock
ADD COLUMN (nullable, or non-volatile default, PG 11+)
metadata only
brief ACCESS EXCLUSIVE
DROP COLUMN
metadata only (space not reclaimed)
brief ACCESS EXCLUSIVE
RENAME column/table
metadata only
brief ACCESS EXCLUSIVE
ADD CONSTRAINT … NOT VALID
metadata only
brief ACCESS EXCLUSIVE
VALIDATE CONSTRAINT
full scan
SHARE UPDATE EXCLUSIVE — reads and writes continue
CREATE INDEX
full scan
blocks writes
CREATE INDEX CONCURRENTLY
two scans
does not block reads or writes
SET NOT NULL
full scan (avoidable via a validated CHECK, PG 12+)
ACCESS EXCLUSIVE for the scan
ALTER COLUMN TYPE (most)
full rewrite
ACCESS EXCLUSIVE throughout
VACUUM FULL / CLUSTER
full rewrite
ACCESS EXCLUSIVE — never in production
Note the pattern: on modern Postgres most schema changes are metadata-only. Which is exactly why lock queueing, not rewriting, is the dominant outage cause — the statements are fast, and the waiting is what hurts.
CREATE INDEX CONCURRENTLY has three sharp edges worth knowing before you rely on it: it takes roughly twice as long and does two passes; it cannot run inside a transaction block, which most migration frameworks wrap statements in by default and which is a recurring practical stumble; and if it fails it leaves an INVALID index behind that continues to consume write overhead and must be found and dropped. Check pg_index.indisvalid after any failed index build.
pg_repack rebuilds a bloated table or index without the sustained exclusive lock of VACUUM FULL — it needs disk space for a copy and a brief lock at the swap, and it is the standard answer for reclaiming space from a table that has accumulated dead tuples (Module F-13).
View-based expand/contract, and the MySQL tools that are not the answer
The state of the art in Postgres migration tooling is view-based versioning — pgroll and Reshape are the notable implementations. They present each application version with its own view of the schema, so version N−1 sees email while version N sees email_address, both backed by the same physical table with triggers keeping them consistent. That makes expand/contract a property of the tooling rather than six hand-managed deploys, and it makes rollback a view swap.
And the contrast worth being explicit about, because the names are famous enough to be misapplied:
gh-ost and pt-online-schema-change are MySQL tools. They exist because MySQL's online-DDL story was historically weak, so they build a shadow table, copy rows in batches, keep it synchronised via triggers or the binlog, and atomically swap it in at the end.
Understanding why they exist is useful — the shadow-table approach is how you get an online migration when the engine will not give you one. But they are not the Postgres answer, and reaching for a shadow-table pattern on Postgres means reimplementing what the engine already provides: transactional DDL (so a failed migration rolls back cleanly), metadata-only column operations, CREATE INDEX CONCURRENTLY, and NOT VALID/VALIDATE for constraints. The residual gap — presenting two schema versions simultaneously — is what pgroll fills natively. Use the MySQL tools as an explanation of the problem, not as a template for this stack.
Backfills That Do Not Take the Database Down
The one-liner that looks correct and is an outage:
sql
Four separate failures, all at once:
One transaction, one snapshot, held for hours — which blocks vacuum across the whole database and bloats every busy table (Module F-13).
An enormous WAL burst, which is replicated to every standby (replica lag spikes, Module P-5) and to every CDC consumer (slot growth, Module P-23), and can fill disk.
Lock contention on 400 million rows.
No progress and no resumability — a failure at 90% redoes everything.
The correct shape is chunked, throttled, resumable and idempotent:
ts
Four properties: small committed chunks (so no long snapshot and no large WAL transaction), a persisted cursor so it resumes, idempotent by predicate (WHERE email_address IS NULL) so re-running is harmless, and — the practice most worth adopting — throttling on replica lag as the feedback signal rather than a fixed sleep. Lag is the composite measure of whether you are outrunning the system's ability to absorb your writes, so using it as the control loop makes the backfill self-pacing across different times of day.
Watch during a backfill: replica lag, WAL generation rate, primary disk free, dead-tuple accumulation, and CDC slot retention (Module P-23).
Rollback Is Asymmetric
Code rollback is easy: redeploy the previous immutable artefact (Module O-3). Schema rollback usually is not — you cannot un-drop a column that had data in it, and you cannot un-write rows a new version created in a new format.
Hence the two rules:
Never combine a schema change and the code that depends on it in one deploy. Ship the expand, then the code. Otherwise a code rollback leaves a schema the old version cannot use, or vice versa.
Prefer rolling forward for data problems. A corrective migration you write deliberately is usually safer than attempting to reverse one, precisely because the reverse path was never designed.
And remember the deploy interacts with the stateful parts: connection draining must complete before an instance exits, or every deploy serves errors (Module A-5), and a database failover during a deploy is a genuinely unpleasant combination worth avoiding by not deploying during maintenance windows.
Why this matters in production: the migration incidents that happen are a two-millisecond ALTER TABLE that made a table unavailable for eight minutes because it queued behind a report; a CREATE INDEX without CONCURRENTLY that blocked writes for forty minutes; a CREATE INDEX CONCURRENTLY that failed inside a framework's transaction wrapper and left an invalid index nobody noticed for a month; a single-statement backfill that pushed replica lag to twenty minutes and broke read-your-own-writes across the product; and a column rename shipped in one deploy that broke every instance still running the previous version. Each has a specific preventive named above, and lock_timeout alone prevents the largest share.
Where This Shows Up in Your Stack
PostgreSQL:SET lock_timeout before every DDL statement, with a retry loop — the single highest-value practice here. Check pg_stat_activity for long and idle in transaction sessions first. ADD COLUMN, DROP COLUMN, RENAME and ADD CONSTRAINT … NOT VALID are metadata-only; VALIDATE CONSTRAINT scans under a weak lock; CREATE INDEX CONCURRENTLY cannot run in a transaction and can leave an INVALID index; pg_repack reclaims bloat without VACUUM FULL's exclusive lock. Run migrations as a dedicated role with its own statement_timeout (Module A-13), and consider pgroll for genuine two-version schema presentation.
Node.js migration frameworks: most wrap each migration in a transaction, which breaks CREATE INDEX CONCURRENTLY — check for the framework's opt-out and make sure the setting is actually applied. Set lock_timeout in the migration's session, not globally. And handle SIGTERM so a rolling deploy drains rather than dropping in-flight requests (Module A-5).
Kubernetes: rolling updates with maxSurge/maxUnavailable, readiness gates so an unready pod receives no traffic, a preStop delay so deregistration propagates, and a terminationGracePeriodSeconds longer than your longest request (Module A-5). Run migrations as a separate Job that completes before the new version rolls out — not as an init container on every pod, which runs it N times concurrently.
Kafka and event consumers: during a rolling deploy both consumer versions are in the group, so message formats must be forward and backward compatible and a schema registry should reject incompatible changes (Module P-23). Adding a required field is a breaking change for the consumers still running.
Feature-flag services: locally cached with compiled-in defaults so they cannot become a hard dependency (Module A-7), with flag age tracked and release toggles removed as part of the work.
Caches: version the key when the value's shape changes, or the new code deserialises an old entry and fails on a fraction of requests that shrinks as the TTL expires — an unusually confusing symptom (Module P-13).
Summary
Deploy is not release. Separating them with flags means you ship code dark, verify it, and expose it deliberately — and your fastest rollback becomes a configuration change in seconds rather than a redeploy in minutes.
Rolling is cheap and slow to reverse; blue-green gives the fastest rollback at double capacity and solves nothing about the database, since both environments share it; canary detects risk on 1% of users (watch for insufficient events to be significant, and for users bouncing between versions); shadow traffic validates performance before any user is affected and is only safe on read-only or genuinely idempotent paths.
Every strategy runs two versions at once, so version N must be compatible with N−1 in both directions — additive API responses, requests that accept the old shape, tolerant event readers, versioned cache keys, and a schema both can read and write. Most "the deploy broke things for four minutes" incidents are this rule being violated.
Feature flags are four different things with different lifetimes, and release toggles are debt with a due date — n flags give 2ⁿ untested paths, so track their age and delete them as part of the work. The flag service must be locally cached with compiled defaults so it is never a hard dependency.
Expand/contract is the only pattern where each step is reversible: expand, migrate/backfill, switch reads, contract — with the contract as the point of no return, taken days later, not in the same release. A column rename is six steps; ALTER TABLE RENAME breaks every running old instance instantly.
Use NOT VALID then VALIDATE for constraints, which separates the strong lock from the full scan so neither is held long and strong at once.
The dominant cause of migration outages is lock queueing, not table rewriting. An ACCESS EXCLUSIVE request that waits behind one long-running SELECT causes every subsequent query on that table to queue behind it, because locks are granted in order — so a two-millisecond ALTER TABLE can make a table unavailable for eight minutes.
SET lock_timeout before every DDL statement, and retry with backoff. Failing the migration is vastly better than blocking production, and this one setting prevents the largest share of migration incidents. Also check pg_stat_activity for long and idle in transaction sessions first, and do not migrate during a reporting window.
On modern Postgres most schema changes are metadata-only — ADD COLUMN (including with a non-volatile default since PG 11), DROP COLUMN, renames, ADD CONSTRAINT … NOT VALID — which is precisely why waiting, rather than rewriting, is what hurts. Type changes, SET NOT NULL, VACUUM FULL and CLUSTER do rewrite.
CREATE INDEX CONCURRENTLY has three sharp edges: it takes about twice as long, it cannot run inside a transaction (which most migration frameworks impose by default), and a failure leaves an INVALID index that still costs write overhead until someone finds and drops it. pg_repack reclaims bloat without VACUUM FULL's exclusive lock.
View-based tooling (pgroll, Reshape) presents two schema versions simultaneously, making expand/contract a property of the tool and rollback a view swap. gh-ost and pt-online-schema-change are MySQL tools whose shadow-table approach exists because MySQL's online DDL was historically weak — useful as an explanation of the problem, and the wrong template for Postgres, which already provides transactional DDL, metadata-only operations, concurrent index builds, and NOT VALID/VALIDATE.
A single-statement backfill over hundreds of millions of rows fails four ways at once: a snapshot held for hours blocking vacuum database-wide, a WAL burst that spikes replica lag and CDC slot growth and can fill disk, mass lock contention, and no resumability. Chunk it, commit each chunk, persist a cursor, make the predicate idempotent, and throttle on replica lag as the control signal so the backfill paces itself.
Rollback is asymmetric: code rolls back by redeploying an artefact, schema usually cannot — you cannot un-drop a column with data. So never ship a schema change together with the code depending on it, and prefer rolling forward for data problems, because the reverse path was never designed.
The next module, Load Testing and Capacity Planning, is where the numbers behind every limit in this course come from. Rate limits, concurrency caps, shed thresholds, pool sizes and autoscaling targets have all been described as "derived from measured capacity" — O-5 is where that measurement happens, including how to find the knee of the curve, why a soak test finds different bugs from a spike test, and how to build a capacity model that survives a launch.
Knowledge Check
A team runs ALTER TABLE orders ADD COLUMN channel text; against a 4 TB table on Postgres 15. The statement itself completes in under 10 ms once it starts, but the application is down for roughly seven minutes and the connection pool is exhausted throughout. What happened, and what single change prevents it?
A migration needs to rename users.email to users.email_address with no downtime, on a system deployed via rolling updates. An engineer proposes: deploy the code referencing the new name, then immediately run ALTER TABLE users RENAME COLUMN email TO email_address so the window of mismatch is minimal. What does the module say?
A backfill copies a value into a new column across 400 million rows using a single UPDATE. It runs for three hours before being cancelled. Afterwards: replica lag peaked at 22 minutes, several unrelated tables have significant bloat, the CDC replication slot retained a large amount of WAL, and no rows were updated. What does the module prescribe, and which throttling signal does it recommend?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
EXPAND add the new structure, backward compatible ← reversible
MIGRATE write both / backfill existing rows ← reversible
SWITCH read from the new structure ← reversible
CONTRACT remove the old structure ← THE POINT OF NO RETURN
ALTERTABLE users RENAMECOLUMN email TO email_address;-- old version breaks INSTANTLY
1. EXPAND ALTER TABLE users ADD COLUMN email_address text; -- nullable, metadata-only
2. DEPLOY application writes BOTH columns, reads the old one
3. BACKFILL chunked, throttled copy of email → email_address -- see the backfill section
4. DEPLOY application reads the new column, still writes both
5. DEPLOY application writes only the new column
6. CONTRACT ALTER TABLE users DROP COLUMN email; -- days later
-- 1. Add nullable (metadata-only on PG 11+, even with a default).ALTERTABLE orders ADDCOLUMN channel text;-- 2. Backfill in chunks.-- 3. Add the constraint UNVALIDATED — a brief lock, no scan.ALTERTABLE orders ADDCONSTRAINT orders_channel_nn CHECK(channel ISNOTNULL)NOT VALID;-- 4. Validate separately — scans, but takes only a SHARE UPDATE EXCLUSIVE lock,-- so reads and writes continue.ALTERTABLE orders VALIDATE CONSTRAINT orders_channel_nn;
t=0 A long-running SELECT starts (an analytics query, an idle-in-transaction
session, a forgotten psql window). Holds ACCESS SHARE for 8 minutes.
t=1 ALTER TABLE orders ADD COLUMN … arrives.
Needs ACCESS EXCLUSIVE. Cannot get it. WAITS.
t=2 A normal SELECT arrives.
It needs only ACCESS SHARE — which does NOT conflict with the running query.
But Postgres grants locks in ORDER, and there is now an ACCESS EXCLUSIVE
request ahead of it in the queue. → IT WAITS TOO.
t=3.. Every subsequent query on that table queues behind the DDL.
↓
The table is effectively unavailable for 8 minutes,
and the DDL statement itself would have taken 2 milliseconds.
lock queue on `orders`:
[ACCESS SHARE ] ← running, 8 minutes remaining
[ACCESS EXCL ] ← the migration, waiting
[ACCESS SHARE ] ← blocked by the one above it, though it conflicts with nothing running
[ACCESS SHARE ] ← blocked
[ACCESS SHARE ] ← blocked … connection pool exhausts (Module P-4), app times out
SET lock_timeout ='2s';-- fail fast rather than queueALTERTABLE orders ADDCOLUMN channel text;-- On failure: back off with jitter and retry (Module A-6).
UPDATE users SET email_address = email WHERE email_address ISNULL;-- 400M rows
let cursor =0n;for(;;){const{ rows }=await db.query(`UPDATE users SET email_address = email
WHERE id IN (SELECT id FROM users WHERE id > $1 AND email_address IS NULL
ORDER BY id LIMIT 5000)
RETURNING id`,[cursor]);if(rows.length ===0)break; cursor = rows.at(-1)!.id;awaitsaveCursor(cursor);// resumable (Module P-19)// Throttle on the signal that actually matters:while(awaitreplicaLagSeconds()>5)awaitsleep(1_000);}