Scope: Comprehensive guide to database migrations, schema versioning, zero-downtime deployments, and migration tooling Lines: ~1800 Last Updated: 2025-10-27
Scope: Comprehensive guide to database migrations, schema versioning, zero-downtime deployments, and migration tooling Lines: ~1800 Last Updated: 2025-10-27
Migrations are versioned, ordered scripts that evolve database schema over time.
Core Properties:
Without migrations:
-- Developer A: Creates table manually
CREATE TABLE users (id INT, email VARCHAR(255));
-- Developer B: Different schema!
CREATE TABLE users (id SERIAL, email TEXT, created_at TIMESTAMP);
-- Production: Who knows what's deployed?
With migrations:
-- V1: Everyone runs same migration
-- Version controlled, tested, reproducible
-- Production schema matches development
Development → Testing → Staging → Production
↓ ↓ ↓ ↓
V1, V2 V1, V2 V1, V2 V1, V2
Key principle: Same migrations run in all environments.
Most tools create a metadata table:
-- Example: Flyway's schema_version table
CREATE TABLE flyway_schema_history (
installed_rank INT NOT NULL,
version VARCHAR(50),
description VARCHAR(200) NOT NULL,
type VARCHAR(20) NOT NULL,
script VARCHAR(1000) NOT NULL,
checksum INT,
installed_by VARCHAR(100) NOT NULL,
installed_on TIMESTAMP NOT NULL DEFAULT NOW(),
execution_time INT NOT NULL,
success BOOLEAN NOT NULL
);
Query applied migrations:
SELECT version, description, installed_on, success
FROM flyway_schema_history
ORDER BY installed_rank;
Approach: Only write "up" migrations, never roll back.
V1 → V2 → V3 → V4
↓ ↓ ↓ ↓
Pros:
Cons:
Best for: Production systems where rollback is rare.
Example:
-- V1__initial_schema.sql
CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR(255));
-- V2__add_username.sql
ALTER TABLE users ADD COLUMN username VARCHAR(100);
-- V3__add_index.sql
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
-- V4 (fix for V3 if it failed)
-- V4__retry_index.sql
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email;
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
Approach: Every migration has "up" and "down" version.
V1 ⇄ V2 ⇄ V3 ⇄ V4
Pros:
Cons:
Best for: Development, staging, new projects.
Example:
-- 001_add_users.up.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL
);
-- 001_add_users.down.sql
-- Example of rollback migration - destructive operation for reverting schema
DROP TABLE users;
-- 002_add_username.up.sql
ALTER TABLE users ADD COLUMN username VARCHAR(100);
-- 002_add_username.down.sql
ALTER TABLE users DROP COLUMN username;
Approach: Once applied, migrations NEVER change. Fixes go in new migrations.
Rule: Migration files are immutable after merge to main.
V1 (bug!) → V2 (fix) → V3
Pros:
Cons:
Best practice: Use checksums to detect unauthorized changes.
Example:
-- V1__create_users.sql (has bug: wrong column type)
CREATE TABLE users (id INT, email VARCHAR(50)); -- Bug: email too short!
-- V2__fix_email_length.sql (fix in new migration)
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(255);
Approach: Define desired state, tool generates migrations.
Tools: Atlas, Prisma Migrate, TypeORM with synchronize.
Schema Definition (code) → Tool → Generated Migrations
Pros:
Cons:
Example (Atlas):
// schema.hcl
table "users" {
column "id" { type = serial }
column "email" { type = varchar(255) }
primary_key { columns = [column.id] }
}
atlas migrate diff --env local
# Generates: 20231027_initial.sql
Language: Java/SQL Philosophy: SQL-first, version-based Best for: Enterprise Java applications
Features:
Configuration:
# flyway.conf
flyway.url=jdbc:postgresql://localhost:5432/mydb
flyway.user=postgres
flyway.password=secret
flyway.locations=filesystem:./migrations
flyway.baselineVersion=1
flyway.baselineOnMigrate=true
Naming Convention:
V{version}__{description}.sql
V1__initial_schema.sql
V2__add_users_table.sql
V2.1__add_users_email_index.sql # Dot notation for sub-versions
Commands:
flyway migrate # Apply pending migrations
flyway info # Show migration status
flyway validate # Validate applied migrations
flyway baseline # Baseline existing database
flyway repair # Fix metadata (use with caution)
flyway clean # Drop all objects (DANGEROUS!)
Callbacks:
-- beforeMigrate.sql: Runs before any migration
SET statement_timeout = '30s';
-- afterMigrate.sql: Runs after all migrations
ANALYZE;
Pros:
Cons:
Language: Go/SQL Philosophy: Simple, CLI-focused, up/down pattern Best for: Go microservices
Features:
Installation:
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
Naming Convention:
{version}_{description}.up.sql
{version}_{description}.down.sql
000001_initial_schema.up.sql
000001_initial_schema.down.sql
000002_add_users_table.up.sql
000002_add_users_table.down.sql
Commands:
# Create migration
migrate create -ext sql -dir migrations -seq add_users_table
# Apply all up migrations
migrate -database "postgres://localhost:5432/db?sslmode=disable" -path migrations up
# Apply one up migration
migrate -database "$DB_URL" -path migrations up 1
# Rollback one migration
migrate -database "$DB_URL" -path migrations down 1
# Force version (recovery from dirty state)
migrate -database "$DB_URL" -path migrations force 5
# Show current version
migrate -database "$DB_URL" -path migrations version
Programmatic Usage:
import (
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
m, err := migrate.New(
"file://migrations",
"postgres://localhost:5432/db?sslmode=disable")
m.Up() // Apply all migrations
m.Steps(2) // Apply 2 migrations
m.Down() // Rollback all (DANGEROUS!)
Pros:
Cons:
Language: Python/SQL Philosophy: SQLAlchemy integration, auto-generate Best for: Python applications (Flask, FastAPI, Django alternatives)
Features:
Installation:
uv add alembic psycopg2-binary
alembic init alembic
Configuration:
# alembic.ini
[alembic]
script_location = alembic
sqlalchemy.url = postgresql://localhost:5432/mydb
# alembic/env.py
from myapp.models import Base
target_metadata = Base.metadata
Creating Migrations:
# Manual migration
alembic revision -m "add users table"
# Auto-generate from models
alembic revision --autogenerate -m "add users table"
Migration File:
# alembic/versions/abc123_add_users_table.py
from alembic import op
import sqlalchemy as sa
revision = 'abc123'
down_revision = 'def456' # Previous migration
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'users',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('email', sa.String(255), unique=True, nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now())
)
op.create_index('idx_users_email', 'users', ['email'])
def downgrade():
op.drop_index('idx_users_email', 'users')
op.drop_table('users')
Commands:
alembic upgrade head # Apply all migrations
alembic upgrade +1 # Apply one migration
alembic downgrade -1 # Rollback one migration
alembic current # Show current version
alembic history # Show migration history
alembic stamp head # Mark as current without running
alembic revision --sql ... # Generate SQL without applying
Branching:
# Create branch
alembic revision -m "feature A" --head=base --branch-label=feature_a
alembic revision -m "feature B" --head=base --branch-label=feature_b
# Merge branches
alembic merge -m "merge features" feature_a feature_b
Pros:
Cons:
Language: Java/XML/YAML/SQL Philosophy: Database-agnostic, change sets Best for: Enterprise multi-database environments
Features:
Example (YAML):
# changelog.yml
databaseChangeLog:
- changeSet:
id: 1
author: developer
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: SERIAL
constraints:
primaryKey: true
- column:
name: email
type: VARCHAR(255)
constraints:
unique: true
nullable: false
rollback:
- dropTable:
tableName: users
Pros:
Cons:
Language: Any/SQL Philosophy: Simple, language-agnostic, minimal Best for: Polyglot projects, simple setups
Features:
Installation:
# macOS
brew install dbmate
# Or download binary
curl -fsSL -o /usr/local/bin/dbmate https://github.com/amacneil/dbmate/releases/latest/download/dbmate-linux-amd64
chmod +x /usr/local/bin/dbmate
Usage:
# Set database URL
export DATABASE_URL="postgres://localhost:5432/mydb?sslmode=disable"
# Create migration
dbmate new add_users_table
# Apply migrations
dbmate up
# Rollback
dbmate down
# Status
dbmate status
Migration File:
-- migrations/20231027120000_add_users_table.sql
-- migrate:up
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL
);
-- migrate:down
-- Example of rollback migration - destructive operation for reverting schema
DROP TABLE users;
Pros:
Cons:
Language: Go/HCL Philosophy: Modern, declarative, schema-as-code Best for: Modern Go applications, GitOps workflows
Features:
Schema Definition:
// schema.hcl
table "users" {
schema = schema.public
column "id" {
type = serial
}
column "email" {
type = varchar(255)
}
primary_key {
columns = [column.id]
}
index "idx_email" {
unique = true
columns = [column.email]
}
}
Commands:
# Inspect current schema
atlas schema inspect -u "postgres://localhost:5432/db" > schema.hcl
# Generate migration
atlas migrate diff add_users --env local
# Apply migrations
atlas migrate apply --env local
# Validate
atlas migrate validate --env local
Pros:
Cons:
Problem: Migration fails halfway, retried, errors on existing objects.
Solution: Use IF NOT EXISTS / IF EXISTS.
-- ✅ GOOD: Idempotent
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255)
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(20);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
ALTER TABLE users ADD CONSTRAINT IF NOT EXISTS users_email_unique UNIQUE (email);
-- ✅ GOOD: Idempotent drop (safe cleanup operation)
DROP TABLE IF EXISTS old_temp_table;
DROP INDEX IF EXISTS idx_old_index;
PostgreSQL version check:
-- IF NOT EXISTS added in:
-- CREATE TABLE IF NOT EXISTS: 9.1+
-- ALTER TABLE ADD COLUMN IF NOT EXISTS: 9.6+
-- CREATE INDEX IF NOT EXISTS: 9.5+
Use transactions when possible:
BEGIN;
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
total DECIMAL(10,2) NOT NULL
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
COMMIT;
When NOT to use transactions:
-- ❌ Cannot run in transaction block
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
DROP INDEX CONCURRENTLY idx_old_index;
VACUUM;
REINDEX CONCURRENTLY;
Solution: Split into separate migration files.
-- V1__add_orders_table.sql (transactional)
BEGIN;
CREATE TABLE orders (...);
COMMIT;
-- V2__add_orders_index.sql (non-transactional)
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
Problem: ALTER TABLE ADD CONSTRAINT locks table for validation.
Solution: Add constraint as NOT VALID, then validate separately.
-- Step 1: Add constraint without validation (fast, allows writes)
ALTER TABLE users
ADD CONSTRAINT check_age_positive
CHECK (age > 0) NOT VALID;
-- Step 2: Validate constraint (slow, but doesn't block writes)
ALTER TABLE users VALIDATE CONSTRAINT check_age_positive;
Locking behavior:
ADD CONSTRAINT ... NOT VALID: ShareUpdateExclusiveLock (allows SELECT, INSERT, UPDATE, DELETE)VALIDATE CONSTRAINT: ShareUpdateExclusiveLock (allows SELECT, INSERT, UPDATE, DELETE)Problem: CREATE INDEX takes AccessExclusiveLock, blocks all queries.
Solution: CREATE INDEX CONCURRENTLY
-- ❌ BAD: Locks table
CREATE INDEX idx_users_email ON users(email);
-- ✅ GOOD: No lock
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
Trade-offs:
Check for invalid indexes:
SELECT
schemaname,
tablename,
indexname,
indexdef
FROM pg_indexes
WHERE indexname NOT IN (
SELECT indexrelid::regclass::text
FROM pg_index
WHERE indisvalid
);
-- Or simpler:
SELECT indexrelid::regclass AS index_name
FROM pg_index
WHERE NOT indisvalid;
Clean up invalid index:
DROP INDEX CONCURRENTLY idx_users_email;
-- Then retry creation
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
Problem: Adding NOT NULL column to non-empty table fails.
Solution: Multi-step approach.
-- ❌ FAILS on existing rows
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL;
-- ERROR: column "phone" contains null values
-- ✅ GOOD: Multi-step
-- Step 1: Add nullable column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- Step 2: Backfill (in batches, see Data Migrations section)
UPDATE users SET phone = 'unknown' WHERE phone IS NULL;
-- Step 3: Add NOT NULL constraint (fast, already validated)
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
Alternative with default:
-- ✅ GOOD: Use default value
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL DEFAULT 'unknown';
-- Optionally remove default after
ALTER TABLE users ALTER COLUMN phone DROP DEFAULT;
Note: Adding column with DEFAULT rewrites entire table in PG < 11. In PG 11+, it's fast (default stored in metadata).
Scenario: Add new optional column.
Strategy: Single-step, backward compatible.
-- Migration: Add nullable column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
Deployment:
Compatibility:
Scenario: Add required column.
Strategy: Multi-phase deployment.
Phase 1 Migration:
-- Add nullable column with default
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
Phase 1 Deployment:
Phase 2 Migration (after all instances updated):
-- Backfill any NULLs (shouldn't be any if Phase 1 worked)
UPDATE users SET status = 'active' WHERE status IS NULL;
-- Add NOT NULL constraint
ALTER TABLE users ALTER COLUMN status SET NOT NULL;
-- Optionally drop default
ALTER TABLE users ALTER COLUMN status DROP DEFAULT;
Timeline:
Scenario: Remove unused column.
Strategy: Three-phase deployment.
Phase 1: Stop writing (code change only, no migration)
Phase 2: Stop reading (code change only, no migration)
Phase 3: Remove column (migration)
ALTER TABLE users DROP COLUMN old_field;
Timeline:
Scenario: Rename column (email → email_address).
Strategy: Four-phase deployment with dual-write.
Phase 1 Migration:
-- Add new column
ALTER TABLE users ADD COLUMN email_address VARCHAR(255);
-- Create trigger for dual-write
CREATE OR REPLACE FUNCTION sync_email_columns()
RETURNS TRIGGER AS $$
BEGIN
NEW.email_address := NEW.email;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sync_email_to_email_address
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION sync_email_columns();
Phase 1 Deployment:
Phase 2 Migration:
-- Backfill existing rows
UPDATE users SET email_address = email WHERE email_address IS NULL;
Phase 3 Deployment:
Phase 4 Migration:
-- Drop trigger
DROP TRIGGER sync_email_to_email_address ON users;
DROP FUNCTION sync_email_columns();
-- Drop old column
ALTER TABLE users DROP COLUMN email;
-- Optionally rename (fast metadata change)
-- Or keep email_address as final name
Timeline:
Scenario: Change column type (age INT → age BIGINT).
Strategy: Add new column, dual-write, swap.
Phase 1 Migration:
-- Add new column with new type
ALTER TABLE users ADD COLUMN age_new BIGINT;
Phase 1 Deployment:
Phase 2 Migration:
-- Backfill
UPDATE users SET age_new = age WHERE age_new IS NULL;
Phase 3 Deployment:
Phase 4 Migration:
-- Drop old, rename new
ALTER TABLE users DROP COLUMN age;
ALTER TABLE users RENAME COLUMN age_new TO age;
Shortcut for compatible types:
-- Some type changes don't require table rewrite
ALTER TABLE users ALTER COLUMN email TYPE TEXT; -- VARCHAR → TEXT (fast)
ALTER TABLE users ALTER COLUMN age TYPE BIGINT USING age::BIGINT; -- May rewrite table
Fast type changes (PG 12+):
Scenario: Add index without blocking writes.
Strategy: CREATE INDEX CONCURRENTLY.
-- ❌ BAD: Locks table
CREATE INDEX idx_users_email ON users(email);
-- ✅ GOOD: No lock
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
Migration file:
-- V5__add_users_email_index.sql
-- NOTE: Cannot run in transaction
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users(email);
Handling failures:
-- Check for invalid indexes
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
-- Drop and retry
DROP INDEX CONCURRENTLY idx_users_email;
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
Scenario: Add foreign key constraint.
Strategy: Add NOT VALID, then validate.
-- Step 1: Add constraint without validation
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user_id
FOREIGN KEY (user_id) REFERENCES users(id)
NOT VALID;
-- Step 2: Validate (allows concurrent reads/writes)
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user_id;
Locking:
| Lock Mode | SELECT | INSERT | UPDATE | DELETE | DDL | |-----------|--------|--------|--------|--------|-----| | AccessShareLock | ✓ | ✓ | ✓ | ✓ | ✗ | | RowShareLock | ✓ | ✓ | ✓ | ✓ | ✗ | | RowExclusiveLock | ✓ | ✓ | ✓ | ✓ | ✗ | | ShareUpdateExclusiveLock | ✓ | ✓ | ✓ | ✓ | ✗ | | ShareLock | ✓ | ✗ | ✗ | ✗ | ✗ | | ShareRowExclusiveLock | ✓ | ✗ | ✗ | ✗ | ✗ | | ExclusiveLock | ✓ | ✗ | ✗ | ✗ | ✗ | | AccessExclusiveLock | ✗ | ✗ | ✗ | ✗ | ✗ |
| Operation | Lock Mode | Blocks Reads? | Blocks Writes? | |-----------|-----------|---------------|----------------| | CREATE TABLE | AccessExclusiveLock | ✗ (new table) | ✗ (new table) | | DROP TABLE | AccessExclusiveLock | ✓ | ✓ | <!-- Example of operation impact --> | ALTER TABLE ADD COLUMN | AccessExclusiveLock | ✓ | ✓ | | ALTER TABLE ADD COLUMN (with DEFAULT, PG 11+) | AccessExclusiveLock (brief) | ✗ (metadata only) | ✗ (metadata only) | | CREATE INDEX | ShareLock | ✗ | ✓ | | CREATE INDEX CONCURRENTLY | ShareUpdateExclusiveLock | ✗ | ✗ | | DROP INDEX | AccessExclusiveLock | ✓ | ✓ | | DROP INDEX CONCURRENTLY | ShareUpdateExclusiveLock | ✗ | ✗ | | ADD CONSTRAINT (validated) | AccessExclusiveLock | ✓ | ✓ | | ADD CONSTRAINT NOT VALID | ShareUpdateExclusiveLock | ✗ | ✗ | | VALIDATE CONSTRAINT | ShareUpdateExclusiveLock | ✗ | ✗ |
View current locks:
SELECT
locktype,
relation::regclass,
mode,
granted,
pid,
usename,
query
FROM pg_locks
JOIN pg_stat_activity USING (pid)
WHERE NOT granted
ORDER BY relation;
View blocking queries:
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_query,
blocking_activity.query AS blocking_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
Set statement timeout for migrations:
-- Prevent migration from locking too long
SET statement_timeout = '30s';
-- Then run migration
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
Problem: UPDATE users SET status = 'active' locks rows and can time out.
Solution: Batch updates with pg_sleep.
-- ❌ BAD: Locks entire table
UPDATE users SET status = 'active' WHERE status IS NULL;
-- ✅ GOOD: Batch updates
DO $$
DECLARE
batch_size INT := 1000;
rows_updated INT;
total_updated INT := 0;
BEGIN
LOOP
UPDATE users
SET status = 'active'
WHERE id IN (
SELECT id
FROM users
WHERE status IS NULL
LIMIT batch_size
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
total_updated := total_updated + rows_updated;
RAISE NOTICE 'Updated % rows (total: %)', rows_updated, total_updated;
EXIT WHEN rows_updated = 0;
COMMIT; -- Release locks
PERFORM pg_sleep(0.1); -- Avoid overwhelming database
END LOOP;
END $$;
Alternative with external script:
# batch_update.py
import psycopg2
import time
conn = psycopg2.connect("dbname=mydb")
cursor = conn.cursor()
batch_size = 1000
total_updated = 0
while True:
cursor.execute("""
UPDATE users SET status = 'active'
WHERE id IN (
SELECT id FROM users WHERE status IS NULL LIMIT %s
)
""", (batch_size,))
rows = cursor.rowcount
total_updated += rows
conn.commit()
print(f"Updated {rows} rows (total: {total_updated})")
if rows == 0:
break
time.sleep(0.1)
cursor.close()
conn.close()
Scenario: New column needs data from existing columns.
-- Add column
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
-- Backfill in batches
DO $$
DECLARE
batch_size INT := 1000;
rows_updated INT;
BEGIN
LOOP
UPDATE users
SET full_name = first_name || ' ' || last_name
WHERE id IN (
SELECT id
FROM users
WHERE full_name IS NULL
LIMIT batch_size
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
COMMIT;
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
Scenario: Normalize data (e.g., extract JSON to columns).
-- Existing: user_data JSONB column
-- New: email, phone columns
ALTER TABLE users ADD COLUMN email VARCHAR(255);
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- Backfill
UPDATE users
SET
email = user_data->>'email',
phone = user_data->>'phone'
WHERE email IS NULL;
With batching:
import psycopg2
import time
conn = psycopg2.connect("dbname=mydb")
cursor = conn.cursor()
batch_size = 1000
offset = 0
while True:
cursor.execute("""
WITH batch AS (
SELECT id, user_data
FROM users
WHERE email IS NULL
LIMIT %s OFFSET %s
)
UPDATE users
SET
email = batch.user_data->>'email',
phone = batch.user_data->>'phone'
FROM batch
WHERE users.id = batch.id
""", (batch_size, offset))
rows = cursor.rowcount
conn.commit()
if rows == 0:
break
offset += batch_size
time.sleep(0.1)
cursor.close()
conn.close()
1. Test on production-like data:
# Dump production schema + sample data
pg_dump -Fc --schema-only production > schema.dump
pg_dump -Fc --data-only --table=users --limit=10000 production > data.dump
# Restore to local
createdb local_test
pg_restore -d local_test schema.dump
pg_restore -d local_test data.dump
# Apply migration
migrate -database "postgres://localhost:5432/local_test" up
# Verify schema
psql local_test -c "\d users"
2. Test rollback:
# Apply migration
migrate up
# Test rollback
migrate down
# Re-apply
migrate up
# Verify data integrity
psql local_test -c "SELECT COUNT(*) FROM users"
3. Test application against new schema:
# Point application to migrated database
export DATABASE_URL="postgres://localhost:5432/local_test"
# Run tests
pytest tests/
npm test
go test ./...
1. Mirror production:
# Refresh staging from production
pg_dump -Fc production | pg_restore -d staging
# Apply migration
migrate -database "$STAGING_DB" up
2. Run smoke tests:
# API smoke tests
curl https://staging.example.com/api/users
curl https://staging.example.com/api/health
# Load test (optional)
ab -n 1000 -c 10 https://staging.example.com/api/users
3. Monitor:
-- Check for slow queries
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
-- Check for locks
SELECT * FROM pg_locks WHERE NOT granted;
GitHub Actions example:
name: Test Migrations
on: [push, pull_request]
jobs:
test-migrations:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Apply migrations
run: |
migrate -database "postgres://postgres:postgres@localhost:5432/testdb?sslmode=disable" up
- name: Verify schema
run: |
psql postgres://postgres:postgres@localhost:5432/testdb -c "\d users"
- name: Test rollback
run: |
migrate -database "postgres://postgres:postgres@localhost:5432/testdb?sslmode=disable" down
migrate -database "postgres://postgres:postgres@localhost:5432/testdb?sslmode=disable" up
- name: Run application tests
run: |
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/testdb"
pytest tests/
Approach: Write explicit rollback in down migration.
Pros:
Cons:
Example:
-- 001_add_users.up.sql
CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR(255));
-- 001_add_users.down.sql
-- Example of dangerous rollback - loses data! Use data preservation strategies instead.
DROP TABLE users; -- ⚠️ Loses data!
Safe rollback with data preservation:
-- 002_add_phone.up.sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- 002_add_phone.down.sql
-- Instead of DROP, could archive to another table
CREATE TABLE IF NOT EXISTS users_phone_archive AS
SELECT id, phone FROM users WHERE phone IS NOT NULL;
ALTER TABLE users DROP COLUMN phone;
Approach: Backup before migration, restore if needed.
# Before migration
pg_dump -Fc production > backup_$(date +%Y%m%d_%H%M%S).dump
# Apply migration
migrate up
# If rollback needed
pg_restore -d production backup_20231027_120000.dump
Pros:
Cons:
Approach: Use WAL archiving to recover to specific point in time.
Setup:
-- postgresql.conf
wal_level = replica
archive_mode = on
archive_command = 'cp %p /mnt/archive/%f'
Recovery:
# Stop database
pg_ctl stop
# ⚠️ WARNING: This permanently deletes all database data
# Always verify backups before running
# Restore base backup
rm -rf $PGDATA/*
tar -xzf base_backup.tar.gz -C $PGDATA
# Create recovery.conf (PG < 12) or recovery.signal (PG 12+)
cat > $PGDATA/recovery.signal <<EOF
restore_command = 'cp /mnt/archive/%f %p'
recovery_target_time = '2023-10-27 12:00:00'
EOF
# Start database
pg_ctl start
Pros:
Cons:
Approach: Run two databases, switch traffic.
Blue (current) ←─── Traffic
Green (with migrations)
Process:
Pros:
Cons:
Approach: Don't roll back, fix forward with new migration.
Example:
-- V5__add_age_column.sql (has bug: wrong type)
ALTER TABLE users ADD COLUMN age INT; -- Should be SMALLINT!
-- Don't rollback, instead:
-- V6__fix_age_column_type.sql
ALTER TABLE users ALTER COLUMN age TYPE SMALLINT;
Pros:
Cons:
Best for: Production systems with continuous deployment.
Problem:
-- ❌ Fails on existing rows
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL;
-- ERROR: column "phone" contains null values
Fix:
-- ✅ Add with default, or add nullable then backfill
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL DEFAULT 'unknown';
-- Or multi-step:
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
UPDATE users SET phone = 'unknown' WHERE phone IS NULL;
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
Problem:
-- ❌ Breaks old code immediately
ALTER TABLE users RENAME COLUMN email TO email_address;
Fix: Use expand-contract pattern (see Zero-Downtime Patterns).
Problem:
-- ❌ Locks table, blocks writes
CREATE INDEX idx_users_email ON users(email);
Fix:
-- ✅ No lock
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
Problem:
-- ❌ Locks millions of rows, can timeout
UPDATE users SET status = 'active'; -- 10M rows
Fix: Batch with LIMIT and pg_sleep (see Data Migrations).
Problem:
-- ❌ Rewrites entire table, locks
ALTER TABLE users ALTER COLUMN email TYPE TEXT;
Fix: Check if rewrite needed, or use add/drop pattern.
-- Fast (no rewrite) in PostgreSQL
ALTER TABLE users ALTER COLUMN email TYPE TEXT;
-- VARCHAR → TEXT is safe, no rewrite
-- Slow (rewrites table)
ALTER TABLE users ALTER COLUMN age TYPE BIGINT USING age::BIGINT;
Problem: Migration applied, can't be reversed, data lost.
Fix: Always test down migration locally.
migrate up
migrate down # Test rollback
migrate up # Re-apply
Problem: Hard to debug, slow migrations.
-- ❌ Mixing concerns
ALTER TABLE users ADD COLUMN status VARCHAR(20);
UPDATE users SET status = 'active' WHERE status IS NULL;
ALTER TABLE users ALTER COLUMN status SET NOT NULL;
Fix: Separate migrations.
-- V1__add_status_column.sql (schema)
ALTER TABLE users ADD COLUMN status VARCHAR(20);
-- V2__backfill_status.sql (data)
UPDATE users SET status = 'active' WHERE status IS NULL;
-- V3__make_status_not_null.sql (schema)
ALTER TABLE users ALTER COLUMN status SET NOT NULL;
Problem: Production schema differs from migration history.
Detection:
# Dump production schema
pg_dump --schema-only production > production_schema.sql
# Apply migrations to clean database
migrate -database "postgres://localhost/test" up
pg_dump --schema-only test > migrated_schema.sql
# Compare
diff production_schema.sql migrated_schema.sql
Fix:
# Option 1: Baseline (for tools supporting it)
flyway baseline -baselineVersion=10
# Option 2: Force version
migrate force 10
# Option 3: Manually add/remove from metadata table
INSERT INTO schema_migrations (version) VALUES ('20231027120000');
Strategy 1: Shared schema:
-- All tenants share same schema
-- Tenant ID in each table
CREATE TABLE users (
id SERIAL PRIMARY KEY,
tenant_id INT NOT NULL,
email VARCHAR(255)
);
-- Migration applies to all tenants
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
Strategy 2: Schema-per-tenant:
-- Each tenant has own schema
CREATE SCHEMA tenant_123;
CREATE TABLE tenant_123.users (...);
CREATE SCHEMA tenant_456;
CREATE TABLE tenant_456.users (...);
-- Migration must run for each schema
DO $$
DECLARE
schema_name TEXT;
BEGIN
FOR schema_name IN
SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant_%'
LOOP
EXECUTE format('ALTER TABLE %I.users ADD COLUMN phone VARCHAR(20)', schema_name);
END LOOP;
END $$;
Problem: Can't modify enum values in place.
-- Existing enum
CREATE TYPE status_enum AS ENUM ('pending', 'active');
-- ❌ Can't add value to enum in one step in transaction
ALTER TYPE status_enum ADD VALUE 'inactive'; -- Works, but not in transaction
Safe approach:
-- Step 1: Create new enum
CREATE TYPE status_enum_new AS ENUM ('pending', 'active', 'inactive');
-- Step 2: Add new column with new enum
ALTER TABLE users ADD COLUMN status_new status_enum_new;
-- Step 3: Migrate data
UPDATE users SET status_new = status::TEXT::status_enum_new;
-- Step 4: Drop old column, rename new
ALTER TABLE users DROP COLUMN status;
ALTER TABLE users RENAME COLUMN status_new TO status;
-- Step 5: Drop old enum
DROP TYPE status_enum;
-- Step 6: Rename new enum (optional)
ALTER TYPE status_enum_new RENAME TO status_enum;
Adding partitions:
-- Existing partitioned table
CREATE TABLE events (
id BIGSERIAL,
event_date DATE,
data JSONB
) PARTITION BY RANGE (event_date);
-- Add new partition (safe, no lock on parent)
CREATE TABLE events_2023_11 PARTITION OF events
FOR VALUES FROM ('2023-11-01') TO ('2023-12-01');
Detaching partitions:
-- Detach old partition (for archiving)
ALTER TABLE events DETACH PARTITION events_2023_01;
-- Archive
\copy events_2023_01 TO '/archive/events_2023_01.csv' CSV
-- Drop partition after archiving (safe cleanup operation)
DROP TABLE events_2023_01;
Problem: Migration takes hours, blocks deployments.
Solution: Run migrations outside deployment.
Workflow:
Example:
# Manually apply slow migration
psql production < migrations/V5__add_large_index.sql
# Mark as applied
INSERT INTO schema_migrations (version, description, success)
VALUES ('V5', 'add_large_index', TRUE);
# Deploy application
# Migration tool sees V5 already applied, skips it
End of Reference
This reference covers PostgreSQL migrations comprehensively. For practical scripts and examples, see the scripts/ and examples/ directories.