DatabasesADVANCEDPart 2 of 3 in Series

Zero-Downtime PostgreSQL Schema Migrations at Scale

Understanding lock queues, ACCESS EXCLUSIVE table locks, expand-contract patterns, and executing safe DDL operations on production databases under heavy concurrency.

E
Elena Rostova
@erostova
September 25, 2026 9 min read
Share
Part 2 of 3Technical Learning Track

System Design: From Zero to Production

A comprehensive engineering series guiding backend developers from single-node instances to highly resilient distributed architectures.

Zero-Downtime PostgreSQL Schema Migrations at Scale

Introduction

Performing schema migrations on a PostgreSQL database with hundreds of millions of records and thousands of write transactions per second requires strict avoidance of blocking table locks.

The Expand and Contract Pattern

To avoid locking tables and breaking running application instances during rolling deployments, we utilize the phased Expand-Contract (Parallel Run) strategy:

text
stateDiagram-v2
    [*] --> Phase1_Expand: Add nullable column or index CONCURRENTLY
    Phase1_Expand --> Phase2_DualWrite: Deploy App v2 (Writes to both old and new columns)
    Phase2_DualWrite --> Phase3_Backfill: Asynchronous batch backfill historical rows
    Phase3_Backfill --> Phase4_Contract: Deploy App v3 (Reads only new column)
    Phase4_Contract --> Phase5_Cleanup: Drop deprecated old column asynchronously
    Phase5_Cleanup --> [*]

Safe vs Dangerous PostgreSQL DDL Commands

Lock Duration & Impact on 50M Row Table

Benchmarked Live
Comparison of traditional vs non-blocking migration patterns.
1.Standard CREATE INDEX
48s table lock
100% blocked queries
2.CREATE INDEX CONCURRENTLY
0ms table lock
Zero read/write blocking
3.Lock Timeout Guard
2000ms max
Instant abort on lock wait

Safe DDL Script Template

Always set a strict lock timeout and statement timeout before running DDL:

text
-- Set aggressive lock timeout to fail fast if table is busy
SET lock_timeout = '2s';
SET statement_timeout = '30s';

-- Step 1: Add new column without default
ALTER TABLE users ADD COLUMN phone_normalized VARCHAR(32);

-- Step 2: Create index concurrently outside transaction block
-- Note: CONCURRENTLY cannot run inside a multi-statement transaction
CREATE INDEX CONCURRENTLY idx_users_phone_normalized ON users(phone_normalized);
Share
E

Elena Rostova

@erostova

Database Internals Specialist & PostgreSQL Performance Consultant.