DB Migration Guide
Why Database Migrations Matter
Database migrations are among the highest-risk operations in any application lifecycle. Every schema change โ adding a column, creating an index, modifying a constraint โ can affect data integrity, query performance, or even bring down your service entirely. Without versioned migration management, teams quickly fall into the "works on my machine" trap, and production schemas drift away from development environments. This guide covers 12 popular migration tools, zero-downtime strategies, PostgreSQL and MySQL syntax examples, comprehensive checklists, and common pitfalls to help you manage database changes safely and reliably.
Migration Tools Comparison (12 Tools)
| Tool | Language | Approach | Rollback Support | Best For |
|---|---|---|---|---|
| Flyway | Java / CLI | Versioned SQL files (V1__name.sql) | Paid Undo feature | Java apps, SQL-first teams |
| Liquibase | Java / CLI | XML / YAML / SQL changelogs | Auto rollback | Multi-DB, complex change sets |
| golang-migrate | Go / CLI | Numbered .up.sql / .down.sql | Hand-written down files | Go apps, simple SQL migrations |
| Alembic | Python | Python scripts with upgrade / downgrade | downgrade function | SQLAlchemy / Python projects |
| Prisma Migrate | TypeScript | Schema-diff based, auto-generated | Manual down scripts | Node.js / TypeScript apps |
| Atlas | Go / CLI | Declarative (desired state) or versioned | Auto-generated reverse | Modern Go apps, CI/CD |
| Knex.js | JavaScript | JS/TS migration functions (up / down) | Hand-written down function | Node.js / Express projects |
| TypeORM | TypeScript | Auto-detects entity changes, generates migrations | Auto-generated down | TypeScript full-stack apps |
| Sequelize | JavaScript | JS migration files (up / down) | Hand-written down function | Node.js legacy projects |
| Django Migrations | Python | Auto-detects Model changes, generates migrations | Auto-generated reverse | Django web apps |
| Rails ActiveRecord | Ruby | DSL migration files (change / up / down) | Auto-reversible or manual down | Ruby on Rails projects |
| dbmate | Go / CLI | Plain SQL .up.sql / .down.sql | Hand-written down files | Lightweight, language-agnostic |
Migration File Structure
golang-migrate (numbered up/down)
Flyway (V__ / R__ / U__)
Prisma (auto-generated)
Django (auto-generated)
Common Migration Operations (PostgreSQL & MySQL)
Create Table
Add Column (with default โ zero-downtime safe)
Remove Column (expand-contract)
Rename Column (expand-contract)
Add Index (without locking)
Change Column Type
Add Foreign Key
Zero-Downtime Migration Strategies
Expand-Contract Pattern
The most universal zero-downtime approach, executed in 5 phases:
| Phase | Action | Notes |
|---|---|---|
| 1. Expand | Add new column / table | Additive only โ no impact on existing code |
| 2. Backfill | Migrate old data to new structure | Process in batches to avoid long locks |
| 3. Dual-write | Deploy code writing to both old and new | Ensures data consistency during transition |
| 4. Switch reads | Deploy code reading from new structure | Old structure becomes a fallback |
| 5. Contract | Remove old column / table | Only after confirming no rollback is needed |
Blue-Green Database Deployments
Maintain two database instances (Blue and Green). Run migrations on Green, verify correctness, then switch traffic. Suited for major version upgrades but requires double storage and a data synchronization mechanism such as logical replication.
Online DDL Tools
| Tool | Database | Mechanism |
|---|---|---|
| pt-online-schema-change | MySQL | Creates shadow table โ installs triggers โ copies data โ atomic rename |
| gh-ost | MySQL | Replicates changes via binlog, no triggers needed โ safer approach |
| pg_repack | PostgreSQL | Rebuilds tables and indexes online, reclaims bloat |
| pgroll | PostgreSQL | Multi-version schema coexistence, progressive migrations |
Shadow Table Approach
Migration Checklist
| Phase | Action | Details |
|---|---|---|
| Before | Take a full database backup | pg_dump / mysqldump / cloud snapshot |
| Before | Test on staging with production data copy | Ensures migration script works on real data |
| Before | Estimate lock duration for DDL changes | Use pg_stat_activity to check active locks on large tables |
| Before | Prepare and test rollback script | Run down migration on staging first |
| Before | Notify team of migration window | Coordinate release freeze period |
| During | Monitor active connections and replication lag | Pause if lag exceeds threshold |
| During | Set statement_timeout / lock_timeout | Prevent indefinite lock waits from cascading |
| During | Manage connection pools | Temporarily increase pool size or pause non-critical services |
| After | Verify row counts and data integrity | Compare COUNT / CHECKSUM before and after |
| After | Monitor slow queries and index usage | Check pg_stat_user_indexes or EXPLAIN plans |
| After | Run performance benchmarks | Compare core query response times to baseline |
| Cleanup | Remove deprecated columns after old code is gone | Wait at least 1-2 release cycles |
Common Pitfalls
- Running untested migrations in production โ Always test on staging with a production data copy first. A single syntax error can leave your migration in a half-applied dirty state.
- No rollback scripts โ Every up migration should have a corresponding down. Without rollback scripts, recovery means manual fixes under pressure.
- Long-running locks on busy tables โ ALTER TABLE on large tables can hold locks for minutes or hours. Use CONCURRENTLY, pt-online-schema-change, or gh-ost to avoid blocking.
- Irreversible data loss โ DROP COLUMN is permanent. Ensure data has been migrated to new columns and backups exist before dropping anything.
- Adding NOT NULL columns without defaults โ Existing rows cannot satisfy the constraint. Always ADD COLUMN with DEFAULT first, then alter as needed.
- Forgetting to update ORM models โ Migration changes the database schema, but if entity classes or models are not updated, you get runtime errors.
- Migration ordering conflicts โ Parallel development branches create conflicting migration numbers or dependency ordering. Use timestamp-based naming instead of sequential numbers to reduce conflicts.
Tool-Specific Quickstart
golang-migrate
Flyway
Prisma Migrate
Alembic
Frequently Asked Questions
A: First, check the actual database state (was it partially applied?). If you use a database with transactional DDL (PostgreSQL), failed migrations roll back automatically. MySQL DDL is not transactional, so you may need manual fixes. Most tools mark a "dirty" state โ after fixing the issue, reset the version number (e.g., migrate force N).
A: It depends on your team and use case. Auto-generation (Prisma, Django, Alembic autogenerate) is great for rapid iteration but may miss data migration logic. Hand-written SQL gives precise control for performance-sensitive operations (indexes, large table changes). Recommended: auto-generate, then review and edit before committing.
A: Keep data migrations separate from schema migrations. First, apply the schema change (add new column), then use a dedicated script or migration step to backfill data. For large datasets, process in batches (1,000-10,000 rows each) to avoid long transactions and lock contention.
A: Migrations should run before your application starts in the deployment pipeline. Recommended flow: in CI, validate against a temporary database (up โ down โ up); in CD, run migrate up first, then rolling-update application containers. In Kubernetes, use an init container to execute migrations before the app container starts.
A: Declarative tools (Atlas, Prisma) define the desired end state and auto-compute the diff โ ideal for new projects and rapid prototyping. Versioned tools (Flyway, golang-migrate) give explicit control over every change โ better for production systems requiring audit trails and precise control. Many teams use declarative during development and export to versioned migrations for production deployment.