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)

ToolLanguageApproachRollback SupportBest For
FlywayJava / CLIVersioned SQL files (V1__name.sql)Paid Undo featureJava apps, SQL-first teams
LiquibaseJava / CLIXML / YAML / SQL changelogsAuto rollbackMulti-DB, complex change sets
golang-migrateGo / CLINumbered .up.sql / .down.sqlHand-written down filesGo apps, simple SQL migrations
AlembicPythonPython scripts with upgrade / downgradedowngrade functionSQLAlchemy / Python projects
Prisma MigrateTypeScriptSchema-diff based, auto-generatedManual down scriptsNode.js / TypeScript apps
AtlasGo / CLIDeclarative (desired state) or versionedAuto-generated reverseModern Go apps, CI/CD
Knex.jsJavaScriptJS/TS migration functions (up / down)Hand-written down functionNode.js / Express projects
TypeORMTypeScriptAuto-detects entity changes, generates migrationsAuto-generated downTypeScript full-stack apps
SequelizeJavaScriptJS migration files (up / down)Hand-written down functionNode.js legacy projects
Django MigrationsPythonAuto-detects Model changes, generates migrationsAuto-generated reverseDjango web apps
Rails ActiveRecordRubyDSL migration files (change / up / down)Auto-reversible or manual downRuby on Rails projects
dbmateGo / CLIPlain SQL .up.sql / .down.sqlHand-written down filesLightweight, language-agnostic

Migration File Structure

golang-migrate (numbered up/down)

db/migrations/ โ”œโ”€โ”€ 000001_create_users.up.sql โ”œโ”€โ”€ 000001_create_users.down.sql โ”œโ”€โ”€ 000002_add_email_index.up.sql โ”œโ”€โ”€ 000002_add_email_index.down.sql โ”œโ”€โ”€ 000003_add_orders_table.up.sql โ””โ”€โ”€ 000003_add_orders_table.down.sql

Flyway (V__ / R__ / U__)

sql/ โ”œโ”€โ”€ V001__create_users.sql -- versioned (runs once, in order) โ”œโ”€โ”€ V002__add_orders_table.sql โ”œโ”€โ”€ R__refresh_materialized_view.sql -- repeatable (re-runs when content changes) โ””โ”€โ”€ U001__undo_create_users.sql -- undo (paid feature)

Prisma (auto-generated)

prisma/ โ”œโ”€โ”€ schema.prisma -- declarative schema โ””โ”€โ”€ migrations/ โ”œโ”€โ”€ 20260101120000_init/ โ”‚ โ””โ”€โ”€ migration.sql โ”œโ”€โ”€ 20260115090000_add_orders/ โ”‚ โ””โ”€โ”€ migration.sql โ””โ”€โ”€ migration_lock.toml

Django (auto-generated)

myapp/migrations/ โ”œโ”€โ”€ __init__.py โ”œโ”€โ”€ 0001_initial.py -- auto-detected from models.py โ”œโ”€โ”€ 0002_add_email_field.py โ””โ”€โ”€ 0003_create_order_model.py # Generate migration: python manage.py makemigrations # Run migrations: python manage.py migrate # Show status: python manage.py showmigrations

Common Migration Operations (PostgreSQL & MySQL)

Create Table

-- PostgreSQL CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id), total NUMERIC(12,2) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- MySQL CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, total DECIMAL(12,2) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Add Column (with default โ€” zero-downtime safe)

-- PostgreSQL (PG 11+ adds columns with defaults without table rewrite) ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT ''; -- MySQL (8.0+ INSTANT algorithm, near-zero lock time) ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT '', ALGORITHM=INSTANT;

Remove Column (expand-contract)

-- Step 1: Deploy new code that no longer reads old_column -- Step 2: Confirm no active queries reference old_column -- Step 3: -- PostgreSQL ALTER TABLE users DROP COLUMN old_column; -- MySQL ALTER TABLE users DROP COLUMN old_column;

Rename Column (expand-contract)

-- NEVER do a direct rename (breaks live app immediately): -- ALTER TABLE users RENAME COLUMN username TO display_name; -- Correct approach: add new โ†’ backfill โ†’ dual-write โ†’ switch reads โ†’ drop old -- Step 1 (Expand) ALTER TABLE users ADD COLUMN display_name VARCHAR(100); -- Step 2 (Backfill) UPDATE users SET display_name = username WHERE display_name IS NULL; -- Steps 3-4: Deploy code that dual-writes, then switch reads to display_name -- Step 5 (Contract) ALTER TABLE users DROP COLUMN username;

Add Index (without locking)

-- PostgreSQL โ€” CONCURRENTLY does not block writes CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id); -- MySQL โ€” INPLACE algorithm (8.0+), allows concurrent DML ALTER TABLE orders ADD INDEX idx_orders_user_id (user_id), ALGORITHM=INPLACE, LOCK=NONE;

Change Column Type

-- PostgreSQL (small tables can alter directly; large tables: add new column + backfill) ALTER TABLE products ALTER COLUMN price TYPE NUMERIC(14,2); -- MySQL (large tables: use pt-online-schema-change) -- Direct approach (locks table! small tables only): ALTER TABLE products MODIFY COLUMN price DECIMAL(14,2);

Add Foreign Key

-- PostgreSQL โ€” NOT VALID creates constraint without checking existing rows, then validate async ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user; -- MySQL ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id);

Zero-Downtime Migration Strategies

Expand-Contract Pattern

The most universal zero-downtime approach, executed in 5 phases:

PhaseActionNotes
1. ExpandAdd new column / tableAdditive only โ€” no impact on existing code
2. BackfillMigrate old data to new structureProcess in batches to avoid long locks
3. Dual-writeDeploy code writing to both old and newEnsures data consistency during transition
4. Switch readsDeploy code reading from new structureOld structure becomes a fallback
5. ContractRemove old column / tableOnly 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

ToolDatabaseMechanism
pt-online-schema-changeMySQLCreates shadow table โ†’ installs triggers โ†’ copies data โ†’ atomic rename
gh-ostMySQLReplicates changes via binlog, no triggers needed โ€” safer approach
pg_repackPostgreSQLRebuilds tables and indexes online, reclaims bloat
pgrollPostgreSQLMulti-version schema coexistence, progressive migrations

Shadow Table Approach

-- 1. Create shadow table matching original (with new schema changes) CREATE TABLE users_new (LIKE users INCLUDING ALL); ALTER TABLE users_new ADD COLUMN display_name VARCHAR(100); -- 2. Install triggers to sync incremental changes -- 3. Batch-copy existing data to shadow table -- 4. Atomic swap ALTER TABLE users RENAME TO users_old; ALTER TABLE users_new RENAME TO users; -- 5. Drop old table DROP TABLE users_old;

Migration Checklist

PhaseActionDetails
BeforeTake a full database backuppg_dump / mysqldump / cloud snapshot
BeforeTest on staging with production data copyEnsures migration script works on real data
BeforeEstimate lock duration for DDL changesUse pg_stat_activity to check active locks on large tables
BeforePrepare and test rollback scriptRun down migration on staging first
BeforeNotify team of migration windowCoordinate release freeze period
DuringMonitor active connections and replication lagPause if lag exceeds threshold
DuringSet statement_timeout / lock_timeoutPrevent indefinite lock waits from cascading
DuringManage connection poolsTemporarily increase pool size or pause non-critical services
AfterVerify row counts and data integrityCompare COUNT / CHECKSUM before and after
AfterMonitor slow queries and index usageCheck pg_stat_user_indexes or EXPLAIN plans
AfterRun performance benchmarksCompare core query response times to baseline
CleanupRemove deprecated columns after old code is goneWait 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

# Install go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest # Create migration migrate create -ext sql -dir db/migrations -seq create_users # Run all pending migrations migrate -path db/migrations -database "postgres://user:pass@localhost/mydb?sslmode=disable" up # Rollback one step migrate -path db/migrations -database "postgres://..." down 1 # Check current version migrate -path db/migrations -database "postgres://..." version # Force version (fix dirty state) migrate -path db/migrations -database "postgres://..." force 3

Flyway

# Install (macOS) brew install flyway # Configuration (flyway.conf) flyway.url=jdbc:postgresql://localhost:5432/mydb flyway.user=postgres flyway.password=secret flyway.locations=filesystem:./sql # Run migrations flyway migrate # Show migration status flyway info # Validate applied migrations match local files flyway validate # Clean database (DANGEROUS โ€” dev only) flyway clean # Baseline existing database for Flyway adoption flyway baseline

Prisma Migrate

# Install npm install prisma --save-dev # After editing prisma/schema.prisma models: # Generate migration (development) npx prisma migrate dev --name add_orders_table # Deploy migrations (production) npx prisma migrate deploy # Check migration status npx prisma migrate status # Reset database (DANGEROUS โ€” destroys all data) npx prisma migrate reset # Generate Prisma Client npx prisma generate

Alembic

# Install pip install alembic # Initialize alembic init alembic # Auto-generate migration (based on SQLAlchemy model diff) alembic revision --autogenerate -m "add orders table" # Upgrade to latest alembic upgrade head # Rollback one step alembic downgrade -1 # Check current version alembic current # View migration history alembic history --verbose

Frequently Asked Questions

Q: How do I handle a failed migration?

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).

Q: Should I use auto-generated or hand-written migrations?

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.

Q: How do I handle data migrations (not just schema)?

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.

Q: How should I handle migrations in CI/CD?

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.

Q: When should I use declarative vs versioned migrations?

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.