PostgreSQLXVII · Capacity, Maintenance and UpgradesMaintenance
DDL and schema change from an operations perspective
What you'll learn
- Predict whether a DDL statement rewrites the table
- Apply schema changes without an outage
- Use lock_timeout so a migration fails instead of stalling everything
- Sequence a change so it is reversible at every step
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
DDL in PostgreSQL is transactional, which is a genuine advantage. It is also where most self-inflicted outages come from, and for a reason that is not the one people expect.
Two questions
1. Does it rewrite the table? A rewrite is proportional to the table’s size in I/O, WAL and space.
2. How long will it wait for its lock? This is the one that causes outages.
Rewrites, and the ones that do not
| Statement | Rewrites? |
|---|---|
ADD COLUMN with no default | No — catalogue only |
ADD COLUMN ... DEFAULT <constant> | No, since PostgreSQL 11 |
ADD COLUMN ... DEFAULT <volatile> | Yes |
DROP COLUMN | No — marked dropped, space reclaimed by vacuum |
ALTER COLUMN TYPE to a binary-coercible type | No |
ALTER COLUMN TYPE otherwise | Yes |
SET NOT NULL | No rewrite, but a full scan to verify |
ADD CONSTRAINT ... CHECK | Full scan to verify |
ADD CONSTRAINT ... CHECK NOT VALID | No scan |
ADD FOREIGN KEY | Full scan of both tables |
ADD FOREIGN KEY ... NOT VALID | No scan |
SET DEFAULT / DROP DEFAULT | No |
RENAME | No |
ADD COLUMN ... DEFAULT <constant> not rewriting is the single most
useful of these, and it is recent enough that a great deal of advice
still says otherwise.
The lock queue is the real hazard
$ -- session 1: a long SELECT (holds ACCESS SHARE)
-- session 2: ALTER TABLE ... (waits for ACCESS EXCLUSIVE)
-- session 3: SELECT count(*) FROM t;ERROR: canceling statement due to lock timeout
blocked_by = {1622} <- the WAITING ALTER, which holds nothingRead that carefully. Session 3 is blocked by session 2 — which holds no lock at all. It is waiting for one, and PostgreSQL queues later requests behind waiting ones to prevent starvation.
So a single ALTER TABLE that cannot get its lock stops every
subsequent query on that table, even queries that would not have
conflicted with anything currently granted.
lock_timeout is the answer
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN promo_code text;
COMMIT;
With lock_timeout set, the ALTER gives up after three seconds
instead of holding the queue indefinitely. The migration fails, which is
loud, recoverable and infinitely preferable to an outage.
Wrap it in a retry loop and it becomes reliable:
for i in $(seq 1 20); do
psql -v ON_ERROR_STOP=1 -c "SET lock_timeout='3s'; ALTER TABLE orders ADD COLUMN promo_code text;" \
&& break
sleep 10
done
Each attempt is cheap and bounded. Eventually one lands in a gap between long queries.
Safe patterns
Adding a column. ADD COLUMN with no default, or a constant
default. Both are catalogue-only.
Adding a NOT NULL constraint, without the full-table scan blocking
everything:
-- 1. cheap: no scan
ALTER TABLE t ADD CONSTRAINT t_c_not_null CHECK (c IS NOT NULL) NOT VALID;
-- 2. validates with a weaker lock; can run for a long time safely
ALTER TABLE t VALIDATE CONSTRAINT t_c_not_null;
Adding a foreign key, same shape:
ALTER TABLE child ADD CONSTRAINT fk FOREIGN KEY (pid)
REFERENCES parent(id) NOT VALID;
ALTER TABLE child VALIDATE CONSTRAINT fk;
Changing a column type. The rewrite is unavoidable in general. The alternative is the expand-and-contract sequence: add a new column, backfill in batches, switch the application, drop the old one.
Dropping a column. DROP COLUMN is catalogue-only. The space is
returned as rows are rewritten by ordinary activity or by a vacuum, not
immediately.
What to take from this
- Two questions: does it rewrite, and how long will it wait for its lock. The second causes the outages.
ADD COLUMN ... DEFAULT <constant>does not rewrite.- A waiting
ALTERblocks everything behind it. Measured: a plainSELECTrefused while the only granted lock was another plainSELECT. - Set
lock_timeout, and set it on the migration role. Retry in a loop. - Use
NOT VALIDplusVALIDATE CONSTRAINTto split the lock from the scan. - Batch backfills; a single large
UPDATEis table-sized WAL, bloat and one long snapshot. - Keep DDL transactions short — locks are held until commit.
CREATE INDEX CONCURRENTLYcannot run inside a transaction block.
Cross-course references
- Git, CI/CD & GitOps — Part XLIX (Infrastructure CI) covers testing a migration before it reaches production, Part LVII (Approval gates) covers the review it needs, and Part LIX (Rollback) covers why a migration’s rollback is a second migration rather than an undo.
- Ansible for Production Sysadmins — Part XXV (Check mode and diff) covers seeing what a change would do before it does it.
- Observability for Production Sysadmins — Part LI (Correlating metrics, logs, and traces) covers putting the migration and the latency it may change on one timeline.
Quiz
Knowledge check · 6 questions
Q1. An ALTER TABLE that would take one millisecond takes a site down. What happened?
Q2. Which of these ALTER TABLE forms does NOT rewrite the table?
Q3. Why should DDL and a long data change not share a transaction?
Q4. Which practices make schema migrations safer? Select all that apply.
Q5. A single UPDATE setting a default value across a 100-million-row table is a safe way to backfill a new column.
Q6. Explain the lock-queue mechanism that turns a fast ALTER TABLE into an outage, and how to prevent it.
Passing score: 75%. Answers are checked in this browser.