Skip to main content
RunBook Academy

PostgreSQLXVII · Capacity, Maintenance and UpgradesMaintenance

DDL and schema change from an operations perspective

Advanced⏱ ~35 minpsql

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

Not yet marked complete on this device.

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

StatementRewrites?
ADD COLUMN with no defaultNo — catalogue only
ADD COLUMN ... DEFAULT <constant>No, since PostgreSQL 11
ADD COLUMN ... DEFAULT <volatile>Yes
DROP COLUMNNo — marked dropped, space reclaimed by vacuum
ALTER COLUMN TYPE to a binary-coercible typeNo
ALTER COLUMN TYPE otherwiseYes
SET NOT NULLNo rewrite, but a full scan to verify
ADD CONSTRAINT ... CHECKFull scan to verify
ADD CONSTRAINT ... CHECK NOT VALIDNo scan
ADD FOREIGN KEYFull scan of both tables
ADD FOREIGN KEY ... NOT VALIDNo scan
SET DEFAULT / DROP DEFAULTNo
RENAMENo

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

Service impact possiblea plain SELECT refused, with no conflicting lock granted
$ -- 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 nothing

Read 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 ALTER blocks everything behind it. Measured: a plain SELECT refused while the only granted lock was another plain SELECT.
  • Set lock_timeout, and set it on the migration role. Retry in a loop.
  • Use NOT VALID plus VALIDATE CONSTRAINT to split the lock from the scan.
  • Batch backfills; a single large UPDATE is table-sized WAL, bloat and one long snapshot.
  • Keep DDL transactions short — locks are held until commit.
  • CREATE INDEX CONCURRENTLY cannot 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

  1. Q1. An ALTER TABLE that would take one millisecond takes a site down. What happened?

  2. Q2. Which of these ALTER TABLE forms does NOT rewrite the table?

  3. Q3. Why should DDL and a long data change not share a transaction?

  4. Q4. Which practices make schema migrations safer? Select all that apply.

  5. Q5. A single UPDATE setting a default value across a 100-million-row table is a safe way to backfill a new column.

  6. 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.