Skip to content

Database Migration Strategies for Zero-Downtime Deployments

Run database schema migrations without downtime using expand-contract patterns, backward-compatible changes and phased rollouts that keep serving traffic.

5 min read
Timeline diagram showing a database migration executing in phases alongside application deployments with no downtime gaps

Putting your application into maintenance mode for database migrations stopped being acceptable years ago. Users expect always-on services, and businesses lose money every minute the site is down. But schema migrations inherently change the structure your application depends on—renaming a column breaks every query that references the old name. Zero-downtime migrations solve this by making every change backward-compatible, deploying in phases, and never assuming all application instances are running the same code version.

The core principle is simple: old code and new code must work simultaneously against the same database during the transition period. Every migration technique flows from this requirement.

The Expand-Contract Pattern

The most fundamental zero-downtime pattern: first expand the schema (add new structure), then migrate data, then contract (remove old structure). Never combine these steps.

sqlsql
-- Example: renaming a column from 'name' to 'full_name'
 
-- ❌ Dangerous: breaks all running application instances instantly
ALTER TABLE users RENAME COLUMN name TO full_name;
 
-- ✅ Phase 1: EXPAND — add new column alongside old one
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
 
-- Copy existing data
UPDATE users SET full_name = name WHERE full_name IS NULL;
 
-- Add trigger to keep columns in sync during transition
CREATE OR REPLACE FUNCTION sync_user_name()
RETURNS TRIGGER AS $$
BEGIN
  IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
    IF NEW.full_name IS NULL AND NEW.name IS NOT NULL THEN
      NEW.full_name := NEW.name;
    ELSIF NEW.name IS NULL AND NEW.full_name IS NOT NULL THEN
      NEW.name := NEW.full_name;
    END IF;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER user_name_sync
  BEFORE INSERT OR UPDATE ON users
  FOR EACH ROW EXECUTE FUNCTION sync_user_name();
sqlsql
-- ✅ Phase 2: MIGRATE — deploy application code 
-- that writes to both columns and reads from full_name
-- Wait until all instances are running the new code
 
-- ✅ Phase 3: CONTRACT — remove old column and trigger
-- Only after all application instances use full_name
DROP TRIGGER user_name_sync ON users;
DROP FUNCTION sync_user_name();
ALTER TABLE users DROP COLUMN name;

Safe Column Operations

Not all column operations are created equal. Some are instant, some lock the table, and some are quietly dangerous.

sqlsql
-- PostgreSQL safe operations (no table lock / instant):
ALTER TABLE users ADD COLUMN bio TEXT;
-- Adding a nullable column without default: instant ✅
 
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
-- Adding column with DEFAULT: instant in PG 11+ ✅
-- (Older PostgreSQL rewrites the entire table — dangerous)
 
ALTER TABLE users ALTER COLUMN bio TYPE TEXT;
-- Changing to a compatible type: usually safe ✅
 
-- PostgreSQL dangerous operations (table lock / rewrite):
 
-- ❌ Adding NOT NULL to existing column with default
ALTER TABLE users ALTER COLUMN bio SET NOT NULL;
-- Scans entire table to verify — locks table on older PG
 
-- ✅ Safe alternative: use a CHECK constraint
ALTER TABLE users ADD CONSTRAINT bio_not_null
  CHECK (bio IS NOT NULL) NOT VALID;
-- NOT VALID means don't check existing rows yet
 
-- Then validate in a separate step (concurrent-safe):
ALTER TABLE users VALIDATE CONSTRAINT bio_not_null;
-- Validates without blocking writes
tstypescript
// Migration helper that enforces safe patterns
class SafeMigration {
  // Check if a migration is safe before running
  async analyzeMigration(sql: string): Promise<MigrationAnalysis> {
    const dangerous = [
      { pattern: /ALTER TABLE.*RENAME COLUMN/i, 
        risk: 'Breaks existing queries' },
      { pattern: /ALTER TABLE.*DROP COLUMN/i, 
        risk: 'Breaks existing queries' },
      { pattern: /ALTER TABLE.*ALTER COLUMN.*TYPE/i, 
        risk: 'May rewrite table' },
      { pattern: /ALTER TABLE.*SET NOT NULL/i, 
        risk: 'Full table scan with lock' },
      { pattern: /CREATE INDEX(?!.*CONCURRENTLY)/i, 
        risk: 'Blocks writes during build' },
    ];
 
    const risks = dangerous
      .filter(d => d.pattern.test(sql))
      .map(d => d.risk);
 
    return {
      safe: risks.length === 0,
      risks,
      sql,
    };
  }
}

Index Creation Without Locking

Creating an index on a large table blocks all writes for the duration of the build. CONCURRENTLY solves this but comes with trade-offs.

sqlsql
-- ❌ Standard index creation: blocks writes
CREATE INDEX idx_users_email ON users (email);
-- On a 100M row table, this blocks inserts/updates 
-- for minutes
 
-- ✅ Concurrent index creation: doesn't block writes
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
-- Takes longer but allows normal operations
 
-- ⚠️ If concurrent index creation fails, it leaves 
-- an INVALID index. Check and clean up:
SELECT indexrelid::regclass, indisvalid 
FROM pg_index 
WHERE NOT indisvalid;
 
-- Drop invalid index and retry:
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email;
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
sqlsql
-- Unique constraints also need careful handling
 
-- ❌ Adding unique constraint directly locks table
ALTER TABLE users ADD CONSTRAINT users_email_unique 
  UNIQUE (email);
 
-- ✅ Create unique index concurrently, then add constraint
CREATE UNIQUE INDEX CONCURRENTLY idx_users_email_unique 
  ON users (email);
 
-- Then add constraint using the existing index (instant):
ALTER TABLE users ADD CONSTRAINT users_email_unique 
  UNIQUE USING INDEX idx_users_email_unique;

Data Migration in Batches

Large data migrations must run in batches to avoid long-running transactions that bloat WAL, lock rows, and spike resource usage.

tstypescript
// Batch data migration with progress tracking
async function migrateInBatches(
  pool: Pool,
  config: {
    table: string;
    batchSize: number;
    transform: string;
    whereClause: string;
  }
): Promise<void> {
  let totalMigrated = 0;
  let hasMore = true;
 
  while (hasMore) {
    const result = await pool.query(`
      WITH batch AS (
        SELECT id FROM ${config.table}
        WHERE ${config.whereClause}
        ORDER BY id
        LIMIT $1
        FOR UPDATE SKIP LOCKED
      )
      UPDATE ${config.table} t
      SET ${config.transform}
      FROM batch b
      WHERE t.id = b.id
      RETURNING t.id
    `, [config.batchSize]);
 
    totalMigrated += result.rowCount ?? 0;
    hasMore = (result.rowCount ?? 0) === config.batchSize;
 
    console.log(`Migrated ${totalMigrated} rows`);
 
    // Brief pause to let other queries through
    await new Promise(resolve => setTimeout(resolve, 100));
  }
 
  console.log(`Migration complete: ${totalMigrated} total rows`);
}
 
// Usage: populate the new full_name column
await migrateInBatches(pool, {
  table: 'users',
  batchSize: 5000,
  transform: "full_name = name",
  whereClause: "full_name IS NULL",
});

Foreign Key Constraints

Adding foreign keys on large tables is particularly tricky because PostgreSQL validates all existing rows, locking both tables.

sqlsql
-- ❌ Adding foreign key directly: validates all rows with lock
ALTER TABLE orders 
  ADD CONSTRAINT fk_orders_user 
  FOREIGN KEY (user_id) REFERENCES users (id);
 
-- ✅ Add constraint without validation, then validate separately
ALTER TABLE orders 
  ADD CONSTRAINT fk_orders_user 
  FOREIGN KEY (user_id) REFERENCES users (id) 
  NOT VALID;
-- Instant: only enforces constraint on new/updated rows
 
-- Validate existing data without blocking writes:
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user;
-- Scans table but allows concurrent writes

Deployment Coordination

The migration and application deployment must be coordinated so that running application instances can work with both the old and new schema.

tstypescript
// Migration versioning that supports zero-downtime deploys
interface MigrationPhase {
  phase: 'expand' | 'migrate-data' | 'contract';
  sql: string;
  requiresAppVersion?: string;
}
 
const renameMigration: MigrationPhase[] = [
  {
    // Deploy this BEFORE new app code
    phase: 'expand',
    sql: `
      ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
      -- Add sync trigger here
    `,
  },
  {
    // Deploy app v2 (reads full_name, writes both)
    // Wait for ALL instances to be on v2
    phase: 'migrate-data',
    requiresAppVersion: '2.0.0',
    sql: `
      -- Batch update existing rows
      -- This runs as a background job, not a blocking migration
    `,
  },
  {
    // After ALL instances on v2 AND data migration complete
    phase: 'contract',
    requiresAppVersion: '2.0.0',
    sql: `
      DROP TRIGGER user_name_sync ON users;
      ALTER TABLE users DROP COLUMN name;
    `,
  },
];

Key Takeaways

The expand-contract pattern is the foundation of zero-downtime migrations: add new structure first, migrate data, deploy application code that uses the new structure, verify all instances have switched, then remove old structure—never combine these steps into a single deployment. Use CREATE INDEX CONCURRENTLY for every index on production tables, add foreign keys and NOT NULL constraints with NOT VALID followed by a separate VALIDATE step, and add columns as nullable with defaults—these PostgreSQL-specific techniques avoid table locks that block writes during migration. Batch your data migrations using LIMIT with FOR UPDATE SKIP LOCKED to process thousands of rows at a time without holding long-running transactions that bloat WAL logs and block other operations. Coordinate deployment order carefully: expand migrations must run before deploying new application code, contract migrations must only run after all application instances are running the new code, and you need version-awareness tooling to enforce this sequencing automatically.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX