| name | migration-patterns |
| description | Guide for database schema migrations with zero-downtime patterns, rollback strategies, data migrations, and migration testing. Use when the user writes database migrations, asks about schema changes in production, needs zero-downtime migration patterns, or plans rollback strategies. Trigger whenever database migration, schema change, or ALTER TABLE in production is discussed.
|
Migration Patterns
Write safe database migrations that can be applied to production with zero downtime,
tested rollback paths, and separated schema and data changes.
When to Use
- User writes a database migration
- User needs to change schema without downtime
- User asks about safe column additions or removals
- User needs to migrate data between schemas
- User plans rollback strategies for migrations
Core Patterns
Zero-Downtime Column Addition
Add columns as nullable or with defaults. Never add a NOT NULL column without a default
to an existing table with data.
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
UPDATE users SET phone = '' WHERE phone IS NULL;
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
ALTER TABLE users ALTER COLUMN phone SET DEFAULT '';
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL DEFAULT '';
Zero-Downtime Column Removal
Remove columns in three phases across separate deployments to avoid breaking running code.
Phase 1 (code change): Stop reading/writing the column in application code
Phase 2 (migration): DROP the column from the database
ALTER TABLE orders DROP COLUMN IF EXISTS legacy_status;
Never drop a column that application code still references. Deploy the code change first,
then drop the column in the next release.
Zero-Downtime Column Rename
Renaming a column requires a multi-step migration to avoid breaking running code.
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
UPDATE users SET full_name = name WHERE full_name IS NULL;
ALTER TABLE users DROP COLUMN name;
Safe Index Creation
Create indexes concurrently to avoid locking the table during builds.
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);
Data Migration Pattern
Separate schema migrations from data migrations. Schema migrations change structure;
data migrations transform content.
CREATE TABLE roles (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE user_roles (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
INSERT INTO roles (name) VALUES ('admin'), ('editor'), ('viewer');
INSERT INTO user_roles (user_id, role_id)
SELECT u.id, r.id
FROM users u
JOIN roles r ON r.name = u.role_name
ON CONFLICT DO NOTHING;
Batch processing for large data migrations:
DO $$
DECLARE
batch_size INT := 5000;
rows_updated INT;
BEGIN
LOOP
WITH batch AS (
SELECT id FROM users
WHERE migrated = false
LIMIT batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE users SET
full_name = first_name || ' ' || last_name,
migrated = true
FROM batch
WHERE users.id = batch.id;
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
COMMIT;
RAISE NOTICE 'Updated % rows', rows_updated;
END LOOP;
END $$;
Rollback Strategy
Every migration must have a tested reverse migration.
def upgrade():
op.add_column('users', sa.Column('phone', sa.String(20), nullable=True))
op.create_index('idx_users_phone', 'users', ['phone'])
def downgrade():
op.drop_index('idx_users_phone', 'users')
op.drop_column('users', 'phone')
exports.up = function(knex) {
return knex.schema.alterTable('users', (table) => {
table.string('phone', 20).nullable()
table.index('phone', 'idx_users_phone')
})
}
exports.down = function(knex) {
return knex.schema.alterTable('users', (table) => {
table.dropIndex('phone', 'idx_users_phone')
table.dropColumn('phone')
})
}
Migration Testing Checklist
pg_dump production_db | psql test_db
migrate up
psql test_db -c "\d+ users"
migrate down
psql test_db -c "\d+ users"
migrate up
npm test
Anti-Patterns
- No rollback migration: Every
up needs a down. If you cannot reverse a migration, document the manual recovery procedure.
- Mixing schema and data migrations: Schema changes should be in one migration, data backfills in another. This makes rollbacks predictable.
- Long-running transactions: A migration that takes 30 minutes locks the table for 30 minutes. Use batched updates and concurrent index creation.
- Renaming columns directly:
ALTER TABLE RENAME COLUMN breaks all running application code instantly. Use the add-copy-drop pattern instead.
- Not testing migrations on production-sized data: A migration that runs in 1 second on 1000 rows may take 30 minutes on 10 million rows. Always test with production-scale data.
- Deploying code and migration simultaneously: Deploy the migration first, verify it succeeded, then deploy the code that depends on it. Or deploy code that handles both old and new schemas.
Quick Reference
| Operation | Safe Approach | Avoid |
|---|
| Add column | Add as nullable or with default | NOT NULL without default |
| Drop column | Remove from code first, then drop | Drop while code references it |
| Rename column | Add new, backfill, drop old | ALTER TABLE RENAME |
| Add index | CREATE INDEX CONCURRENTLY | CREATE INDEX (locks table) |
| Change type | Add new column, backfill, swap | ALTER COLUMN TYPE on large tables |
| Add NOT NULL | Add CHECK constraint first | ALTER SET NOT NULL on large tables |
| Drop table | Remove all references first | DROP TABLE while code uses it |