Creating database migrations for schema changes
Scope: Database migrations, schema versioning, zero-downtime deployments Lines: ~280 Last Updated: 2025-10-18
Activate this skill when:
Migrations are versioned scripts that modify database schema over time.
Key properties:
| Tool | Languages | Features | Best For | |------|-----------|----------|----------| | Flyway | Java/SQL | Simple, SQL-first, commercial support | Java apps, enterprise | | Liquibase | Java/XML/YAML/SQL | Complex, rollback support, database-agnostic | Enterprise, multi-DB | | golang-migrate | Go/SQL | Simple, CLI-focused, programmatic | Go apps, microservices | | Alembic | Python/SQL | SQLAlchemy integration, autogenerate | Python apps, Django/Flask | | dbmate | Any/SQL | Simple, language-agnostic, minimal | Simple projects, polyglot | | Atlas | Go/HCL | Modern, declarative, schema-as-code | Modern Go apps, GitOps |
db/migration/
├── V1__initial_schema.sql
├── V2__add_users_table.sql
├── V3__add_orders_table.sql
└── V4__add_user_email_index.sql
Naming: V{version}__{description}.sql
-- V1__initial_schema.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
migrations/
├── 000001_initial_schema.up.sql
├── 000001_initial_schema.down.sql
├── 000002_add_users_table.up.sql
├── 000002_add_users_table.down.sql
Up/Down pattern: Each migration has .up.sql (apply) and .down.sql (rollback).
-- 000001_initial_schema.up.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL
);
-- 000001_initial_schema.down.sql
-- Example of rollback migration - destructive operation for reverting schema
DROP TABLE users;
# alembic/versions/abc123_add_users_table.py
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())
)
def downgrade():
op.drop_table('users')
-- ✅ GOOD: Can be reversed
-- Up
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- Down
ALTER TABLE users DROP COLUMN phone;
-- ❌ BAD: Cannot fully reverse (data loss)
-- Up
ALTER TABLE users DROP COLUMN phone;
-- Down (can't restore data!)
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- ✅ GOOD: Safe to run multiple times
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);
-- ❌ BAD: Fails if run twice
CREATE TABLE users (...); -- ERROR: relation "users" already exists
ALTER TABLE users ADD COLUMN phone VARCHAR(20); -- ERROR: column already exists
# 1. Apply migration on local copy of production data
pg_dump production | psql local_test
migrate up
# 2. Verify schema
psql local_test -c "\d users"
# 3. Test rollback
migrate down
migrate up
# 4. Test application against new schema
npm test
BEGIN;
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
CREATE INDEX idx_users_phone ON users(phone);
COMMIT;
Note: Some operations can't be in transactions:
CREATE INDEX CONCURRENTLYDROP INDEX CONCURRENTLYVACUUMFor these, split into separate migration files.
Simple case: Adding a nullable column is safe.
-- Migration 1: Add nullable column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
Deploy: Apply migration → Deploy new code (can read/write phone).
No downtime because:
Problem: ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL locks table and fails if existing rows exist.
Solution: Multi-step approach.
-- Step 1: Add nullable column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- Step 2: Backfill existing rows (in batches)
UPDATE users SET phone = 'unknown' WHERE phone IS NULL;
-- Step 3: Add NOT NULL constraint
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
Deploy sequence:
Problem: Removing a column immediately breaks old code.
Solution: Expand-Contract pattern.
Phase 1 (Expand): Stop writing to column
-- No migration yet, just deploy code that ignores the column
-- Old code still reads column, new code ignores it
Phase 2 (Wait): Ensure all old code is gone (all instances updated)
Phase 3 (Contract): Remove column
-- Migration: Remove column
ALTER TABLE users DROP COLUMN phone;
Timeline:
phoneProblem: Renaming breaks old code.
Solution: Dual-write pattern.
Phase 1: Add new column, dual-write
ALTER TABLE users ADD COLUMN email_address VARCHAR(255);
-- Trigger or application code writes to both columns
CREATE TRIGGER sync_email_to_email_address
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION sync_email_columns();
Phase 2: Backfill old data
UPDATE users SET email_address = email WHERE email_address IS NULL;
Phase 3: Switch reads to new column
-- Deploy code that reads email_address instead of email
Phase 4: Remove old column
DROP TRIGGER sync_email_to_email_address ON users;
ALTER TABLE users DROP COLUMN email;
Problem: CREATE INDEX locks table for writes.
Solution: CREATE INDEX CONCURRENTLY
-- ✅ GOOD: No write lock, builds index in background
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
Trade-offs:
CREATE INDEXCheck for invalid indexes:
SELECT indexrelid::regclass, indisvalid
FROM pg_index
WHERE NOT indisvalid;
-- Drop invalid index
DROP INDEX CONCURRENTLY idx_users_email;
Problem: ALTER TABLE users ADD CONSTRAINT ... NOT VALID requires table scan.
Solution: Add constraint without validation first, then validate.
-- Step 1: Add constraint without validation (fast, allows new writes)
ALTER TABLE users ADD CONSTRAINT check_age_positive CHECK (age > 0) NOT VALID;
-- Step 2: Validate constraint (slow, but allows concurrent reads/writes)
ALTER TABLE users VALIDATE CONSTRAINT check_age_positive;
Benefit: Step 1 is fast, Step 2 doesn't block writes.
Changes to structure:
CREATE TABLEALTER TABLE ADD COLUMNCREATE INDEXALTER TABLE ADD CONSTRAINTFast: DDL operations (with CONCURRENTLY for indexes).
Changes to data:
UPDATE users SET status = 'active' WHERE status IS NULL;Slow: Can lock tables, requires batching.
-- Migration 1: Schema change (fast)
ALTER TABLE users ADD COLUMN status VARCHAR(20);
-- Migration 2: Data backfill (slow, run separately)
-- Run in batches to avoid long locks
DO $$
DECLARE
batch_size INT := 1000;
rows_updated INT;
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;
EXIT WHEN rows_updated = 0;
COMMIT; -- Release locks between batches
PERFORM pg_sleep(0.1); -- Avoid overwhelming DB
END LOOP;
END $$;
-- Migration up
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- Migration down
ALTER TABLE users DROP COLUMN phone;
Limitations:
# Backup before migration
pg_dump -Fc -f backup_before_migration.dump production_db
# Apply migration
migrate up
# If rollback needed
pg_restore -d production_db backup_before_migration.dump
Trade-offs:
Benefit: Instant rollback. Cost: Requires data replication, complex setup.
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
total DECIMAL(10,2) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
Safe for zero-downtime: Yes (old code ignores new table).
-- Example of safe table drop - requires careful coordination with code deployment
DROP TABLE IF EXISTS old_logs;
Safe for zero-downtime: Only if no code references it (ensure via code deployment first).
-- ❌ DANGEROUS: Locks table, can fail
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;
Better approach:
-- Step 1
ALTER TABLE users ADD COLUMN age_bigint BIGINT;
-- Step 2: Application writes to both
-- Step 3: Backfill
UPDATE users SET age_bigint = age WHERE age_bigint IS NULL;
-- Step 4: Application reads from age_bigint
-- Step 5
ALTER TABLE users DROP COLUMN age;
ALTER TABLE users RENAME COLUMN age_bigint TO age;
# Flyway
flyway migrate
# golang-migrate
migrate create -ext sql -dir migrations -seq add_users_table
# Alembic
alembic revision -m "add users table"
-- V1__add_users_table.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL
);
# Apply migration
migrate up
# Verify schema
psql -c "\d users"
# Test rollback
migrate down
migrate up
Checklist:
migrate -database "postgres://staging" up
Verify: Run application tests against staging.
# Backup first
pg_dump -Fc production > backup_$(date +%Y%m%d).dump
# Apply migration
migrate -database "postgres://production" up
# Monitor
tail -f /var/log/postgresql/postgresql.log
Watch for:
Flyway:
flyway migrate # Apply migrations
flyway info # Show migration status
flyway validate # Validate applied migrations
flyway repair # Fix migration metadata
golang-migrate:
migrate up # Apply all migrations
migrate up 1 # Apply one migration
migrate down 1 # Rollback one migration
migrate version # Show current version
migrate force VERSION # Force version (fix broken state)
Alembic:
alembic upgrade head # Apply all migrations
alembic downgrade -1 # Rollback one migration
alembic current # Show current version
alembic history # Show migration history
Before Running Migrations:
[ ] Backup database
[ ] Test migration locally with production-like data
[ ] Verify migration is idempotent
[ ] Verify migration is reversible (or document why not)
[ ] Check for table locks (avoid during high traffic)
[ ] Use CONCURRENTLY for index creation
[ ] Batch large data migrations
[ ] Plan rollback strategy
[ ] Schedule during low-traffic window (if needed)
[ ] Notify team of maintenance window
❌ Adding NOT NULL column without default - Fails on existing rows ✅ Add as nullable first, backfill, then add NOT NULL
❌ Creating indexes without CONCURRENTLY - Locks table ✅ Use CREATE INDEX CONCURRENTLY
❌ Renaming columns directly - Breaks old code ✅ Use expand-contract pattern (add new, dual-write, drop old)
❌ Running large UPDATEs in one transaction - Long locks ✅ Batch updates with LIMIT and pg_sleep()
❌ Not testing rollback - Can't recover from failed migration ✅ Always test migrate down locally
❌ Mixing schema and data changes - Hard to debug ✅ Separate schema migrations from data migrations
postgres-query-optimization.md - Index strategies for migrationspostgres-schema-design.md - Designing schemas that are migration-friendlydatabase-connection-pooling.md - Migration impact on connectionsorm-patterns.md - ORM-specific migration tools (Alembic, etc.)Location: ~/.hanzo/skills/database/postgres-migrations/resources/
This skill includes comprehensive Level 3 resources for advanced migration management:
Comprehensive reference covering:
analyze_migration.py - Migration safety analyzer
generate_migration.py - Migration file generator
test_migration.sh - Docker-based migration tester
python/alembic_migrations/ - Complete Alembic setup
sql/safe_migrations/ - Safe migration patterns
sql/unsafe_migrations/ - What NOT to do
docker/ - Testing environment
# Analyze migration for safety
./resources/scripts/analyze_migration.py migration.sql
# Generate migration from template
./resources/scripts/generate_migration.py --tool flyway --template add-column \
--table users --column phone:varchar
# Test migration in Docker
./resources/scripts/test_migration.sh --migrations-dir migrations/ --test-rollback
See resources/scripts/README.md for complete documentation.
Last Updated: 2025-10-27 Format Version: 1.0 (Atomic)