← Back to Blog

Orchestrating Zero-Downtime PostgreSQL Migrations at Scale

How I built a reliable WhatsApp AI shopping assistant for Clickmothercare that survives hallucinated products, silent save failures, and multi-agent handoff bugs.

Anas Rhimi
Anas Rhimi August 2026 • 8 min read

The Zero-Downtime Imperative

Performing database migrations on a live PostgreSQL cluster serving high-throughput traffic is one of the most high-stakes operations in backend engineering. A poorly planned migration that acquires an exclusive lock on a frequently accessed table can instantly cause massive connection queuing, leading to application timeouts and cascading failures. Achieving zero-downtime migrations requires a deep understanding of PostgreSQL's locking mechanisms and a disciplined, multi-step approach to schema changes.

The core principle is backward compatibility. The application code currently running, and the new application code being deployed, must both be able to read and write to the database during the migration window.

1. Adding a Column Without Locking

The most common mistake is adding a column with a default value. In older versions of PostgreSQL (pre-11), this required rewriting the entire table, holding an `ACCESS EXCLUSIVE` lock for the duration. While modern PostgreSQL handles constant defaults natively, dynamic defaults (like `UUID()` or `NOW()`) or backfilling existing rows still pose severe risks.

Here is the safe, multi-step pattern for adding and backfilling a new column:

-- Migration Step 1: Add the column as NULLable.
-- This acquires an ACCESS EXCLUSIVE lock but only for milliseconds,
-- as it merely updates system catalogs, not table data.
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP WITH TIME ZONE NULL;

-- Application Deployment:
-- Deploy updated application code that WRITES to the new column 
-- for all *new* or *updated* rows, but gracefully handles NULLs on read.

-- Migration Step 2: Backfill data in small, non-blocking batches.
-- DO NOT run a single UPDATE statement for 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 = created_at -- Example backfill logic
        WHERE id > current_id AND id <= current_id + batch_size
          AND last_login_at IS NULL; -- Only update rows that haven't been touched by the app yet
        
        current_id := current_id + batch_size;
        COMMIT; -- Release row locks quickly to prevent bloat and contention
        -- Optional: Add pg_sleep(0.1) here to throttle the backfill if replication lag spikes
    END LOOP;
END $$;

Code Analysis:

  • ADD COLUMN ... NULL: The initial alteration is virtually instantaneous. We avoid `DEFAULT` or `NOT NULL` constraints initially.
  • DO $$ ... LOOP: We use a PL/pgSQL anonymous block to script a batched update. This prevents a single massive transaction from holding row locks for an extended period, which would block concurrent updates and cause massive autovacuum bloat.
  • COMMIT; inside the loop: This is crucial. It ensures each batch is a separate transaction, releasing locks frequently and allowing replication to keep pace.

2. Safely Creating Indexes Concurrently

A standard `CREATE INDEX` command acquires a `SHARE` lock on the table, blocking all writes (INSERT, UPDATE, DELETE) until the index build completes—which can take hours for large tables. This is catastrophic for a live application.

-- Migration Step 3: Create the index concurrently.
-- This requires an active transaction to NOT be wrapped in a BEGIN/COMMIT block
-- by your migration tool (e.g., Flyway or Liquibase need specific configuration).
CREATE INDEX CONCURRENTLY idx_users_last_login ON users (last_login_at);

-- Migration Step 4 (Optional): Enforce NOT NULL if required.
-- Adding a NOT NULL constraint requires a full table scan to verify data.
-- To avoid an ACCESS EXCLUSIVE lock during the scan, we use a two-step constraint creation.

-- 4a. Add constraint as NOT VALID. This acquires an ACCESS EXCLUSIVE lock briefly,
-- but skips the full table scan. New rows will be validated, existing ones are ignored.
ALTER TABLE users ADD CONSTRAINT users_last_login_not_null 
    CHECK (last_login_at IS NOT NULL) NOT VALID;

-- 4b. Validate the constraint. This acquires a SHARE UPDATE EXCLUSIVE lock,
-- which does NOT block reads or writes, only other schema changes.
ALTER TABLE users VALIDATE CONSTRAINT users_last_login_not_null;

Code Analysis:

  • CONCURRENTLY: This keyword instructs PostgreSQL to build the index without taking any locks that prevent concurrent inserts, updates, or deletes. It performs multiple scans of the table and takes significantly longer to build, but it guarantees zero downtime. Note that if this fails (e.g., due to a unique constraint violation), it leaves an `INVALID` index behind that must be manually dropped.
  • NOT VALID constraint: This is a powerful technique for adding constraints to large tables. We first declare the constraint to apply to *new* data without checking *existing* data, requiring only a brief lock.
  • VALIDATE CONSTRAINT: We then tell Postgres to scan the table and verify existing data in the background, without blocking concurrent transactions.

By adhering to these multi-step patterns and decoupling schema changes from application deployments, you can execute complex database migrations on massive datasets without impacting your users or risking debilitating lock contention.

Is your AI agent's infrastructure secure and reliable?

Book a Free 15-Min Technical Audit
Hire Me