Changing a Postgres schema without taking the app down: expand and contract.
Most outages from a migration come from locks, not from the change itself. Set lock_timeout so a blocked ALTER fails instead of queueing every query behind it. Build indexes CONCURRENTLY, add constraints NOT VALID and validate them later, backfill in small batches, and rename by adding the new column first and dropping the old one several deploys later.
Facts checked
Which change takes which lock
Every ALTER TABLE takes a lock. The Postgres docs say ACCESS EXCLUSIVE is taken "unless explicitly noted", and that it is the only mode that blocks a plain SELECT. What matters is how long the lock is held: a catalog-only change holds it for milliseconds, a scan or rewrite holds it for as long as the table takes to read.
| Change | Lock (Postgres docs) | Held for | Do this instead |
|---|---|---|---|
ADD COLUMN nullable, no default † | ACCESS EXCLUSIVE | Milliseconds (catalog only) | Safe. Still set lock_timeout (below). |
ADD COLUMN … DEFAULT 'x' (constant) † † | ACCESS EXCLUSIVE | Milliseconds since Postgres 11: the default is stored in the catalog, no rewrite | Safe, NOT NULL included. |
ADD COLUMN … DEFAULT clock_timestamp() (volatile) † | ACCESS EXCLUSIVE | A full rewrite of the table and its indexes | Add the column with no default, set the default in a second statement, backfill old rows in batches. |
ALTER COLUMN … TYPE † | ACCESS EXCLUSIVE | A full rewrite, unless the old type is binary-coercible to the new one (varchar to text) | New column, write to both, backfill, switch, drop the old one. |
SET NOT NULL † † | ACCESS EXCLUSIVE | A full scan | A CHECK … NOT VALID, then VALIDATE, then SET NOT NULL (or a NOT NULL … NOT VALID constraint on Postgres 18). |
ADD CONSTRAINT … CHECK † | ACCESS EXCLUSIVE | A full scan | Add it NOT VALID, then VALIDATE CONSTRAINT, which takes only SHARE UPDATE EXCLUSIVE. |
ADD FOREIGN KEY † | SHARE ROW EXCLUSIVE on both tables (blocks writes) | A full scan | Add it NOT VALID, then VALIDATE CONSTRAINT. |
CREATE INDEX † † | SHARE (reads continue, writes wait) | The whole build | CREATE INDEX CONCURRENTLY: SHARE UPDATE EXCLUSIVE, writes continue. |
RENAME COLUMN, DROP COLUMN † | ACCESS EXCLUSIVE | Milliseconds | The lock is fine. The old code is not: see expand and contract. |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. `ADD COLUMN` nullable, no default (2026-10-29) · `ADD COLUMN … DEFAULT 'x'` (constant) (2026-10-29) · `ADD COLUMN … DEFAULT 'x'` (constant) (2026-10-29) · `ADD COLUMN … DEFAULT clock_timestamp()` (volatile) (2026-10-29) · `ALTER COLUMN … TYPE` (2026-10-29) · `SET NOT NULL` (2026-10-29) · `SET NOT NULL` (2026-10-29) · `ADD CONSTRAINT … CHECK` (2026-10-29) · `ADD FOREIGN KEY` (2026-10-29) · `CREATE INDEX` (2026-10-29) · `CREATE INDEX` (2026-10-29) · `RENAME COLUMN`, `DROP COLUMN` (2026-10-29)
The lock queue: why a fast ALTER can freeze the site
An ALTER TABLE that holds its lock for two milliseconds can still stop every query on the table for minutes. It has to wait for transactions already using the table to finish. While it waits, new queries that need any conflicting lock, plain SELECTs included, queue behind it. One report or job holding a transaction open for five minutes becomes a five-minute outage for that table. Xata and Postgres.ai both walk through it step by step.
The fix is lock_timeout. Postgres aborts any statement that waits longer than that for a lock, so a blocked migration fails and the queue behind it drains at once. The docs note the limit "applies separately to each lock acquisition attempt".
- Pick a short value. A few seconds is plenty for catalog-only changes. If it fails, find the blocker and run the migration again.
- Set it per session, not server-wide. The Postgres docs advise against setting
lock_timeoutinpostgresql.conf"because it would affect all sessions", your app included. - Find what is in the way before you retry, with the query in the next section.
-- At the top of each migration that touches a busy table
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN status_code text;Find the blocking session
pg_blocking_pids lists the sessions holding a lock that a given session waits for. Run it while the migration is stuck, or right before a deploy that touches a busy table.
-- Who has had a transaction open the longest, and who is waiting on whom
SELECT pid, pg_blocking_pids(pid) AS blocked_by,
now() - xact_start AS open_for, state, left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;NOT NULL and CHECK constraints without a long lock
NOT VALID adds the constraint without checking existing rows, so it is instant, and new writes are checked from then on. VALIDATE CONSTRAINT scans the table later under SHARE UPDATE EXCLUSIVE, which lets reads and writes continue. For SET NOT NULL, the Postgres docs say the scan is skipped when a valid CHECK constraint already proves no NULL can exist.
-- Postgres 17: a CHECK constraint proves it, SET NOT NULL then skips the scan
ALTER TABLE orders ADD CONSTRAINT orders_status_nn
CHECK (status_code IS NOT NULL) NOT VALID; -- instant
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_nn; -- scans, writes keep going
ALTER TABLE orders ALTER COLUMN status_code SET NOT NULL; -- no scan
ALTER TABLE orders DROP CONSTRAINT orders_status_nn;
-- Postgres 18: NOT NULL itself can be NOT VALID
ALTER TABLE orders ADD CONSTRAINT orders_status_nn NOT NULL status_code NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_nn;Indexes: CONCURRENTLY, in a migration of its own
A plain CREATE INDEX lets reads continue but makes every INSERT, UPDATE and DELETE on the table wait until the build ends. CONCURRENTLY lets writes continue at the price of two table scans and waits for older transactions, so it takes longer overall.
Two rules from the Postgres docs. It cannot run inside a transaction block, so it needs a migration that is not wrapped in one. And if it fails (a deadlock, a duplicate value for a unique index), it leaves an INVALID index that queries ignore but writes still maintain. Drop it and run again, or REINDEX INDEX CONCURRENTLY.
It waits for every transaction that might use the table, so a short lock_timeout from an earlier migration in the same session can cancel it. Turn the timeout off for this one statement, as below.
-- Its own migration, outside a transaction
-- (Rails: disable_ddl_transaction!, Django: atomic = False)
SET lock_timeout = 0;
CREATE INDEX CONCURRENTLY orders_customer_id_idx ON orders (customer_id);
-- If it failed, it left an INVALID index behind. Drop it and run again:
DROP INDEX CONCURRENTLY IF EXISTS orders_customer_id_idx;Foreign keys
ADD FOREIGN KEY takes SHARE ROW EXCLUSIVE on both the table and the table it references, which blocks writes to both while it scans. Add it NOT VALID and validate in a second step; validation takes SHARE UPDATE EXCLUSIVE on your table and ROW SHARE on the referenced one.
ALTER TABLE orders ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID;
-- A later migration (or the same one, after the first commits):
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;Renames and type changes: expand and contract
A rename is instant in Postgres and still breaks production, because during the deploy the old code is running and asking for the old name. Expand and contract keeps every deploy compatible with the code before it. To rename orders.status to status_code:
- Deploy 1, expand. The migration adds
status_code. The code writes both columns and still readsstatus. - Backfill the old rows in batches (next section).
- Deploy 2. The code reads
status_codeand still writes both. - Deploy 3. The code stops writing
status. In Rails, add it toignored_columnsso Active Record forgets it. - Deploy 4, contract. The migration drops
status. The version still serving while it runs is deploy 3, which no longer touches it.
Backfills in batches
One UPDATE over ten million rows is one long transaction: it holds row locks on everything it touched until the end, and leaves ten million dead rows for vacuum. Small batches that each commit keep locks short and let autovacuum keep up.
- Start writing both columns first (deploy 1 above), or rows created during the backfill come back
NULL. - Batch on an indexed key.
ORDER BY id LIMIT 5000on the primary key stays fast as the backfill moves through the table. - Keep long backfills out of the deploy. On Canner the release command is stopped after 15 minutes. Run the loop yourself through the database’s external access, which is limited to the IP addresses you allow.
# Run from your machine through the database's external access,
# or from a protected route in your app. Each UPDATE commits on its own.
while true; do
out=$(psql "$DB_URL" -c "
UPDATE orders SET status_code = status
WHERE id IN (
SELECT id FROM orders
WHERE status_code IS NULL AND status IS NOT NULL
ORDER BY id LIMIT 5000
)")
echo "$out"
[ "$out" = "UPDATE 0" ] && break
sleep 1
doneHow this plays out on Canner
Migrations go in the release command (Settings → Build). It runs once per production deploy, after the build and before the new version starts, while the current version keeps serving. That is why every migration has to suit the code already running. If it fails, a lock_timeout included, the deploy stops and the current version keeps running. Find the blocker and deploy again. With Prisma, mark the failed migration with prisma migrate resolve --rolled-back <name> first; Prisma’s docs say migrate deploy refuses to continue until you do.
Then Canner stops the running process (it gets up to 30 seconds to finish its requests) and starts the new one, which must answer its health check within 30 seconds. Requests that arrive during the switch can fail. So a Canner deploy has a short interruption even when the schema change has none. What this page removes is the longer outage: a table locked for minutes, or old code meeting a column that no longer exists. Details in the deploy docs.
On the dedicated instance, you can set statement, idle-in-transaction and lock-wait timeouts for the whole instance (canner db tuning --lock-timeout 5, in seconds). They take effect without a restart. They apply to your app’s sessions too, so keep them at values the app can live with and set a tighter lock_timeout inside migrations. New databases run Postgres 17 by default, or 18 if you choose it; the NOT NULL … NOT VALID form needs 18.
Questions
Does adding a column with a default lock the table in Postgres?
Briefly. Since Postgres 11, a constant default is stored in the catalog and the table is not rewritten, so the ACCESS EXCLUSIVE lock lasts milliseconds. A volatile default such as clock_timestamp() still rewrites the whole table.
Why did a quick ALTER TABLE take my site down?
It waited for a lock behind a long-running transaction, and every new query on the table queued behind it. Set lock_timeout to a few seconds in the migration so it fails instead of waiting, then find the open transaction with pg_stat_activity.
Can CREATE INDEX CONCURRENTLY run inside a migration?
Yes, but not inside a transaction. Put it in its own migration with the transaction turned off (disable_ddl_transaction! in Rails, atomic = False in Django). If it fails, drop the INVALID index it leaves and run it again.
How do I add NOT NULL to a big table without downtime?
Backfill the column, add CHECK (col IS NOT NULL) NOT VALID, run VALIDATE CONSTRAINT, then SET NOT NULL, which skips the scan because the check proves it. On Postgres 18 you can add the NOT NULL constraint itself as NOT VALID and validate it.
Are deploys on Canner zero-downtime?
Not fully. The old process is stopped and the new one started on each production deploy, so requests during that switch can fail. The release command runs while the old version is still serving, so a migration written this way causes no extra outage.
Run your migrations where they can fail safely.
Every Canner project gets a Postgres database and a release command. If a migration fails, the current version keeps running. More guides are in Managed Postgres.