Why Database Migrations Matter

Database Migration Tools and Strategies

Database migrations are how you version-control your schema changes. Without a migration system, schema changes are applied manually, untracked, and unrepeatable. Migrations ensure that every environment (dev, staging, production) has the same schema, changes are auditable, and rollbacks are possible.

Migration Tools by Language

| Language | Tool | Pros | |----------|------|------| | Python | Alembic (SQLAlchemy) | Auto-generation, async support | | Node.js | Knex.js | Transactional migrations, seed support | | Ruby | ActiveRecord Migrations | Simple DSL, mature ecosystem | | Java | Flyway, Liquibase | Repeatable migrations, CI/CD friendly | | Go | golang-migrate, Goose | No-dependency binaries | | Rust | Diesel | Type-safe, compile-time checking |

Alembic (Python)

Setup

pip install alembic

alembic init alembic

alembic/env.py

from myapp.models import Base

target_metadata = Base.metadata

Creating Migrations

Auto-generate migration

alembic revision --autogenerate -m "add user roles table"

Apply migrations

alembic upgrade head

Rollback

alembic downgrade -1

Migration File

"""add user roles table

Revision ID: abc123

Revises: def456

"""

from alembic import op

import sqlalchemy as sa

def upgrade():

op.create_table(

'user_roles',

sa.Column('id', sa.Integer(), nullable=False),

sa.Column('user_id', sa.Integer(), nullable=False),

sa.Column('role', sa.String(length=50), nullable=False),

sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),

sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),

sa.PrimaryKeyConstraint('id')

)

op.create_index('ix_user_roles_user_id', 'user_roles', ['user_id'])

def downgrade():

op.drop_index('ix_user_roles_user_id')

op.drop_table('user_roles')

Flyway (Java)

Migration naming convention:

V1__create_users.sql

V2__add_email_column.sql

V3__create_orders_table.sql

Apply migrations

flyway migrate

Check status

flyway info

Repair checksums

flyway repair

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\-- V2__add_email_column.sql

ALTER TABLE users ADD COLUMN email VARCHAR(255) UNIQUE;

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\-- V3__create_orders_table.sql

CREATE TABLE orders (

id BIGSERIAL PRIMARY KEY,

user_id BIGINT NOT NULL REFERENCES users(id),

total DECIMAL(10,2) NOT NULL,

status VARCHAR(20) NOT NULL DEFAULT 'pending',

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()

);

CREATE INDEX idx_orders_user_id ON orders(user_id);

Strategies for Complex Migrations

Expand-Contract Pattern (Zero Downtime)

For making backward-incompatible changes without downtime:

Phase 1: Expand

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\-- Add new column alongside old one

ALTER TABLE users ADD COLUMN email_new VARCHAR(255);

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\-- Start writing to both columns

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\-- Backfill existing rows

UPDATE users SET email_new = email WHERE email_new IS NULL;

Phase 2: Migrate

Application reads from new column, still writes to both

Deploy application update

Phase 3: Contract

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\-- Stop writing to old column

ALTER TABLE users DROP COLUMN email;

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\-- Or rename

ALTER TABLE users RENAME COLUMN email TO email_old;

Batch Migrations for Large Tables

"""migrate large table in batches"""

from sqlalchemy import text

def upgrade():

batch_size = 10000

offset = 0

while True:

result = op.execute(

text("""

UPDATE users

SET email_lower = LOWER(email)

WHERE id IN (

SELECT id FROM users

WHERE email_lower IS NULL

ORDER BY id

LIMIT :batch_size

)

RETURNING id

"""),

{'batch_size': batch_size}

)

if result.rowcount == 0:

break

offset += batch_size

print(f"Updated {offset} rows...")

Online Schema Change (pt-online-schema-change)

For MySQL without downtime

pt-online-schema-change \

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\--alter "ADD COLUMN email VARCHAR(255)" \

D=database,t=users \

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\--execute

Migration Best Practices

Always Have a Rollback

Every migration must have a tested downgrade path:

def upgrade():

op.add_column('users', sa.Column('email', sa.String(255)))

def downgrade():

op.drop_column('users', 'email')

Test Migrations

Test upgrade and downgrade

alembic upgrade head

alembic downgrade base

alembic upgrade head

CI/CD Integration

.github/workflows/migrate.yml

jobs:

migrate:

runs-on: ubuntu-latest

steps:

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\- uses: actions/checkout@v4

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\- name: Run migrations

env:

DATABASE_URL: ${{ secrets.DATABASE_URL }}

run: |

alembic upgrade head

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\- name: Verify schema

run: |

alembic check # Detect schema drift

Common Mistakes

| Mistake | Consequence | Prevention | |---------|-------------|------------| | No rollback plan | Cannot undo failed migration | Always write downgrade | | Long-running locks | Table locked, queries fail | Use batch/online migrations | | Missing default values | NULL violations in existing rows | Add with default or nullable first | | Schema drift | Environments out of sync | Automate migrations, check in CI | | No data migration | New columns are empty | Backfill after schema change |

Summary

Database migrations bring the same version control discipline to schema changes that Git brings to code. Use tools like Alembic or Flyway, always write reversible migrations with upgrade and downgrade paths, adopt the expand-contract pattern for zero-downtime changes, batch large table modifications, and integrate migrations into your CI/CD pipeline. Test every migration against a copy of production data before deploying to production.