Migrations: changing a schema without downtime
Changing an empty schema is free. Changing one with ten million rows and live traffic is where people take production down, and they do it with statements that look completely harmless.
The thing to understand: the danger is almost never the work. It is the lock.
Migrations as files
Never change production with psql by hand. Every change is a numbered, reviewed, committed
file:
migrations/
001_create_customers.sql
002_create_subscriptions.sql
003_add_route_to_customers.sql
The tool does not much matter — Alembic, Django migrations, Flyway, golang-migrate, Prisma Migrate, Knex. What matters:
Ordered and recorded. The tool tracks which have run, in a table in the database itself.
In version control, reviewed like code. A migration is the most dangerous code in your repository and deserves the most careful review.
Forwards only, in practice. Most tools support a down migration. In production you will
almost never run one — rolling back a schema change after new data has arrived usually means
losing that data. Write the down for local development, and treat a bad migration as
something you fix with a new forward migration.
Tested against production-like data. A migration that runs in 200ms on your 500-row dev database can take 40 minutes on 50 million rows. Restore a production backup and time it. This is the single most valuable habit in this lesson.
The lock table you need
This is the reference. ACCESS EXCLUSIVE blocks everything, including SELECT.
| Operation | Lock | Safe on a big live table? |
|---|---|---|
ADD COLUMN (no default) |
ACCESS EXCLUSIVE, instant | Yes |
ADD COLUMN ... DEFAULT <constant> |
ACCESS EXCLUSIVE, instant (PG 11+) | Yes |
ADD COLUMN ... DEFAULT <volatile> |
ACCESS EXCLUSIVE, rewrites table | No |
ADD COLUMN ... NOT NULL (no default) |
ACCESS EXCLUSIVE | No — fails if rows exist |
DROP COLUMN |
ACCESS EXCLUSIVE, instant | Yes (space reclaimed later) |
ALTER COLUMN TYPE |
ACCESS EXCLUSIVE, usually rewrites | No |
SET NOT NULL |
ACCESS EXCLUSIVE, full scan | No, unless a valid CHECK exists |
ADD CONSTRAINT CHECK |
ACCESS EXCLUSIVE, full scan | No — use NOT VALID |
ADD FOREIGN KEY |
locks both tables, full scan | No — use NOT VALID |
CREATE INDEX |
blocks writes | No — use CONCURRENTLY |
CREATE INDEX CONCURRENTLY |
allows writes | Yes |
RENAME COLUMN |
ACCESS EXCLUSIVE, instant | Lock is fine; your code is not |
ADD COLUMN ... UNIQUE |
builds an index while locked | No — split it |
The pattern: anything that scans or rewrites the table while holding ACCESS EXCLUSIVE is
dangerous. Anything instant is fine.
Measured on a table of one million rows, so the differences are not theoretical:
| Statement | Time |
|---|---|
ADD COLUMN x text |
0.43 ms |
ADD COLUMN y text NOT NULL DEFAULT '' |
0.53 ms |
ADD COLUMN w uuid DEFAULT gen_random_uuid() |
1,467 ms |
ADD CONSTRAINT CHECK (v >= 0) |
57.7 ms |
ADD CONSTRAINT CHECK (…) NOT VALID |
0.56 ms |
VALIDATE CONSTRAINT (weak lock) |
36.1 ms |
ADD CONSTRAINT UNIQUE (s) |
1,247 ms |
Two things to read out of that. A constant default is instant — PostgreSQL 11 stopped
rewriting the table for those, and a lot of advice on the internet predates it. A volatile
default is 2,700× slower, because every row genuinely needs a different value. And
NOT VALID turns a 57 ms exclusive scan into a 0.56 ms metadata change plus a 36 ms scan under a
lock that blocks nobody.
Scale those to a hundred million rows and the safe ones are still sub-millisecond while the others are minutes.
And the error you get from ADD COLUMN z text NOT NULL with no default, which is the fourth row
of the table above:
ERROR: column "z" of relation "t" contains null values
The lock queue, which is the real trap
Here is the thing that catches everyone, and it is worth reading twice.
A migration needing ACCESS EXCLUSIVE must wait for existing queries to finish. While it
waits, it queues — and every new query touching that table queues behind it.
1. A long analytics SELECT on `orders` is running (20 minutes left)
2. Your ALTER TABLE orders asks for ACCESS EXCLUSIVE → waits
3. Every subsequent query on `orders` queues behind your ALTER
4. Every page that reads orders stops responding
Your instant migration just caused a 20-minute outage, and it had not started yet. The table was never rewritten. The lock queue did it.
Note the precise scope: queries on that table, not every query on the server. A SELECT that
never touches orders sails past. That is small comfort when orders is your busiest table, but
it is worth being accurate about — I over-stated this to myself first, built the demo, and found
an unrelated SELECT 1 completely unaffected.
Here is the demo, with three sessions against a one-million-row table:
| Session | Duration |
|---|---|
1. BEGIN; SELECT count(*) FROM t; pg_sleep(8); |
8.1 s |
2. ALTER TABLE t ADD COLUMN notes text — the "instant" one |
6.6 s |
3. SELECT id FROM t LIMIT 1 — started 1.5 s after the ALTER |
5.1 s |
Session 3 is the point. It is a trivial indexed read that should take under a millisecond, and it took five seconds, because it was queued behind a migration that does no work at all.
The defence, and it is essential:
SET lock_timeout = '3s';
ALTER TABLE customers ADD COLUMN notes text;
Put lock_timeout at the top of every migration that takes a strong lock. Repeating the
experiment with lock_timeout = '2s':
| Session | Duration |
|---|---|
1. the long SELECT |
8.1 s |
2. the ALTER |
2.1 s — ERROR: canceling statement due to lock timeout |
3. the trivial SELECT |
0.1 s |
The migration failed and nothing else noticed. Failing is the correct outcome — you retry when the table is quiet, and your users never find out.
statement_timeout is a different guard: it limits how long the statement runs once it has
the lock. Both are useful; lock_timeout is the one that prevents outages.
And before a migration, look:
SELECT pid, now() - query_start AS duration, state, left(query, 60)
FROM pg_stat_activity
WHERE state <> 'idle' AND now() - query_start > interval '30 seconds'
ORDER BY duration DESC;
Safe recipes
Adding a NOT NULL column
Not in one step. Four:
-- 1. add it nullable (instant)
ALTER TABLE customers ADD COLUMN notes text;
-- 2. backfill in batches, so no single transaction is long
UPDATE customers SET notes = '' WHERE notes IS NULL AND id BETWEEN 1 AND 10000;
-- repeat, or loop in application code
-- 3. add a NOT VALID check (instant, no scan)
ALTER TABLE customers ADD CONSTRAINT notes_not_null CHECK (notes IS NOT NULL) NOT VALID;
-- 4. validate it (scans, but takes only a SHARE UPDATE EXCLUSIVE lock)
ALTER TABLE customers VALIDATE CONSTRAINT notes_not_null;
NOT VALID then VALIDATE is the key trick of this lesson. NOT VALID adds the constraint
instantly and enforces it on all new rows; VALIDATE checks the existing ones under a weak
lock that does not block reads or writes.
On PostgreSQL 12+ you can then convert it to a real SET NOT NULL cheaply, because the planner
uses the validated CHECK to skip the scan.
Why batch the backfill? A single UPDATE of ten million rows is one enormous transaction: it
holds locks on every row it touches, bloats the table with dead tuples, and generates a WAL
spike that can stall replication. Batch, and commit between batches.
Adding a foreign key
ALTER TABLE deliveries ADD CONSTRAINT fk_staff
FOREIGN KEY (staff_id) REFERENCES staff(id) NOT VALID;
ALTER TABLE deliveries VALIDATE CONSTRAINT fk_staff;
Same pattern. And index the referencing column first, CONCURRENTLY, or the validation scan is
much worse.
Changing a column's type
Some type changes are free because the on-disk representation is unchanged — varchar(50) to
varchar(100), varchar to text. Most are not, and int to bigint on a big table rewrites
it entirely.
The safe route is the new-column dance:
ALTER TABLE payments ADD COLUMN amount_paise_new bigint;
CREATE FUNCTION sync_amount() RETURNS trigger AS $$
BEGIN NEW.amount_paise_new := NEW.amount_paise; RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_amount BEFORE INSERT OR UPDATE ON payments
FOR EACH ROW EXECUTE FUNCTION sync_amount();
-- backfill in batches, then:
BEGIN;
ALTER TABLE payments RENAME COLUMN amount_paise TO amount_paise_old;
ALTER TABLE payments RENAME COLUMN amount_paise_new TO amount_paise;
COMMIT;
Laborious, and the reason bigint is the right default for an id in the first place. Running
out of int on a primary key at 2.1 billion rows is a genuinely bad day, and it has happened
to well-known companies.
Renaming anything
The lock is instant. Your deployed code is the problem. The moment you rename, every running instance referring to the old name breaks — and during a rolling deploy both versions are live.
So: do not rename in one step. Add the new name, write to both, migrate readers, then drop the old one. Or simply accept the old name. An imperfect column name is cheaper than an outage, and this is the migration people most often regret attempting.
Adding a unique constraint
CREATE UNIQUE INDEX CONCURRENTLY customers_phone_key ON customers (phone);
ALTER TABLE customers ADD CONSTRAINT customers_phone_unique
UNIQUE USING INDEX customers_phone_key;
ALTER TABLE ... ADD UNIQUE builds the index while holding ACCESS EXCLUSIVE. Building it
CONCURRENTLY first and then adopting it makes the second step instant. USING INDEX is worth
remembering.
Dropping a column
The lock is instant, and PostgreSQL only marks it dead — space comes back on a later rewrite.
But deploy the code that stops using it first. A SELECT * from an old instance against a
dropped column is an error. Two deploys, in order: code, then schema.
The expand–contract pattern
The general principle behind all of the above, and the one thing to remember if you remember nothing else:
1. EXPAND add the new thing. Old and new both work.
2. MIGRATE write to both; backfill; move readers to the new thing.
3. CONTRACT remove the old thing, once nothing uses it.
Three deploys instead of one. Every step is independently reversible, and at no point is the schema incompatible with the code that is running — which is the actual requirement during a rolling deploy, where two versions of your application are live at once.
It is slower. It is why zero-downtime deployment works at all.
A migration checklist
Before you run one against production:
- Timed against a restored production-sized copy.
-
SET lock_timeoutat the top. - Any scan or rewrite split with
NOT VALID/CONCURRENTLY/ batching. - Backfills batched and committed between batches.
- Compatible with the currently deployed code, and with the next version.
- Reviewed by somebody else.
- Long-running queries checked in
pg_stat_activity. - A recent backup, and you know how long a restore takes.
- A plan for what you do if it fails halfway.
The last one is the one people skip, and it is the difference between a bad ten minutes and a bad weekend.
Check your work
Where the danger in a migration usually is. The lock, not the work.
Four properties of a good migration process. Ordered and recorded, reviewed in version control, forwards-only in practice, timed against production-sized data.
Why down migrations are rarely run in production. Rolling back a schema change after new
data has arrived loses data.
What ACCESS EXCLUSIVE blocks. Everything, including SELECT.
The lock queue problem. A waiting strong lock makes every later query on that table queue
behind it, so an instant migration can cause a long outage. Measured: a trivial SELECT took
5.1 s, and 0.1 s once lock_timeout was set.
Why a constant default is safe but a volatile one is not. A constant is stored as metadata (PostgreSQL 11+); a volatile default needs a distinct value per row, so the table is rewritten — measured at 0.53 ms against 1,467 ms.
The one-line defence. SET lock_timeout at the top of the migration.
The difference between lock_timeout and statement_timeout. Waiting for the lock versus
running with it.
The four steps to add a NOT NULL column. Nullable, batched backfill, NOT VALID check,
VALIDATE.
What NOT VALID buys. The constraint applies to new rows instantly; validation of old rows
happens later under a weak lock.
Why backfills are batched. One huge transaction holds locks, bloats the table and spikes WAL.
Why bigint for an id. Converting int to bigint later rewrites the whole table.
Why renaming is dangerous even though the lock is instant. Deployed code refers to the old name, and during a rolling deploy both versions are live.
How to add a unique constraint safely. CREATE UNIQUE INDEX CONCURRENTLY, then
ADD CONSTRAINT ... USING INDEX.
The order for dropping a column. Code first, then schema.
The three phases of expand–contract. Expand, migrate, contract — each independently reversible.
Practice
- Set up a migration tool in a project and create two migrations.
- Find the table it uses to track which have run, and read it.
- Load a million rows. Time
ADD COLUMN x textandADD COLUMN y text NOT NULL DEFAULT ''. - Try
ADD COLUMN z text NOT NULLwith no default and read the error. - Try
ADD COLUMN w text DEFAULT gen_random_uuid()and time it. Explain the difference. - The lock queue: open a session,
BEGIN; SELECT * FROM t LIMIT 1;and leave it open. In a second session run anALTER TABLE. In a third, run aSELECT. Watch the third block. - Repeat with
SET lock_timeout = '2s'and confirm theALTERfails instead. - Query
pg_locksjoined topg_stat_activityduring that experiment and identify the blocker. - Add a
NOT NULLcolumn with all four steps. Time step 3 and step 4 separately. - Add a
CHECKconstraint normally on a million rows, thenNOT VALID. Compare the times. - Add a foreign key
NOT VALID, insert a violating row, and confirm it is rejected. - Do the new-column dance to widen an
inttobigintwithout downtime. - Add a unique constraint the slow way and the
USING INDEXway. Compare the lock durations. - Write a three-step expand–contract plan for renaming
customers.phonetocustomers.mobile, listing what deploys between each. - Restore a production-sized backup and time your riskiest migration against it.
Official documentation
- PostgreSQL — ALTER TABLE — The Notes section says exactly which operations require a rewrite. Read it before every risky migration.
- PostgreSQL — Explicit locking — The lock modes and the conflict matrix that the table in this lesson summarises.
- PostgreSQL — Client connection defaults —
lock_timeoutandstatement_timeout. - PostgreSQL — Monitoring: pg_locks — How to find what is blocking your migration.
- PostgreSQL — CREATE INDEX — The
CONCURRENTLYsection.
Next: the mistakes that are expensive to undo.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship