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.