Skip to content
System Design
Architecture

Zero-Downtime Database Migrations: The Expand-Contract Pattern

Modifying database schemas in active production environments without causing locks or application downtime requires a phased approach known as the expand-contract pattern.

November 7, 20260 views0 shares

Altering a database schema in a production system running high transaction volumes is one of the most risk-intensive tasks in software engineering. Executing an aggressive ALTER TABLE statement or dropping a column used by active application processes frequently leads to exclusive table locks, dropped connections, service degradation, or runtime errors.

When application code and database schemas are tightly coupled, deploying a schema migration simultaneously with application code assumes instantaneous deployment across all instances. In reality, rolling deployments mean old and new versions of an application run side-by-side during deployment windows. If an old application pod executes an INSERT statement against a database where a column was renamed or deleted milliseconds earlier, the write fails.

Achieving true zero-downtime database changes requires decoupling schema changes from application deployments. The standard industry solution for this operational constraint is the expand-contract pattern (also known as the parallel change pattern). This approach breaks structural schema alterations into incremental, backward-compatible phases across multiple deployments.

The Architecture of Expand-Contract

The expand-contract pattern splits a breaking database migration into three distinct, low-risk operational phases:

  1. Expand: Add new schema components (tables, columns, or views) alongside existing ones without modifying or deleting legacy structures. The database structure expands to support both old and new data models concurrently.
  2. Transition: Deploy application code that handles both structures. The application writes to the new schema structures while maintaining backward compatibility with the old schema using dual-writes, database triggers, or lazy backfills. Existing historic data is backfilled asynchronously in batch processes.
  3. Contract: Once all application instances run code referencing only the new schema structures, safely drop legacy columns, tables, or database triggers, completing the migration.
Phase 1: Expand      ---> New Column added (nullable/default). App still uses Old Column.
Phase 2: Transition  ---> App writes to BOTH columns. Worker backfills old rows. Reads move to New Column.
Phase 3: Contract    ---> Old Column is unused. Drop it and remove the legacy code.

By guaranteeing that every single database migration is entirely backward-compatible with currently running application instances, deployments can occur at any time without coordinated downtime windows or service interruptions.

Real-World Case Study: Renaming and Restructuring Columns

Consider a common production scenario: refactoring a user profile table where a legacy column full_name needs to be split into distinct first_name and last_name columns across a service with millions of records.

Directly executing ALTER TABLE users RENAME COLUMN full_name TO legacy_name; will instantly break running application code expecting full_name. Here is how to complete this refactoring safely without downtime.

Step 1: The Expand Phase

First, create the new columns in PostgreSQL. Crucially, new columns added during the expand phase must be nullable or possess a default value. If a new column is marked NOT NULL without a default value, existing application code issuing INSERT queries without knowledge of the new column will immediately fail.

-- Step 1: Add new columns without rewriting the table
ALTER TABLE users 
  ADD COLUMN first_name VARCHAR(255),
  ADD COLUMN last_name VARCHAR(255);

In relational databases like PostgreSQL, adding a nullable column without a default value is a fast, metadata-only change. It does not rewrite the table, but it still needs a brief ACCESS EXCLUSIVE lock on it, which is why the lock timeout covered later in this article matters. For deeper insights into query planning and lock behaviors during schema modifications, review our guide on optimizing database performance, indexing, and schema design.

Step 2: Dual Writes and Application Transition

With the new columns in place, update the application code to read from the old field as primary, but write updates to both old and new fields simultaneously.

Here is an application-level implementation using TypeScript:

interface UserProfile {
  id: string;
  fullName: string;
  firstName?: string;
  lastName?: string;
}

export async function updateUserProfile(
  userId: string, 
  firstName: string, 
  lastName: string
): Promise<void> {
  const combinedFullName = `${firstName} ${lastName}`.trim();

  // Dual-write: Populate legacy full_name alongside new split columns
  await db.query(
    `UPDATE users 
     SET full_name = $1, 
         first_name = $2, 
         last_name = $3, 
         updated_at = NOW()
     WHERE id = $4`,
    [combinedFullName, firstName, lastName, userId]
  );
}

Alternatively, database triggers can handle dual-writing if multiple microservices access the database directly. However, keeping business logic in the application layer is generally preferred for auditability and observability in decoupled event-driven architectures.

Step 3: Asynchronous Data Backfilling

With dual-writing enabled, all newly created or updated rows contain valid data in both full_name and the new first_name/last_name fields. However, historical records created prior to the expand phase still have NULL values in the new columns.

To backfill historical data without locking large tables, run a background script that processes rows in throttled batches using primary key pagination rather than large offset queries. This example assumes id is a UUID:

export async function backfillUserData(batchSize = 1000): Promise<void> {
  let lastId: string | null = null;
  let hasMore = true;

  while (hasMore) {
    // Select batch using indexed primary key comparison
    const result = await db.query(
      `SELECT id, full_name 
       FROM users 
       WHERE ($1::uuid IS NULL OR id > $1::uuid)
         AND (first_name IS NULL OR last_name IS NULL)
       ORDER BY id ASC 
       LIMIT $2`,
      [lastId, batchSize]
    );

    if (result.rows.length === 0) {
      hasMore = false;
      break;
    }

    for (const row of result.rows) {
      const parts = (row.full_name ?? '').trim().split(' ');
      const firstName = parts[0] || '';
      const lastName = parts.slice(1).join(' ') || '';

      await db.query(
        `UPDATE users 
         SET first_name = $1, last_name = $2 
         WHERE id = $3`,
        [firstName, lastName, row.id]
      );

      lastId = row.id;
    }

    // Sleep briefly between batches to allow normal application transactions to claim locks
    await new Promise((resolve) => setTimeout(resolve, 50));
  }
}

Step 4: The Contract Phase

Once the backfill script finishes and metric checks confirm zero active rows contain NULL values in first_name or last_name, deploy a new iteration of the application code. This release reads and writes exclusively to first_name and last_name, ignoring full_name entirely.

After verifying that all running application pods across all production environments are updated and no active queries reference full_name, execute the final cleanup migration:

-- Step 4: Final Contract phase schema cleanup
ALTER TABLE users DROP COLUMN full_name;

Managing DDL Locks and PostgreSQL Safety Settings

Even with the expand-contract pattern, raw SQL operations can cause unexpected outages if database engine locks are not handled properly.

When PostgreSQL runs a Data Definition Language (DDL) command like ALTER TABLE, it requests an ACCESS EXCLUSIVE lock on the target table. If long-running SELECT queries or uncommitted transactions are active, the DDL command queues up behind them.

Because the DDL lock request sits at the front of the queue, subsequent lightweight READ and WRITE queries will block behind the DDL request. This rapidly consumes connection pools and causes application-wide downtime.

To avoid connection pool exhaustion during migrations, set a short lock timeout on migration connections. Refer to the official PostgreSQL Explicit Locking Documentation for exact lock behavior hierarchies.

-- Fail fast instead of queueing behind long transactions
SET lock_timeout = '3s';

-- NOT VALID skips the scan of existing rows, so the strong lock is brief
ALTER TABLE users ADD CONSTRAINT check_first_name_not_null CHECK (first_name IS NOT NULL) NOT VALID;

-- Validation scans the table and can run for a long time on a large one,
-- but its weaker lock lets normal reads and writes continue
ALTER TABLE users VALIDATE CONSTRAINT check_first_name_not_null;

Similarly, when adding database indexes during the expand phase, always use non-blocking index builds. As detailed in the official PostgreSQL CREATE INDEX Documentation, running CREATE INDEX CONCURRENTLY prevents exclusive table locks during creation:

-- Safe concurrent index creation without blocking table reads or writes
CREATE INDEX CONCURRENTLY idx_users_last_name ON users(last_name);

Engineering Tradeoffs and Limitations

While the expand-contract pattern guarantees high availability during migrations, it introduces architectural tradeoffs:

  • Increased Code Complexity: Applications must temporarily support multiple data schemas, dual-write paths, and conditional fallback logic.
  • Multiple Deployment Cycles: A single refactoring project requires at least two separate code deployments and multiple database migrations spread across time.
  • Storage and Compute Costs: Dual writing and asynchronous backfill operations consume additional disk I/O, network bandwidth, and storage capacity during the transition window.

For early-stage applications, non-critical background services, or systems with pre-negotiated maintenance windows, the overhead of expand-contract may exceed its benefits. For mission-critical APIs and high-availability enterprise services, however, it remains the standard approach for maintaining uninterrupted service availability.

Implementation Checklist

To adopt zero-downtime migrations reliably in production environments:

  1. Enforce Additive Changes First: Ensure all new columns are initially nullable or carry reasonable schema defaults.
  2. Apply Tight Lock Timeouts: Configure migration runner scripts with short lock_timeout thresholds so stalled migrations fail fast instead of queueing real-time traffic.
  3. Chunk Historical Backfills: Move data in small primary-key-paginated batches with artificial delays to prevent database disk I/O saturation.
  4. Decouple Deployments: Verify that application releases and schema changes can be deployed independently without tight runtime dependencies.

By standardizing database migration pipelines around the expand-contract lifecycle, engineering teams can continuously modify production schemas while keeping applications fully operational.

postgresql
database migrations
system design
devops
backend
sql
architecture
Share this article