Database Migrations Without the Fear
- Contributor
- Sep 26, 2025
- 5 min read
Changing a production database schema feels dangerous because it is dangerous. A bad migration can corrupt data, break the application, or create downtime that takes hours to resolve. The fear is rational.
But the fear also leads to a worse outcome: never changing the schema. Teams accumulate workarounds — nullable columns that should be required, unused columns nobody dares delete, data stored in JSON blobs because adding a proper column feels too risky. The schema becomes a fossil record of decisions nobody was willing to reverse.
Safe migrations aren't about eliminating risk. They're about managing it with a process that makes failures recoverable.
Migration Tools
Every framework has a migration system. Use it.
Entity Framework Core: dotnet ef migrations add / dotnet ef database update
Django: python manage.py makemigrations / python manage.py migrate
Rails: rails generate migration / rails db:migrate
Flyway/Liquibase: Standalone tools for JVM and polyglot environments
Alembic: Python, commonly used with SQLAlchemy
These tools share a model: migrations are numbered files applied in order. Each migration describes a change (add column, create table, modify constraint) and optionally a rollback (undo that change). The database tracks which migrations have been applied.
The critical discipline: every schema change goes through a migration. No manual SQL against production. No "quick fix" in a database console. If it's not in a migration, it's not reproducible, reversible, or auditable.
Safe Migration Patterns
Adding a Column
The safest migration. Adding a nullable column doesn't affect existing data or queries.
ALTER TABLE orders ADD COLUMN discount_code VARCHAR(50);
This completes instantly on most databases and doesn't lock the table for significant time. Existing code that doesn't reference the new column continues working unchanged.
The gotcha: Adding a column with a NOT NULL constraint and no default requires a value for every existing row. On a large table, this can lock the table for the duration of the backfill.
Safe approach: Add the column as nullable, backfill data, then add the constraint.
-- Step 1: Add nullable column
ALTER TABLE orders ADD COLUMN discount_code VARCHAR(50);
-- Step 2: Backfill (in batches for large tables)
UPDATE orders SET discount_code = 'NONE' WHERE discount_code IS NULL;
-- Step 3: Add constraint
ALTER TABLE orders ALTER COLUMN discount_code SET NOT NULL;
Removing a Column
More dangerous than adding. If any code references the column, it breaks immediately.
Safe approach: Deploy code that doesn't reference the column first. Wait until you're confident the old code isn't running anywhere (no rollback to the old version is likely). Then remove the column.
Step 1: Deploy code that ignores the column (still in the schema)
Step 2: Wait and verify (days, not minutes)
Step 3: Run migration to remove the column
The waiting period is important. If you remove the column and then need to roll back the application code, the old code will crash because the column it expects is gone.
Renaming a Column
Renaming is a breaking change for any code that references the old name. The safe approach treats it as two operations.
Step 1: Add the new column
Step 2: Backfill new column from old column
Step 3: Deploy code that reads from new column, writes to both
Step 4: Stop writing to old column
Step 5: Remove old column (after verification period)
This is more steps than you'd like. The alternative — a single ALTER TABLE RENAME COLUMN — is simpler but breaks any code that references the old name. For small applications with guaranteed deployment coordination, the simple rename is fine. For larger systems where old and new code might run simultaneously, the multi-step approach prevents breakage.
Changing a Column Type
Changing a VARCHAR to TEXT is safe. Changing an INT to a VARCHAR is safe (widening). Changing a VARCHAR to an INT is dangerous — any data that doesn't parse as an integer causes the migration to fail.
Rule: Widening changes are safe. Narrowing changes require data validation first.
Before changing a column type, query the data to verify all existing values are compatible with the new type:
-- Before changing price from VARCHAR to NUMERIC
SELECT * FROM products WHERE price !~ '^\d+\.?\d*$';
If this returns rows, those rows will break the migration. Clean the data first.
Adding an Index
Creating an index on a large table can lock it for minutes or hours, preventing writes. PostgreSQL and MySQL both support concurrent index creation that avoids this.
-- PostgreSQL: won't lock the table
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
Always use concurrent index creation in production. The migration takes longer but doesn't block operations.
The Rollback Question
Every migration should have a rollback path. Not every migration needs a coded rollback.
Additive migrations (add column, add table, add index) are easy to roll back — drop the column, table, or index.
Data migrations (backfill values, transform data) may not be reversible. If you transform data in a way that loses information, the rollback path is "restore from backup." This is why backups before migrations matter.
Destructive migrations (drop column, drop table) are irreversible. The data is gone. If you need it back, it comes from a backup.
The practical approach: write rollback scripts for structural changes. For data migrations, ensure you have a tested backup before running them. For destructive changes, verify three times that the data is truly not needed.
The Production Migration Checklist
Before running any migration in production:
Test on a copy of production data. Not on an empty test database — on a copy with realistic data volumes. Migrations that run instantly on 100 rows can take 30 minutes on 10 million.
Have a backup. Verified and tested. Not "we probably have one" — confirmed.
Run during low-traffic periods. Not because it should matter (safe migrations don't lock tables), but because you want headroom if something goes wrong.
Monitor the migration. Watch query latency, error rates, and lock wait times during execution.
Have a rollback plan. Know exactly what you'll do if the migration fails — and test the rollback path before you run the migration.
Communicate. Tell the team. If it's a significant change, tell stakeholders. "We're running a database migration at 6 AM; if anything looks wrong, here's who to contact."
Key Takeaway
Database migrations are safe when they follow patterns: add nullable columns, deploy code before removing columns, widen types rather than narrowing, create indexes concurrently, and always test on production-sized data. Every schema change goes through a migration file — no manual SQL. Have a backup before every production migration. Write rollback scripts for structural changes. And always deploy code that handles both the old and new schema before running destructive changes.


