Database Migration Strategies

Type: Software Reference Confidence: 0.92 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

StrategyDowntimeReversibilityComplexityBest For
Versioned migrations (Flyway/Alembic)Depends on DDLForward-only (compensating migration)LowMost projects; sequential, auditable changes
State-based (Redgate, SchemaSync)Depends on diffSnapshot rollbackMediumTeams preferring declarative schema definitions
Expand-contractZeroFull (old schema remains until contract)Medium-HighRenaming columns, changing types, splitting tables
Dual-writeZeroSwitch reads back to old tableHighCross-database migrations, service extractions
Blue-green databaseNear-zeroSwitch back to blue environmentHighMajor version upgrades, engine migrations
Shadow tablesZeroDrop shadow; keep originalHighValidating new schema under real traffic before cutover
Online schema change (gh-ost)Near-zero (brief metadata lock)Drop ghost tableMediumLarge MySQL tables (>10M rows)
pg_repack / pgrollZeroRevert migrationMediumLarge PostgreSQL tables needing VACUUM FULL or schema changes

Decision Tree

START
|-- Can you tolerate any downtime?
|   |-- YES (maintenance window available)
|   |   |-- Simple column add/drop?
|   |   |   |-- YES -> Direct ALTER TABLE in a versioned migration
|   |   |   +-- NO -> State-based diff tool or multi-step versioned migrations
|   +-- NO (zero-downtime required) v
|-- Is the table large (>10M rows)?
|   |-- YES
|   |   |-- MySQL?
|   |   |   |-- YES -> gh-ost or pt-online-schema-change
|   |   |   +-- NO (PostgreSQL) -> pg_repack, pgroll, or CREATE INDEX CONCURRENTLY
|   +-- NO (<10M rows) v
|-- What type of schema change?
|   |-- Adding a nullable column -> Direct ALTER TABLE (fast, no lock in PostgreSQL)
|   |-- Renaming a column -> Expand-contract pattern (3-phase)
|   |-- Changing column type -> Expand-contract pattern
|   |-- Dropping a column -> Deploy app changes first, then drop in separate migration
|   |-- Splitting/merging tables -> Dual-write pattern
|   +-- Full engine migration -> Blue-green database deployment
+-- DEFAULT -> Versioned migration file with CI validation

Step-by-Step Guide

1. Set up versioned migration infrastructure

Choose a migration tool that matches your stack and initialize it. Every migration gets a monotonically increasing version number and lives in version control alongside application code. [src5]

# Flyway (Java/.NET/any)
flyway init

# Alembic (Python/SQLAlchemy)
alembic init migrations
alembic revision --autogenerate -m "baseline"

# Knex (Node.js)
npx knex init
npx knex migrate:make baseline

Verify: flyway info / alembic history / npx knex migrate:status -- shows the baseline migration as pending.

2. Write forward-only migration files

Each migration file contains the SQL or code to move the schema forward. Never edit a migration that has already been applied to any shared environment. [src7]

-- V2__add_email_verified_column.sql (Flyway naming convention)
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
CREATE INDEX CONCURRENTLY idx_users_email_verified ON users (email_verified);

Verify: Apply to a staging environment with production-sized data and measure execution time.

3. Implement expand-contract for breaking changes

When renaming a column or changing a type, use three separate deployments to avoid downtime. [src1]

-- Migration 1: EXPAND -- add new column
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);

-- Migration 2: MIGRATE -- backfill (run in batches for large tables)
UPDATE users SET full_name = first_name || ' ' || last_name
WHERE full_name IS NULL
LIMIT 10000;  -- repeat in batches

-- Migration 3: CONTRACT -- drop old columns (only after all app code updated)
ALTER TABLE users DROP COLUMN first_name;
ALTER TABLE users DROP COLUMN last_name;

Verify: Between each phase, confirm SELECT COUNT(*) FROM users WHERE full_name IS NULL returns 0 before proceeding to CONTRACT.

4. Use online schema change for large tables

For MySQL tables with millions of rows, direct ALTER TABLE acquires locks that block reads and writes. Use gh-ost or pt-online-schema-change instead. [src3] [src4]

# gh-ost (GitHub's online schema change -- triggerless, uses binlog)
gh-ost \
  --host=db-primary.example.com \
  --database=myapp \
  --table=orders \
  --alter="ADD COLUMN shipping_status VARCHAR(50) DEFAULT 'pending'" \
  --execute

# pt-online-schema-change (Percona -- trigger-based)
pt-online-schema-change \
  --alter="ADD COLUMN shipping_status VARCHAR(50) DEFAULT 'pending'" \
  D=myapp,t=orders \
  --execute

Verify: SHOW CREATE TABLE orders; -- confirm the new column exists and no locks are held.

5. Validate migrations in CI before production

Add migration validation to your CI pipeline to catch errors before they reach production. [src7]

# In CI pipeline: spin up a fresh DB, apply all migrations, verify
docker run -d --name test-db -e POSTGRES_PASSWORD=test -p 5432:5432 postgres:16
sleep 5
flyway -url=jdbc:postgresql://localhost:5432/postgres -user=postgres -password=test migrate
flyway -url=jdbc:postgresql://localhost:5432/postgres -user=postgres -password=test validate

Verify: CI exits with code 0 and flyway validate reports no pending or failed migrations.

Code Examples

SQL: Zero-downtime column rename (expand-contract)

-- Input:  Table 'products' with column 'price' that needs renaming to 'unit_price'
-- Output: Column renamed with zero application downtime

-- Step 1: EXPAND (Migration V10)
ALTER TABLE products ADD COLUMN unit_price NUMERIC(10,2);

-- Step 2: Dual-write trigger (temporary, remove at CONTRACT)
CREATE OR REPLACE FUNCTION sync_price_columns()
RETURNS TRIGGER AS $$
BEGIN
  IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
    NEW.unit_price := COALESCE(NEW.unit_price, NEW.price);
    NEW.price := COALESCE(NEW.price, NEW.unit_price);
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_price
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION sync_price_columns();

-- Step 3: MIGRATE -- backfill existing rows
UPDATE products SET unit_price = price WHERE unit_price IS NULL;

-- Step 4: CONTRACT (Migration V12, after app code is updated)
DROP TRIGGER trg_sync_price ON products;
DROP FUNCTION sync_price_columns();
ALTER TABLE products DROP COLUMN price;

Node.js (Knex): Migration file with rollback

// Input:  Knex migration system configured
// Output: Creates 'audit_logs' table with indexes

// migrations/20260224_create_audit_logs.js
exports.up = function(knex) {
  return knex.schema.createTable('audit_logs', (table) => {
    table.bigIncrements('id').primary();
    table.uuid('user_id').notNullable().references('id').inTable('users');
    table.string('action', 100).notNullable();
    table.jsonb('payload').defaultTo('{}');
    table.timestamp('created_at').defaultTo(knex.fn.now());
    table.index(['user_id', 'created_at'], 'idx_audit_user_time');
    table.index(['action'], 'idx_audit_action');
  });
};

exports.down = function(knex) {
  return knex.schema.dropTableIfExists('audit_logs');
};

Python (Alembic): Batched data migration

# Input:  Alembic environment configured with SQLAlchemy
# Output: Safely backfills 'full_name' from 'first_name' + 'last_name'

"""backfill full_name column"""
revision = 'a1b2c3d4e5f6'
down_revision = '9z8y7x6w5v4u'

from alembic import op
import sqlalchemy as sa

BATCH_SIZE = 5000

def upgrade():
    conn = op.get_bind()
    while True:
        result = conn.execute(sa.text(
            "UPDATE users SET full_name = first_name || ' ' || last_name "
            "WHERE full_name IS NULL LIMIT :batch"
        ), {"batch": BATCH_SIZE})
        if result.rowcount == 0:
            break

def downgrade():
    op.execute("UPDATE users SET full_name = NULL")

Go (goose): Migration with transaction control

// Input:  goose migration framework configured
// Output: Adds 'status' enum column to orders table

// migrations/20260224_add_order_status.go
package migrations

import (
    "database/sql"
    "github.com/pressly/goose/v3"
)

func init() {
    goose.AddMigration(upAddOrderStatus, downAddOrderStatus)
}

func upAddOrderStatus(tx *sql.Tx) error {
    _, err := tx.Exec(`
        CREATE TYPE order_status AS ENUM ('pending','processing','shipped','delivered');
        ALTER TABLE orders ADD COLUMN status order_status DEFAULT 'pending';
    `)
    return err
}

func downAddOrderStatus(tx *sql.Tx) error {
    _, err := tx.Exec(`
        ALTER TABLE orders DROP COLUMN status;
        DROP TYPE order_status;
    `)
    return err
}

Anti-Patterns

Wrong: Running manual DDL directly in production

-- BAD -- ad-hoc DDL with no version tracking, no rollback, no audit trail
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- "I'll just run this real quick in the production console..."

Correct: Use versioned migration files

-- GOOD -- tracked in VCS, reviewed in PR, tested in CI, applied by migration tool
-- V14__add_phone_to_users.sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- Applied via: flyway migrate (with CI validation gate)

Wrong: Big-bang migration (everything in one file)

-- BAD -- 500-line migration that renames, restructures, backfills, and drops
-- If it fails halfway, you're in an inconsistent state
ALTER TABLE orders RENAME COLUMN price TO unit_price;
ALTER TABLE orders ADD COLUMN currency VARCHAR(3);
UPDATE orders SET currency = 'USD';  -- locks table for 45 minutes on 50M rows
ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;
DROP TABLE legacy_orders;

Correct: Break into small, independent migrations

-- GOOD -- each step is a separate, independently reversible migration

-- V20__add_currency_column.sql
ALTER TABLE orders ADD COLUMN currency VARCHAR(3) DEFAULT 'USD';

-- V21__backfill_currency.sql (run in batches)
-- UPDATE orders SET currency = 'USD' WHERE currency IS NULL LIMIT 10000;

-- V22__set_currency_not_null.sql (only after backfill complete)
ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;

-- V23__drop_legacy_orders.sql (separate deployment)
DROP TABLE IF EXISTS legacy_orders;

Wrong: No rollback plan

# BAD -- forward-only with no escape hatch
def upgrade():
    op.drop_column('users', 'legacy_role')  # data is gone forever

Correct: Always preserve data until verified

# GOOD -- rename instead of drop; keep data recoverable
def upgrade():
    op.alter_column('users', 'legacy_role',
                    new_column_name='_legacy_role_deprecated')
    # Drop in a future migration after 2 release cycles

def downgrade():
    op.alter_column('users', '_legacy_role_deprecated',
                    new_column_name='legacy_role')

Common Pitfalls

Diagnostic Commands

# Check Flyway migration status
flyway info

# Check Alembic migration history
alembic history --verbose
alembic current

# Check Knex migration status
npx knex migrate:status

# Check for active locks (PostgreSQL)
SELECT pid, relation::regclass, mode, granted
FROM pg_locks WHERE NOT granted;

# Check for long-running queries that may block DDL (PostgreSQL)
SELECT pid, now() - pg_stat_activity.query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active' AND now() - query_start > interval '30 seconds';

# Check table size before deciding migration strategy (PostgreSQL)
SELECT pg_size_pretty(pg_total_relation_size('orders'));

# Verify MySQL table structure after migration
SHOW CREATE TABLE orders;

# Check for pending migrations in Flyway schema history
SELECT * FROM flyway_schema_history WHERE success = false;

Version History & Compatibility

ToolCurrent VersionKey FeatureMigration Notes
Flyway10.xBaseline on migrate, undo (Teams)Free tier: no undo; use compensating migrations
Alembic1.13.xAutogenerate, branchingPair with SQLAlchemy 2.0+ for async support
Knex3.xPromise-based, multi-dialectKnex 3.x dropped callback API; use async/await
goose3.xGo + SQL migrations, no-transaction modev3 dropped GOOSE_DRIVER env; use --driver flag
gh-ost1.1.xTriggerless, throttle-awareRequires binlog access (ROW format)
pt-osc3.6.xTrigger-based, matureConflicts with tables that already have triggers
pgroll0.xPostgreSQL-native, versioned rollbackStill pre-1.0; API may change
Liquibase4.xMulti-format (XML/YAML/SQL), rollbackFree tier: limited rollback; Pro for full undo

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Evolving schema in a running applicationOne-time data import/exportETL tools (dbt, Airbyte)
Team collaboration requiring auditable changesPrototyping with throwaway databasesDirect DDL in dev environment
Zero-downtime deployments with expand-contractMigrating between different database enginesAWS DMS, pgloader
Large table alterations (>10M rows)Small tables in low-traffic applicationsDirect ALTER TABLE
CI/CD pipeline integrationSchema managed by ORM auto-sync in devORM auto-migrate (dev only)

Important Caveats

Related Units