← Back to Blog

Orchestrating Zero-Downtime PostgreSQL Migrations at Scale



Orchestrating Zero-Downtime PostgreSQL Migrations at Scale

Executing schema migrations on high-traffic production PostgreSQL databases demands careful engineering. Obtaining an exclusive table lock on heavily queried tables can freeze incoming SQL operations, exhaust application connection pools, and degrade service availability.

Zero-downtime database migrations follow one fundamental rule: maintain continuous backward compatibility. Both the currently active application version and the incoming release candidate must be capable of reading from and writing to the database schema throughout the deployment process.

1. Adding Columns Without Blocking Writes

In older PostgreSQL versions (pre-11), adding a column with a default value triggered a full table rewrite, holding an ACCESS EXCLUSIVE lock until completion. While modern PostgreSQL handles static default values instantly, dynamic defaults (such as NOW() or gen_random_uuid()) or populating existing rows still necessitate a phased migration plan.

Here is the pattern for adding and populating a new column safely:

-- Step 1: Add the column as NULLable.
-- Acquires an ACCESS EXCLUSIVE lock for only a few milliseconds to update metadata.
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP WITH TIME ZONE NULL;

-- Application Step: -- Deploy application code that writes to last_login_at for new or updated records, -- while handling NULL values gracefully on reads.

-- Step 2: Backfill existing rows in small batches. -- Avoid a single UPDATE statement across millions of rows. DO $$ DECLARE batch_size INT := 5000; max_id INT; current_id INT := 0; BEGIN SELECT max(id) INTO max_id FROM users; WHILE current_id < max_id LOOP UPDATE users SET last_login_at = COALESCE(last_login_at, NOW()) WHERE id > current_id AND id <= current_id + batch_size;

current_id := current_id + batch_size; PERFORM pg_sleep(0.05); -- Optional throttle to limit I/O impact END LOOP; END $$;

Key Implementation Principles: * NULLable Column Addition: Adding a NULL column updates only PostgreSQL catalog metadata, completing almost instantly without table scans. * Batched Row Updates: Iterating through primary key ranges in chunks releases row-level locks after each commit, minimizing write contention and avoiding autovacuum table bloat. * I/O Throttling: Micro-sleep pauses between batch executions allocate sufficient I/O bandwidth for ongoing production read/write traffic and mitigate replication lag.

2. Creating Indexes and Constraints Safely

A standard CREATE INDEX statement takes a SHARE lock on the target table, blocking all INSERT, UPDATE, and DELETE operations until index construction finishes. On multi-gigabyte tables, this blocking window can extend for hours.

-- Step 3: Build the index concurrently.
-- Note: Migration tools like Flyway or Liquibase must run this outside transaction blocks.
CREATE INDEX CONCURRENTLY idx_users_last_login ON users (last_login_at);

-- Step 4: Enforce NOT NULL constraints in two steps. -- 4a. Add the constraint as NOT VALID to skip scanning existing rows. ALTER TABLE users ADD CONSTRAINT users_last_login_not_null CHECK (last_login_at IS NOT NULL) NOT VALID;

-- 4b. Validate existing data in the background. -- Uses a SHARE UPDATE EXCLUSIVE lock, allowing concurrent reads and writes. ALTER TABLE users VALIDATE CONSTRAINT users_last_login_not_null;

Key Implementation Principles: * CONCURRENTLY Indexing: Directs PostgreSQL to build index structures using multiple table scans without blocking concurrent write operations. If the execution encounters an error, PostgreSQL retains an INVALID index state that should be dropped prior to re-execution. * NOT VALID Constraints: Enforces validation rules on new incoming rows immediately, requiring only a brief lock without verifying pre-existing data rows. * VALIDATE CONSTRAINT: Verifies historical table rows in the background to confirm constraint compliance without blocking concurrent application operations.

Deconstructing schema migrations into non-blocking, multi-phase steps requires additional deployment orchestration, but it provides the only dependable method for updating large-scale PostgreSQL tables without incurring downtime.