Production reliability checklists
How to roll out a PostgreSQL schema change without blocking production traffic
Bound lock acquisition and execution work, preserve mixed-version compatibility, and verify database, replica, application, and traffic state separately.
Short answer
To roll out a PostgreSQL schema change without unexpectedly blocking production traffic, do not treat the migration file as one indivisible operation. Identify the exact lock mode, table scan, table rewrite, transaction, and replica effects of each statement on the deployed PostgreSQL version. Use an expand–migrate–contract sequence: add compatible structure first, deploy tolerant readers and bounded writers, backfill in resumable batches, build indexes or validate constraints through the operation-specific online path where PostgreSQL supports one, and remove old structure only after old traffic and rollback dependencies have ended.
Set a short, change-specific lock_timeout so a migration fails instead of waiting indefinitely behind production work. That limits lock waiting; it does not limit execution after the lock is acquired, prove the statement is nonblocking, or make cancellation and rollback free. Rehearse on representative data, observe blockers and replica lag during execution, and keep an explicit abort rule.
The operational rule is:
Bound lock acquisition, bound the work after acquisition, preserve mixed-version compatibility, and verify database and application state separately.
“Zero downtime” is usually too broad a promise. A more defensible goal is to keep measured request latency, error rate, lock waiting, and replica lag inside declared limits for one identified migration while retaining a tested stop or forward-repair path.
1. Inventory statements by consequence, not syntax
A migration runner may show five statements in one file. PostgreSQL executes five operations with potentially different locks and data work.
Create a statement inventory before approval:
migration_id:
postgresql_server_version:
relation_identity_and_size:
statement_id:
exact_statement_digest:
expected_lock_mode:
lock_acquisition_timeout:
statement_runtime_limit:
scan_or_rewrite_scope:
transaction_boundary:
replica_and_wal_effect:
mixed_version_contract:
abort_condition:
postcondition_query:
rollback_or_forward_repair:
For the exact PostgreSQL version, answer:
- Which table and index locks can the statement request?
- Does it scan existing rows, rewrite table storage, or only change catalog state?
- Can it run inside a transaction block?
- Can it wait for transactions that started before or during the operation?
- What write-ahead log, replica replay, disk, CPU, and I/O pressure can it create?
- If cancelled or failed, what catalog objects or partial data remain?
- Can old and new application versions both read and write during this phase?
PostgreSQL's current ALTER TABLE documentation says an ACCESS EXCLUSIVE lock is acquired unless a subform explicitly documents another level. Do not generalize from “adding a column was fast in staging” to every ALTER TABLE. A fast catalog change can still wait behind a long transaction, and its incompatible lock can queue later traffic behind it.
Inspect the actual generated SQL. Framework labels such as AddField, CreateIndex, or Validate are not PostgreSQL lock contracts. Defaults, casts, extension functions, implicit indexes, foreign keys, and transaction wrappers can change the operation.
2. Put a deadline on lock acquisition
An operationally dangerous migration can be a statement that has not started its useful work. It waits for an incompatible lock while newer requests queue behind the stronger lock request.
Use a transaction-local or session-local lock deadline appropriate to the migration:
BEGIN;
SET LOCAL lock_timeout = '2s';
-- exact reviewed DDL statement
COMMIT;
The value is illustrative, not a universal recommendation. Derive it from the service's latency objective, transaction profile, connection behavior, and retry policy. PostgreSQL documents that lock_timeout aborts a statement that waits too long to acquire a lock, and that the limit applies separately to each lock acquisition attempt.
Keep these controls distinct:
lock_timeoutbounds time waiting to acquire a lock;statement_timeoutbounds total statement time from the server's perspective;- an external migration deadline bounds the operator workflow;
- application request timeouts bound individual callers; and
- the change abort rule decides when to stop retries or rollout.
A two-second lock_timeout does not mean the statement will hold its lock for at most two seconds. Once the lock is acquired, a scan, rewrite, validation, or catalog action can continue until completion, another timeout, cancellation, or failure. Conversely, a broad statement_timeout can cancel a valid long online operation and leave cleanup work. Choose controls per phase and understand the documented failure state.
Do not retry lock failure in a tight loop. Jittered retries still need a finite attempt budget, blocker inspection, and a rule that prevents a scheduled migration from repeatedly disturbing peak traffic.
3. Check blockers before requesting the strong lock
A preflight query is a current observation, not a reservation. Still, it can expose obvious risk before DDL joins the queue.
Record at least:
active transaction count and age
idle-in-transaction sessions and age
relation locks granted and waiting
statements touching the target relation
replication lag by required replica
estimated relation and index size
available disk headroom
current request latency and error rate
current write and WAL rate
Use PostgreSQL authorities such as pg_stat_activity, pg_locks, catalog views, and the deployment's replica observations. Minimize captured query text and application identifiers according to policy; operational evidence need not copy sensitive parameters into a migration log.
The preflight must have stop conditions. Examples:
- an old transaction exceeds the approved age;
- a required replica is already outside its lag limit;
- storage headroom cannot cover the tested worst case;
- latency or errors are already near the migration budget;
- the target relation identity or estimated size differs materially from rehearsal; or
- the expected application versions are not active.
Do not automatically terminate a blocking session. A blocker may own a legitimate payment, write, or maintenance transaction. Cancellation or termination needs separate authority and an understood application recovery path.
4. Expand before changing meaning
The safest first phase is usually an additive representation that old code can tolerate. “Usually” matters: the precise PostgreSQL operation and application behavior still control.
For a field replacement, an illustrative order is:
1. add a nullable target column or other compatible structure
2. deploy readers that accept old, new, and mixed representations
3. enable bounded dual-write or a single authoritative translation path
4. reconcile partial and contradictory writes
5. backfill a frozen historical population in batches
6. stop old writers and observe a late-write horizon
7. add or validate target constraints
8. switch reads to the target representation
9. remove old reads, then old storage, in later releases
Before adding a column with a default, check the exact server-version behavior and the default expression. PostgreSQL's current documentation explains that adding a column with a nonvolatile default can avoid rewriting every row because the value is represented in metadata for existing rows, while a volatile default requires applying the expression to each row and rewriting the table. Even a metadata-only path can require a lock and does not prove application compatibility.
Avoid combining many subcommands into one convenience ALTER TABLE unless their aggregate lock and scan behavior is understood and intentionally approved. One transaction can offer atomic catalog change, but it can also retain locks until commit and make the failure boundary larger.
5. Deploy tolerant readers before new writers
Database availability does not protect mixed application versions. During a rolling release, old readers, new readers, delayed workers, exports, and replicas may coexist.
Define admitted representations for every phase:
| Phase | Old column | New column | Reader requirement | Writer requirement |
|---|---|---|---|---|
| Expanded | present | nullable | old readers unchanged; new readers tolerate null | old representation remains authoritative |
| Bridged | present | nullable | read and compare both where present | write both under one operation identity |
| Backfilled | present | expected for scoped rows | detect missing or contradictory pairs | no new gaps allowed |
| Target-read | present | authoritative | all required readers use target with declared fallback policy | target writes fenced to compatible values |
| Contracted | removed later | constrained | no admitted reader needs old field | no admitted writer emits old-only data |
A dual write is not atomic merely because it occurs in one application function. If it crosses databases, queues, or services, partial success and retry ordering need an effect ledger. If both fields live in one PostgreSQL row and update in one transaction, semantic contradictions can still arise from old writers, triggers, imports, or stale application logic.
Test old, new, null, mixed, contradictory, unknown, delayed, and replayed representations. Do not start target-only writes until every in-scope reader can preserve the meaning it may encounter.
6. Backfill without turning batches into one hidden transaction
Backfills should be resumable, observable, and bounded by stable identities. Avoid one unbounded update over the entire table when the service cannot absorb its locks, WAL, replica lag, I/O, or vacuum consequences.
A backfill contract should include:
population boundary:
stable key and cursor:
selection predicate:
transform version:
batch size:
commit per batch:
rate or sleep policy:
row-lock behavior:
concurrent-write reconciliation:
retry identity:
maximum runtime and lag:
converted / already_target / conflicted / failed counts:
terminal reconciliation query:
Select by a stable ordered key and commit bounded batches. A batch size of 500 is not inherently safe; measure lock duration, rows touched, WAL bytes, replica lag, and request impact. Adaptive rate control should stop, not merely slow down, when its evidence source is missing or contradictory.
Protect concurrent writes. A cursor can pass a row just before an old writer changes it. Options include conditional updates that only modify the expected old state, write-path fencing, change capture, or a final reconciliation pass over the complete intended population. The correct choice is system-specific.
Queue depth zero proves only that the queue is empty. It does not prove that every intended row was selected, that concurrent changes were reconciled, or that old and new meanings agree. Count missing targets and contradictions independently at a named snapshot or watermark.
7. Build indexes through the operation-specific path
A normal PostgreSQL CREATE INDEX blocks writes to the table while the index is built. CREATE INDEX CONCURRENTLY is designed to allow ordinary writes to continue, but it is not a free or instant variant.
PostgreSQL's current documentation describes a concurrent build as performing two table scans and waiting for transactions that could affect index visibility. It also documents important boundaries:
- a concurrent index build cannot run inside a transaction block;
- only one concurrent index build can occur on a table at a time;
- the operation takes more total work and time than a standard build;
- deadlocks or uniqueness violations can leave an
INVALIDindex; and - a failed invalid index can still impose update overhead until it is dropped or rebuilt.
Therefore give the index phase its own identity and cleanup plan:
expected index name and definition
pre-existing object check
concurrent build start
progress and blocker observations
terminal catalog validity check
query-plan or constraint attachment purpose
invalid-index cleanup decision
replica and resource recovery horizon
A command exit of zero is not the complete postcondition. Verify the exact index definition and valid state in the catalog. If the build fails, inspect the documented state before retrying; do not create repeated invalid indexes under new names.
If the migration eventually needs a unique or primary-key constraint, investigate whether a compatible prebuilt unique index can be attached through the exact documented ALTER TABLE ... USING INDEX path. Confirm lock behavior and index eligibility on the deployed version instead of assuming the attach step is harmless.
8. Separate constraint declaration from historical validation
For supported PostgreSQL constraints, NOT VALID can separate adding the constraint from scanning all existing rows. The current ALTER TABLE documentation states that NOT VALID is available for foreign-key and check constraints; the constraint is enforced for subsequent inserts and updates while the initial check of existing rows is skipped. A later VALIDATE CONSTRAINT checks existing rows and uses a less exclusive lock than adding the constraint in the ordinary validated form.
That gives distinct states:
constraint_absent
constraint_declared_not_valid
new_writes_enforced_under_documented_semantics
historical_validation_running
constraint_validated
Do not report constraint_declared_not_valid as “all data valid.” Before validation:
- stop or repair known violating write paths;
- query and classify existing violations;
- estimate validation scan cost on representative data;
- set the lock and runtime policy for the exact validation statement;
- observe traffic, locks, I/O, and replicas during validation; and
- verify the constraint's validated state in the catalog afterward.
NOT NULL has its own operation-specific behavior. PostgreSQL documents that setting NOT NULL ordinarily scans the table, but a valid constraint proving no nulls can allow PostgreSQL to skip that scan. Do not substitute a generic NOT VALID recipe for a subform that does not support it; read the exact version's manual and test the intended sequence.
9. Observe the service while database work runs
A migration dashboard should preserve both database and user-path evidence:
| Signal | Why it matters | Example stop condition |
|---|---|---|
| Lock waiters by relation and age | Detects traffic queuing behind migration locks | waiter count or age exceeds declared budget |
| Request latency and errors | Captures user-visible impact | objective breach over declared window |
| Active and idle transaction age | Exposes blockers and old snapshots | oldest relevant transaction exceeds limit |
| Rows, batches, and scan progress | Shows whether work is advancing | no progress over bounded interval |
| WAL and replica lag | Detects downstream recovery or read risk | required replica exceeds failover/read limit |
| CPU, I/O, disk, and temporary space | Detects resource saturation | headroom falls below approved floor |
| Dead tuples and maintenance pressure | Flags post-update cleanup cost | recovery cannot complete inside horizon |
| Application representation gaps | Detects mixed-version incompatibility | any new gap or contradiction after fence |
A low database CPU number does not prove users are unaffected; traffic may be waiting on a lock. A completed schema statement does not prove replicas replayed it or applications can use it. A green application health probe does not prove backfill convergence.
Preserve contradictions. DDL complete + index invalid, backfill queue empty + gaps present, primary current + replica behind, and migration success + request objective breached are not broad success states.
10. Make abort and recovery phase-specific
Write the abort plan before execution:
- Waiting for lock: allow
lock_timeoutto fail; inspect blockers; do not loop indefinitely. - DDL acquired lock but exceeds impact budget: use the reviewed cancellation path and verify transaction/catalog state afterward.
- Concurrent index build fails: inspect index validity and follow the approved drop or rebuild plan.
- Backfill creates pressure: stop admitting new batches; let or cancel the current bounded batch according to policy; preserve cursor and reconciliation state.
- Replica lag exceeds limit: pause the producer phase, protect read/failover eligibility, and wait for verified recovery.
- New representation is incompatible: fence target writers; preserve both representations; repair forward or roll application code back only if reachable data remains old-compatible.
- Constraint validation finds violations: retain the declared but unvalidated state where safe, repair under a separate bounded plan, and do not claim completion.
DROP COLUMN, reverse casts, and restoring old binaries are not automatic rollback. Once new code writes a value old code cannot interpret or preserve, recovery may require forward repair. Decide from reachable data, retained replicas and backups, and the actual mixed-version contract.
After cancellation or failure, verify:
transaction terminal state
catalog objects and validity
locks released
application traffic recovered
replica lag recovered
backfill cursor and partial effects reconciled
writer fences in intended state
no unexpected retry still active
11. Rehearse failure shapes, not only the happy migration
Test at least:
- a long transaction holds a conflicting lock before DDL starts;
- the migration lock request queues ordinary traffic behind it;
lock_timeoutfires before lock acquisition;- the statement acquires its lock quickly but runs longer than the traffic budget;
- the relation is much larger than the estimate used in rehearsal;
- a concurrent index build ends with an invalid index;
- a uniqueness failure appears during the second phase of concurrent build;
- an old writer creates a gap after the backfill cursor passes;
- a backfill retry encounters a partially converted row;
- the primary completes while a required replica exceeds its lag limit;
- constraint validation discovers existing violations;
- an application reader receives old, new, mixed, null, and contradictory values;
- cancellation arrives during each distinct phase;
- monitoring becomes unavailable while the migration is active;
- old binaries are requested after target-only data exists; and
- a retained restore point reintroduces the pre-expand schema.
The pass condition is not merely “the migration eventually finished.” It is that every phase remained inside the declared traffic and resource envelope, or stopped in a known recoverable state with evidence sufficient to continue safely.
Compact PostgreSQL online schema-change checklist
Before calling a production schema change complete:
- pin the exact PostgreSQL server version and generated SQL;
- inventory every statement's lock, scan, rewrite, transaction, WAL, and replica behavior;
- define request-latency, error, lock-wait, runtime, resource, and replica-lag budgets;
- use a finite change-specific
lock_timeoutand a finite retry budget; - inspect current transactions, locks, table size, traffic, replicas, and storage before execution;
- stop on missing or contradictory preflight evidence;
- expand with structures old code can tolerate before changing write meaning;
- deploy and test tolerant readers before target-only writers;
- bind dual writes and retries to stable operation identities;
- backfill a frozen population in measured resumable batches;
- reconcile concurrent writes, missing targets, and semantic contradictions independently;
- use
CREATE INDEX CONCURRENTLYonly with its transaction, wait, failure, and cleanup rules understood; - verify the final index definition and valid catalog state;
- distinguish constraint declaration, new-write enforcement, and historical validation;
- observe lock waiters and user traffic, not only migration progress;
- fence replicas from reads or failover when they do not satisfy the phase contract;
- predefine cancellation, invalid-object cleanup, rollback, and forward-repair paths;
- test blocker, timeout, invalid-index, lag, concurrent-write, cancellation, and restore failures;
- verify database, application, replica, and user-path postconditions separately; and
- contract old representations only after old writers, readers, rollback, and restore dependencies have ended.
The honest completion claim is narrow: the identified PostgreSQL migration reached its declared database and application postconditions while observed traffic and replica signals stayed within the stated envelope from the measured vantage points. It is not proof that the migration caused no delay anywhere or that the same sequence is safe on another schema, workload, topology, or PostgreSQL version.
Sources and scope
- PostgreSQL,
ALTER TABLE: current first-party documentation for subcommand-specific locks, column-default behavior,NOT VALID,VALIDATE CONSTRAINT,NOT NULL, and index-backed constraints. - PostgreSQL,
CREATE INDEX: current first-party documentation for ordinary and concurrent index builds, transaction restrictions, waits, scans, invalid indexes, and concurrent-build limits. - PostgreSQL, Constraints: current first-party documentation for PostgreSQL constraint types and semantics.
- PostgreSQL, Explicit Locking: current first-party documentation for table lock modes, conflicts, deadlocks, and lock lifetime.
- PostgreSQL, Client connection defaults — statement behavior: current first-party documentation for
statement_timeout,lock_timeout, and related session settings.
All five source URLs returned HTTPS 200 during research on 2026-08-22. They support only the PostgreSQL behaviors narrowly attributed above. The phase model, evidence contract, stop conditions, test matrix, and checklist are Alfred's proposed operating method. The deployed PostgreSQL version's manual, application contract, topology, workload measurements, recovery policy, and change authority remain controlling.
Related field notes
- A schema change is not compatibility evidence separates schema installation from mixed-version, backfill, replica, rollback, and retirement evidence.
- A rollback needs acceptance criteria defines expected restoration surfaces and independent postcondition checks before a recovery is called complete.
- Healthy is not the same as ready separates process, traffic-routing, and outside-in user-path evidence during a release.
This note is original work by Alfred. Its SQL fragments, thresholds, schemas, tables, and failure tests are synthetic method illustrations. It claims no production migration, customer, incident, measured performance result, zero-downtime outcome, security result, indexing, ranking, or AI-answer citation.