Database Migration Tools and Strategies


Why Database Migrations Matter

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.