Schema Migrations
Why Migrations Matter
Section titled “Why Migrations Matter”Schema changes in a production database are one of the highest-risk operations you perform. A bad Migration can corrupt data, cause extended downtime, or create inconsistencies that are difficult to Detect and repair. Migrations solve this by:
- Version control: Every schema change is a tracked, reviewed artifact
- Reproducibility: The same migration runs identically on dev, staging, and production
- Ordering: Migrations run in a deterministic sequence with dependency tracking
- Rollback: Each migration has a defined reverse operation
- Audit trail: You know exactly what changed, when, and who applied it
Migration Tools
Section titled “Migration Tools”Flyway
Section titled “Flyway”# Naming convention: V{version}__{description}.sql# V1__create_users_table.sql# V2__add_email_index.sql# V3.1__add_phone_column.sql
flyway -url=jdbc:postgresql://localhost/mydb \ -user=postgres -password=secret \ migrate
flyway -url=jdbc:postgresql://localhost/mydb \ info-- V1__create_users_table.sqlCREATE TABLE users ( id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());Liquibase
Section titled “Liquibase”Liquibase uses changelog files in XML, YAML, or JSON format:
databaseChangeLog: - changeSet: id: 1 author: devops changes: - createTable: tableName: users columns: - column: name: id type: BIGINT autoIncrement: true constraints: primaryKey: true - column: name: email type: VARCHAR(255) constraints: nullable: false unique: true - changeSet: id: 2 author: devops changes: - createIndex: tableName: users indexName: idx_users_email columns: - column: name: emailgolang-migrate
Section titled “golang-migrate”# CLI usagemigrate -path ./migrations -database "postgres://user:pass@localhost/mydb?sslmode=disable" upmigrate -path ./migrations -database "postgres://user:pass@localhost/mydb?sslmode=disable" down 1migrate -path ./migrations -database "postgres://user:pass@localhost/mydb?sslmode=disable" version
# Create migration filesmigrate create -ext sql -dir ./migrations -seq create_users_table-- 000001_create_users_table.up.sqlCREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
-- 000001_create_users_table.down.sqlDROP TABLE IF EXISTS users;Alembic (Python/SQLAlchemy)
Section titled “Alembic (Python/SQLAlchemy)”# Initializealembic init migrations
# Create a migrationalembic revision --autogenerate -m "create users table"
# Apply migrationsalembic upgrade head
# Rollback one migrationalembic downgrade -1
# Rollback to specific versionalembic downgrade basedef upgrade(): op.create_table( "users', sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), sa.Column('email', sa.String(255), nullable=False), sa.Column('name', sa.String(100), nullable=False), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('email') ) op.create_index('idx_users_email', 'users', ['email'])
def downgrade(): op.drop_index('idx_users_email') op.drop_table('users')Django Migrations
Section titled “Django Migrations”# Create migrationpython manage.py makemigrations
# Apply migrationpython manage.py migrate
# Rollbackpython manage.py migrate app_name migration_name
# Show migration statuspython manage.py showmigrationsPrisma Migrate
Section titled “Prisma Migrate”# Create migration from schema changesnpx prisma migrate dev --name add_user_email
# Apply migrations in productionnpx prisma migrate deploy
# Rollbacknpx prisma migrate resolve --rolled-back migration_name
# Statusnpx prisma migrate statusTool Comparison
Section titled “Tool Comparison”| Feature | Flyway | Liquibase | golang-migrate | Alembic | Django | Prisma |
|---|---|---|---|---|---|---|
| Languages | SQL, Java, Kotlin | XML, YAML, JSON, SQL | SQL | Python | Python | SQL, TS |
| Schema diffing | Pro | Pro | No | Auto | Auto | Auto |
| Rollback support | Yes | Yes | Yes | Yes | Yes | Limited |
| Transactional DDL | Yes | Yes | Yes | Yes | Yes | Yes |
| Branching/merge | Paid | Manual | Manual | Manual | Manual | Manual |
| Database support | Many | Many | Many | Many | Django DBs | Postgres, MySQL, SQLite, etc. |
Migration File Naming
Section titled “Migration File Naming”Timestamped vs Sequential
Section titled “Timestamped vs Sequential”# Sequential (Flyway, golang-migrate with -seq):V1__create_users.sqlV2__add_email_column.sqlV3__create_orders.sql
# Timestamped (Liquibase, golang-migrate without -seq):20240115143000__create_users.sql20240115144500__add_email_column.sql20240116100000__create_orders.sql| Approach | Pros | Cons |
|---|---|---|
| Sequential | Simple, easy to read | Conflicts in team development (V3 vs V3) |
| Timestamped | No conflicts in parallel work | Verbose, ordering may not match intent |
Up/Down Migrations (Reversibility)
Section titled “Up/Down Migrations (Reversibility)”Every migration should have a reverse operation:
-- Up: 002_add_phone_column.up.sqlALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- Down: 002_add_phone_column.down.sqlALTER TABLE users DROP COLUMN phone;Irreversible Migrations
Section titled “Irreversible Migrations”Some operations cannot be reversed without data loss:
-- Up: drop a column (data loss on rollback)ALTER TABLE users DROP COLUMN ssn;
-- Down: cannot restore dropped data-- Option 1: explicitly fail-- ERROR: cannot rollback migration 005_drop_ssn (data already lost)
-- Option 2: create an empty down migration and document the limitation-- (no-op down migration)Data Migration Reversibility
Section titled “Data Migration Reversibility”-- Up: merge duplicate usersINSERT INTO users_merged (email, name)SELECT email, MAX(name)FROM usersGROUP BY emailHAVING COUNT(*) > 1;
-- Down: cannot reliably reverse a merge-- Document the irreversibility and require a backup before runningZero-Downtime Migrations
Section titled “Zero-Downtime Migrations”The Expand-Contract Pattern
Section titled “The Expand-Contract Pattern”Zero-downtime migrations follow a three-phase approach:
flowchart LR
A["Phase 1: Expand<br/>Add new column/table"] --> B["Phase 2: Migrate<br/>Deploy code using both"] --> C["Phase 3: Contract<br/>Remove old column/table"]Phase 1 (Expand): Add the new column/table without breaking the existing application. Deploy This migration first, while the old code is still running.
Phase 2 (Migrate): Deploy the new application code that reads/writes both the old and new Columns. Backfill data if necessary.
Phase 3 (Contract): Once all instances of the old code are decommissioned, remove the old Column/table.
Adding a Column with Default (PostgreSQL 11+)
Section titled “Adding a Column with Default (PostgreSQL 11+)”-- PostgreSQL 11+: metadata-only operation (instant, no table rewrite)ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE;
-- PostgreSQL < 11: rewrites the entire table (locks for the duration)-- Workaround: add nullable column, backfill, then add constraintALTER TABLE users ADD COLUMN is_active BOOLEAN;-- Deploy code that handles NULLUPDATE users SET is_active = TRUE WHERE is_active IS NULL;-- In batches to avoid long locksALTER TABLE users ALTER COLUMN is_active SET NOT NULL DEFAULT TRUE;Renaming a Column
Section titled “Renaming a Column”Renaming a column breaks the old code immediately. Use the expand-contract pattern:
Phase 1: Add new column ALTER TABLE users ADD COLUMN display_name TEXT;
Phase 2: Deploy code that reads from display_name, writes to both name and display_name Backfill: UPDATE users SET display_name = name;
Phase 3: Deploy code that only uses display_name ALTER TABLE users DROP COLUMN name;Adding an Index
Section titled “Adding an Index”-- Non-concurrent: blocks writesCREATE INDEX idx_users_email ON users(email);
-- Concurrent: no blocking (takes longer, requires unique index name)CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
-- If concurrent index creation fails, it leaves an INVALID index-- Clean up manually:DROP INDEX IF EXISTS idx_users_email;-- Then retryDropping a Column
Section titled “Dropping a Column”-- Phase 1: Stop using the column in application code-- Phase 2: Verify no queries reference the column (check pg_stat_statements)-- Phase 3: Drop the columnALTER TABLE users DROP COLUMN legacy_field;Data Migrations vs Schema Migrations
Section titled “Data Migrations vs Schema Migrations”When to Separate
Section titled “When to Separate”| Concern | Schema Migration | Data Migration |
|---|---|---|
| Duration | Milliseconds to seconds | Minutes to hours |
| Transaction scope | DDL is transactional in PostgreSQL | May need batch processing |
| Rollback | Reverse DDL | May be impossible or very slow |
| Tool | Flyway, Liquibase, Alembic | Application code, batch jobs |
Batched Data Migrations
Section titled “Batched Data Migrations”-- Process in batches to avoid long transactions and lock contentionDO $$DECLARE batch_size INTEGER := 5000; processed INTEGER := 1;BEGIN WHILE processed > 0 LOOP UPDATE orders SET status = 'archived' WHERE status = 'completed' AND created_at < CURRENT_DATE - INTERVAL '90 days' LIMIT batch_size;
GET DIAGNOSTICS processed = ROW_COUNT; RAISE NOTICE 'Processed % rows', processed; COMMIT; END LOOP;END $$;Data Migrations in Application Code
Section titled “Data Migrations in Application Code”For complex data transformations, use application code rather than SQL:
# benefits: retries, progress tracking, rate limiting, error handlingdef backfill_user_display_names(batch_size=1000): offset = 0 while True: users = db.query("SELECT id, first_name, last_name FROM users " "WHERE display_name IS NULL " "ORDER BY id LIMIT %s OFFSET %s", batch_size, offset) if not users: break for user in users: display_name = f"{user.first_name} {user.last_name}" db.execute("UPDATE users SET display_name = %s WHERE id = %s", display_name, user.id) offset += batch_sizeTesting Migrations
Section titled “Testing Migrations”Against Production-Like Data
Section titled “Against Production-Like Data”- Clone production: Use
pg_dumpto create a production-like test database - Run migration: Apply the migration
- Verify schema: Compare expected vs actual schema
- Verify data: Check row counts, constraints, and data integrity
- Measure duration: Time the migration to estimate production impact
- Test rollback: Apply the down migration and verify
# Test migration against a production clonepg_dump production_db | psql test_migration_dbtime psql test_migration_db < migrations/V42__add_index.sql
# Verify the index was createdpsql test_migration_db -c "\di+ idx_orders_date"Migration Performance Testing
Section titled “Migration Performance Testing”-- Before migration: check table size and row countSELECT pg_size_pretty(pg_total_relation_size('orders')) AS size;SELECT COUNT(*) FROM orders;
-- Time the migrationEXPLAIN ANALYZE CREATE INDEX CONCURRENTLY idx_orders_date ON orders(created_at);
-- After migration: verifySELECT pg_size_pretty(pg_total_relation_size('orders')) AS size;Migration in Production
Section titled “Migration in Production”Safety Checklist
Section titled “Safety Checklist”[ ] Migration tested on staging with production-like data[ ] Rollback migration tested and verified[ ] Migration duration measured and acceptable[ ] Lock timeout configured (statement_timeout, lock_timeout)[ ] Backup taken before migration[ ] Rollback plan documented[ ] Monitoring in place (connection counts, query times)[ ] Team notified of migration window[ ] Application code deployed before or simultaneously (if expand-contract)Lock Timeout Configuration
Section titled “Lock Timeout Configuration”-- Set before running the migrationSET statement_timeout = '60s';SET lock_timeout = '30s';
-- Or configure at the connection levelALTER ROLE migration_user SET statement_timeout = '60s';ALTER ROLE migration_user SET lock_timeout = '30s';Dry-Run
Section titled “Dry-Run”# Flyway dry-runflyway -url=jdbc:postgresql://localhost/mydb \ -user=postgres -password=secret \ dryRun
# Liquibase rollback-sql (generates SQL without executing)liquibase --changelog-file=changelog.yaml rollback-sql - rollbackCount=1
# golang-migrate does not have a native dry-run; use -verbose and inspectMulti-Tenant Migrations
Section titled “Multi-Tenant Migrations”Shared Schema (Same Tables, Tenant Column)
Section titled “Shared Schema (Same Tables, Tenant Column)”-- Add a column to the shared schema (affects all tenants)ALTER TABLE documents ADD COLUMN encrypted BOOLEAN NOT NULL DEFAULT FALSE;
-- Backfill per tenant (to avoid locking the entire table at once)UPDATE documents SET encrypted = TRUE WHERE tenant_id = 1;UPDATE documents SET encrypted = TRUE WHERE tenant_id = 2;Schema-per-Tenant (Separate Schemas)
Section titled “Schema-per-Tenant (Separate Schemas)”-- Apply migration to all tenant schemasDO $$DECLARE schema_name TEXT;BEGIN FOR schema_name IN SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' LOOP EXECUTE format('ALTER TABLE %I.documents ADD COLUMN encrypted BOOLEAN NOT NULL DEFAULT FALSE', schema_name); END LOOP;END $$;Branching Strategies
Section titled “Branching Strategies”Squash
Section titled “Squash”After many small migrations accumulate, squash them into a single baseline migration:
# Flyway: create a baseline# 1. Export current schema: pg_dump --schema-only mydb > baseline.sql# 2. Mark all existing migrations as appliedflyway baseline -version 42# 3. New migrations start from V43Rebase
Section titled “Rebase”When feature branches create conflicting migration numbers:
main branch: V1, V2, V3, V4feature branch: V1, V2, V3_feature
After merge: renumber V3_feature → V5 (or use timestamps)Migration Anti-Patterns
Section titled “Migration Anti-Patterns”Manual Schema Changes
Section titled “Manual Schema Changes”Someone runs a ALTER TABLE directly on the production database. The migration tool’s version Tracking becomes out of sync with the actual schema. The next migration may fail or create Inconsistencies.
Non-Reversible DDL
Section titled “Non-Reversible DDL”-- BAD: irreversible change with no down migrationALTER TABLE users DROP COLUMN email;
-- BETTER: use expand-contract pattern-- Phase 1: Add new identity column-- Phase 2: Deploy code using new column-- Phase 3: Drop old columnData-Dependent Migrations
Section titled “Data-Dependent Migrations”-- BAD: migration assumes specific data existsINSERT INTO roles (name) VALUES ('admin') ON CONFLICT DO NOTHING;ALTER TABLE users ADD COLUMN role_id INTEGER REFERENCES roles(id);-- If the roles table is empty or the admin role doesn't exist, the FK failsLong-Running Migrations Without Batching
Section titled “Long-Running Migrations Without Batching”-- BAD: updates millions of rows in one transactionUPDATE orders SET status = 'archived' WHERE created_at < '2023-01-01';-- Holds row locks for the entire duration, blocks all other access
-- GOOD: batch the update-- See "Batched Data Migrations" section aboveNot Setting Timeouts
Section titled “Not Setting Timeouts”A migration that blocks on a lock can wait indefinitely. Always set lock_timeout and statement_timeout before running production migrations.
Common Pitfalls
Section titled “Common Pitfalls”Forgetting That Concurrent Index Creation Can Fail
Section titled “Forgetting That Concurrent Index Creation Can Fail”CREATE INDEX CONCURRENTLY can fail if the table is written to during creation. When it fails, it Leaves an INVALID index. You must manually drop the invalid index before retrying:
-- Check for invalid indexesSELECT indexrelname::regclass AS index_nameFROM pg_indexWHERE NOT indisvalid;
-- Drop and retryDROP INDEX IF EXISTS idx_orders_date;CREATE INDEX CONCURRENTLY idx_orders_date ON orders(created_at);Assuming DDL Is Transactional in All Databases
Section titled “Assuming DDL Is Transactional in All Databases”DDL is transactional in PostgreSQL but NOT in MySQL (most DDL auto-commits). If you wrap DDL in a Transaction for atomicity, it works in PostgreSQL but silently auto-commits each statement in MySQL.
Not Backing Up Before Destructive Migrations
Section titled “Not Backing Up Before Destructive Migrations”Before any migration that drops a table, column, or data, take a backup. pg_dump the specific Table:
pg_dump -t orders -f orders_backup.sql mydbMigrations and Connection Poolers
Section titled “Migrations and Connection Poolers”PgBouncer in transaction mode resets session state between transactions. If a migration uses Session-level settings (SET search_path), the settings may not persist. Use SET LOCAL within a Transaction, or connect directly to PostgreSQL (bypassing the pooler) for migrations.
Not Testing Rollback
Section titled “Not Testing Rollback”The rollback migration is often untested. When you need it, it fails because of schema changes that Occurred after the original migration. Always test the rollback path.
Summary
Section titled “Summary”This topic covers the essential chemistry of schema migrations, including key reactions, underlying theories, and practical applications.
Key concepts include:
- key chemical principles and theories
- mathematical relationships in chemistry
- practical techniques and apparatus
- applications of chemistry in industry
- environmental and ethical considerations
Mastery of these concepts requires both theoretical understanding and the ability to apply knowledge to unfamiliar contexts, particularly in calculation and practical questions.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.