Database migrations at scale
What a schema change really costs
The idea in 30 seconds
Every ALTER TABLE does one of three things to a big table: changes only the catalog (instant), scans every row to check something (seconds), or rewrites the whole table and its indexes (minutes to hours). Most of them hold a lock that stops all writes, and often reads, while they work. Safe migrations pick the instant form, split a check off into a step that does not block writes, and change things in stages so old and new code both keep working.
Helps to know first: Database migrations and schema evolution (coming soon), Locks and locking strategies (coming soon), Database reliability engineering
On this page
Three kinds of change
On a small table every migration is fast, which is why problems show up only in production. On a big one, each ALTER TABLE does one of three things:
- Catalog only. PostgreSQL updates its own description of the table and touches no rows. Milliseconds, at any size.
- Scan. It reads every row to check something, such as that no value is
NULL. Time grows with the table. - Rewrite. It writes a new copy of the whole table and rebuilds every index. Time grows with the table and its indexes, and you need room for both copies on disk.
The catch is the lock. Unless the documentation says otherwise, ALTER TABLE takes ACCESS EXCLUSIVE: no reads, no writes, for as long as the scan or rewrite takes.
We measured each change on a 5-million-row table (287 MB, plus its primary key), PostgreSQL 18.3 on an Apple M1 Pro laptop. Medians of three runs:
| Change | What happens | Time | Writes blocked? |
|---|---|---|---|
ADD COLUMN note text | Catalog | 3 ms | Briefly |
ADD COLUMN currency text NOT NULL DEFAULT 'INR' | Catalog: default stored once | 2 ms | Briefly |
ALTER COLUMN status TYPE varchar(50) (was varchar(20)) | Catalog | under 1 ms | Briefly |
ALTER COLUMN status TYPE text | Catalog | under 1 ms | Briefly |
ALTER COLUMN customer_id SET NOT NULL | Scan | 258 ms | Yes |
ADD CONSTRAINT ... CHECK (amount >= 0) | Scan | 348 ms | Yes |
ADD CONSTRAINT ... CHECK (...) NOT VALID | Catalog | 1 ms | Briefly |
VALIDATE CONSTRAINT | Scan | 364 ms | No |
ADD COLUMN imported_at timestamptz DEFAULT clock_timestamp() | Rewrite | 8.3 s | Yes |
ALTER COLUMN id TYPE bigint | Rewrite | 13.2 s | Yes |
On a 500 GB table, multiply the scans and rewrites by a few thousand.
PredictWhich default rewrites?
Two migrations each add a column to the same 5-million-row table: one with DEFAULT 'INR', one with DEFAULT clock_timestamp(). Both give every existing row a value. Why does one take 2 ms and the other 8 seconds?
'INR' is the same for every row, so PostgreSQL computes it once and stores it in the table’s metadata; existing rows return it when read, and nothing on disk changes. clock_timestamp() is volatile: it returns a different value each call, so each row needs its own stored value, and that means writing every row again. The same applies to random(), gen_random_uuid() and any function not marked STABLE or IMMUTABLE.
The usual safe version: add the column with no default (or a constant one), then fill it in batches.
Split the check from the lock
Constraints are where the biggest wins hide. Adding a CHECK normally scans the table while holding ACCESS EXCLUSIVE. Adding it NOT VALID skips the scan: new and updated rows are checked from that moment, existing rows are not yet. A separate VALIDATE CONSTRAINT then scans, holding only SHARE UPDATE EXCLUSIVE, which does not conflict with inserts, updates or deletes.
ALTER TABLE payments ADD CONSTRAINT amount_positive CHECK (amount >= 0) NOT VALID; -- instant
ALTER TABLE payments VALIDATE CONSTRAINT amount_positive; -- scans, writes continue
The same works for foreign keys. PostgreSQL 18 extends it to NOT NULL, which previously needed a workaround:
ALTER TABLE payments ADD CONSTRAINT amount_not_null NOT NULL amount NOT VALID; -- PostgreSQL 18+
ALTER TABLE payments VALIDATE CONSTRAINT amount_not_null;
On older versions: add CHECK (amount IS NOT NULL) NOT VALID, validate it, then SET NOT NULL. That last step skips its scan because a valid CHECK already proves there are no nulls.
Indexes have their own version: CREATE INDEX CONCURRENTLY builds without blocking writes. It takes longer and cannot run inside a transaction block, and if it fails it leaves an invalid index you must drop and retry. In our runs it took 2.1 s instead of 1.3 s, while an insert during the build finished in under a millisecond instead of waiting 1.2 s for the plain build.
Change in stages: expand, migrate, contract
Some changes cannot be made in place at all without breaking running code. Renaming a column is instant for the database, but every application server still running the old code starts failing the moment it happens. The pattern for these is expand, migrate, contract:
- Expand. Add the new thing alongside the old: a new column, table or index. Old code ignores it.
- Migrate. Deploy code that writes both and reads the new one. Backfill existing rows in batches.
- Contract. Once nothing uses the old thing, remove it in a later deploy.
Each step is backwards-compatible with the code running at the time, so any of them can be deployed, and rolled back, on its own. It is slower: a rename becomes three deploys. That is the price of never having a moment where old code and new schema disagree.
Check yourself
Why is
ALTER COLUMN id TYPE biginta rewrite, while changingvarchar(20)totextis not?Show answer
intandbigintare stored differently on disk (4 bytes against 8), so every row must be rewritten in the new format.varchar(20)andtextare stored the same way, so only the catalog changes.What exactly does
NOT VALIDchange about adding aCHECKconstraint?Show answer
It skips the scan of existing rows, so the
ALTER TABLEholdingACCESS EXCLUSIVEis instant. New and updated rows are checked straight away. Existing rows are checked later byVALIDATE CONSTRAINT, which takes a lock that does not block writes.Why does renaming a column need three deploys under expand, migrate and contract?
Show answer
Old application code still uses the old name. Adding the new column, switching code to it and backfilling, and only then dropping the old column keeps every running version of the code working at each step.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: ALTER TABLEPrimary· Description; Notes
ACCESS EXCLUSIVE is taken unless noted; a non-volatile DEFAULT is stored in metadata without a rewrite; a volatile DEFAULT or most type changes rewrite the table and its indexes; binary-coercible type changes do not; SET NOT NULL scans unless a valid CHECK proves no NULLs; NOT VALID skips the scan; VALIDATE CONSTRAINT takes SHARE UPDATE EXCLUSIVE; ADD FOREIGN KEY takes SHARE ROW EXCLUSIVE.
- 2.PostgreSQL documentation: CREATE INDEXPrimary· Building Indexes Concurrently
A normal index build blocks inserts, updates and deletes until it finishes; CONCURRENTLY does not, but needs two table scans and more total time, cannot run in a transaction block, and leaves an invalid index behind if it fails.
- 3.PostgreSQL documentation: E.4 Release 18Primary· Changes: Utility Commands: Constraints
PostgreSQL 18 allows NOT NULL constraints to be added as NOT VALID.
- 4.PostgreSQL documentation: 13.3 Explicit LockingPrimary· §13.3.1 Table-Level Locks
SHARE UPDATE EXCLUSIVE does not conflict with ROW EXCLUSIVE, the lock taken by INSERT, UPDATE and DELETE.
- 5.ParallelChangeSecondaryDanilo Sato (on martinfowler.com)· whole article
The expand, migrate and contract phases; most database refactorings follow this pattern.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: ALTER TABLEPrimary· Notes
Three paragraphs that say exactly which changes rewrite, which scan and which are free. Re-read them before any migration on a big table.
- 2.ParallelChangeSecondaryDanilo Sato (on martinfowler.com)
The general pattern behind every zero-downtime schema change, in a short article.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.