Blue-Green Deployments with Database Migrations: Patterns That Actually Work

Database migration strategies that maintain backward compatibility during blue-green deployments, enabling zero-downtime releases for stateful applications.

#deployment#database#blue-green#zero-downtime
Cover image for the article: Blue-Green Deployments with Database Migrations: Patterns That Actually Work

Blue-green deployments promise zero-downtime releases, and they deliver — until your release includes a database migration. The moment you need to alter a table, add a column, or change a constraint, the clean symmetry of blue-green breaks down. Both environments share the same database, and the migration must be compatible with both the old and new application code simultaneously.

After shipping 1,400+ production releases with database changes across a platform serving 12 million daily active users, we developed patterns that keep blue-green deployments safe for stateful applications.

The Problem: Shared State in Stateless Deployments

Blue-green deployments assume the two environments are independent. Swap the load balancer, validate, and roll back if needed. But with a shared database:

  • Destructive migrations (dropping columns, renaming tables) immediately break the blue environment
  • Schema additions must be backward-compatible with the code still running in blue
  • Data migrations on large tables can lock writes for minutes
  • Rollback becomes impossible if the migration cannot be reversed

Blue-Green Database Challenge

Our incident log showed that 67% of failed deployments involved database migrations. The average rollback time for migration-related failures was 34 minutes — completely unacceptable for a zero-downtime commitment.

Architecture: The Expand-Contract Pattern

The solution is separating schema changes from code deployments using the expand-contract (also called parallel change) pattern. Every migration happens in three phases, each deployed independently.

Phase 1: Expand (Add without removing)

Deploy the migration that adds new structures without removing old ones:

-- Migration: 001_expand_add_email_verified_column.sql
-- Phase: EXPAND
-- Safe to run while v1 code is active

ALTER TABLE users
  ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;

-- Backfill existing data
UPDATE users
  SET email_verified = TRUE
  WHERE email_confirmed_at IS NOT NULL;

-- Add index for new query patterns
CREATE INDEX CONCURRENTLY idx_users_email_verified
  ON users (email_verified)
  WHERE email_verified = TRUE;

At this point, the old email_confirmed_at column still exists. Both v1 (using email_confirmed_at) and v2 (using email_verified) code can run simultaneously.

Phase 2: Migrate (Deploy new code)

The green environment deploys code that writes to both columns but reads from the new one:

// services/user/repository.ts
class UserRepository {
  async updateEmailVerification(userId: string, verified: boolean): Promise<void> {
    // Dual-write during migration window
    await this.db.query(`
      UPDATE users
      SET email_verified = $1,
          email_confirmed_at = CASE WHEN $1 THEN NOW() ELSE NULL END
      WHERE id = $2
    `, [verified, userId]);
  }

  async isEmailVerified(userId: string): Promise<boolean> {
    // Read from new column
    const result = await this.db.query(`
      SELECT email_verified FROM users WHERE id = $1
    `, [userId]);
    return result.rows[0]?.email_verified ?? false;
  }
}

Phase 3: Contract (Remove old structures)

After the green environment is validated and blue is decommissioned, a follow-up migration removes the old column:

-- Migration: 002_contract_remove_email_confirmed_at.sql
-- Phase: CONTRACT
-- Only run AFTER blue environment is fully decommissioned

ALTER TABLE users
  DROP COLUMN email_confirmed_at;

Handling Large Table Migrations

For tables with 100M+ rows, even ALTER TABLE ... ADD COLUMN can cause issues. We use background migration workers:

# migrations/background/backfill_email_verified.py
class BackfillEmailVerified:
    BATCH_SIZE = 5000
    SLEEP_BETWEEN_BATCHES = 0.1  # seconds

    def __init__(self, db_pool):
        self.db = db_pool
        self.metrics = MigrationMetrics('backfill_email_verified')

    async def execute(self):
        total_rows = await self.get_pending_count()
        self.metrics.set_total(total_rows)

        last_id = 0
        while True:
            batch = await self.db.fetch("""
                UPDATE users
                SET email_verified = (email_confirmed_at IS NOT NULL)
                WHERE id > $1
                  AND email_verified IS NULL
                ORDER BY id
                LIMIT $2
                RETURNING id
            """, last_id, self.BATCH_SIZE)

            if not batch:
                break

            last_id = batch[-1]['id']
            self.metrics.increment(len(batch))

            # Respect database load
            if await self.is_db_under_pressure():
                await asyncio.sleep(2.0)
            else:
                await asyncio.sleep(self.SLEEP_BETWEEN_BATCHES)

        self.metrics.mark_complete()

    async def is_db_under_pressure(self) -> bool:
        result = await self.db.fetchval("""
            SELECT count(*) > 50
            FROM pg_stat_activity
            WHERE state = 'active'
              AND wait_event_type = 'Lock'
        """)
        return result

Migration Safety Framework

We enforce migration safety through automated CI checks:

# .github/workflows/migration-check.yml
name: Migration Safety Check
on:
  pull_request:
    paths: ['migrations/**']

jobs:
  safety-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Validate migration safety
        run: |
          for file in migrations/pending/*.sql; do
            # Check for dangerous operations
            if grep -iE "DROP COLUMN|DROP TABLE|RENAME|ALTER.*TYPE" "$file"; then
              if ! grep -i "Phase: CONTRACT" "$file"; then
                echo "::error file=$file::Destructive migration without CONTRACT phase marker"
                exit 1
              fi
            fi

            # Check for missing CONCURRENTLY on index creation
            if grep -i "CREATE INDEX" "$file" | grep -viE "CONCURRENTLY"; then
              echo "::error file=$file::CREATE INDEX without CONCURRENTLY will lock the table"
              exit 1
            fi

            # Check for NOT NULL without default
            if grep -iE "ADD COLUMN.*NOT NULL" "$file" | grep -vi "DEFAULT"; then
              echo "::error file=$file::NOT NULL column without DEFAULT will fail on existing rows"
              exit 1
            fi
          done

Rollback Strategy: Bidirectional Compatibility Window

Every migration must maintain a compatibility window where both old and new code can function:

Migration Compatibility Window

Migration TypeExpand Safe?Contract TimingRollback Strategy
Add column (nullable)YesAfter 1 deploy cycleDrop column
Add column (NOT NULL + default)YesAfter 1 deploy cycleDrop column
Rename columnNo — use add+copyAfter dual-write verifiedReverse copy
Drop columnNever in expand2+ deploy cycles after code removalCannot undo — requires backup
Change column typeNo — use add new + migrateAfter dual-write verifiedDrop new column
Add indexYes (CONCURRENTLY)ImmediateDrop index

Performance Benchmarks

After implementing the expand-contract pattern:

MetricBeforeAfterImprovement
Migration-related downtime/month23 min0 min100% elimination
Failed deployments (DB-related)67% of failures4% of failures94% reduction
Average rollback time34 min45 sec98% faster
Release frequency3/week12/day28x increase
Lock-related incidents4.2/month0.1/month98% reduction

Key Takeaways

  1. Never mix schema changes with code deployments: Expand first, deploy code, contract later. This is the single most important rule for database safety in blue-green deployments.

  2. Dual-write during transitions: New code must write to both old and new structures until the old environment is fully decommissioned.

  3. Use background workers for backfills: Large table operations belong in background jobs with throttling, not in synchronous migrations.

  4. Automate safety checks: CI should catch destructive operations, missing CONCURRENTLY clauses, and NOT NULL additions without defaults.

  5. Plan for irreversibility: Some migrations cannot be rolled back. For those, the expand phase must be validated in staging for days, not hours, before production.

The investment in migration discipline pays compound returns. Each safe release builds confidence to release more frequently, which reduces batch size, which further reduces risk.

Comments

    No comments yet. Be the first to share your thoughts.