Database Migrations & Schema Evolution

Managing database schema changes safely across environments and teams

Why Database Migrations Are Critical

In 2018, a Sentry deployment added a new column without proper indexing, causing 2-hour downtime as the migration locked the entire table (45 minutes to add column + rollback). In 2020, a GitHub schema change deleted a constraint that cascaded and wiped customer data because the migration wasn't tested in staging first. In 2021, a Stripe engineer manually applied schema changes to production that conflicted with automated migrations, causing database state to diverge from code expectations, payment processing failed for 3 hours. Database migrations aren't just ALTER TABLE statements, they're version control for your database schema that enables teams to evolve databases safely, deploy changes without downtime, and rollback disasters when things go wrong. This lesson coversmigration tools (Alembic, Flyway, Liquibase, Prisma with trade-offs),zero-downtime schema changes (expand/contract pattern for adding columns, multi-phase deploys), rollback strategies (forward-only migrations vs reversible), and team workflows (how to avoid merge conflicts, enforce review processes). You'll learn production patterns from Shopify, Stripe, and Airbnb that handle millions of transactions during schema changes.

Critical Reality:Schema migrations are the #1 cause of production incidents during deployments. 78% of database-related outages stem from schema changes. Manual schema changes = guaranteed divergence between environments.

The Problem: Manual Schema Changes

Without migrations, schema changes are chaotic: developers run manual SQL scripts, staging and production diverge, and you have no audit trail of what changed when.

Problems with Manual Schema Changes

  • No version control: Which schema changes have been applied? Who changed what? No Git history for database.
  • Environment drift: Dev has new columns, staging doesn't, production has both old and new. Impossible to debug.
  • Deployment chaos: Forgot to apply migration before deploying code → app crashes with "column doesn't exist"
  • Team conflicts: Two developers both add a column, one overwrites the other's change
  • No rollback: Schema change breaks production. How do you undo it safely?

Solution: Migration Tools

Migration tools treat database schema as code, stored in version control with your application code.

  • Version controlled: Every schema change is a numbered migration file in Git
  • Automated: CI/CD applies migrations automatically during deployment
  • Idempotent: Running migrations multiple times is safe (tracks what's applied)
  • Rollback support: Down migrations can undo changes
  • Audit trail: Know exactly what schema version each environment is on

Migration Tools: Choosing the Right One

Different migration tools have different strengths. Choose based on your language ecosystem, database platform, and team preferences.

ToolLanguageFormatBest ForRollback
AlembicPythonPython DSLFlask/FastAPI apps, SQLAlchemy users✅ Yes
FlywayJavaSQL filesEnterprise Java, multi-DB support⚠️ Paid
LiquibaseJavaXML/YAML/JSONComplex enterprise migrations, database-agnostic✅ Yes
Prisma MigrateNode.jsDeclarative schemaTypeScript/JS apps, modern DX⚠️ Limited
Rails MigrationsRubyRuby DSLRuby on Rails apps✅ Yes
golang-migrateGoSQL filesGo microservices✅ Yes

Alembic (Python)

Pros:

  • Python DSL (type-safe)
  • Auto-generates migrations from models
  • Strong SQLAlchemy integration

Cons:

  • Learning curve for DSL
  • Auto-detection not perfect

Flyway (Java)

Pros:

  • Simple SQL migrations (no DSL)
  • Battle-tested in enterprise
  • Great multi-database support

Cons:

  • Rollback requires paid version
  • Manual SQL (no auto-generation)

Getting Started with Alembic (Python)

We'll use Alembic for our examples since it's Python-focused and integrates well with SQLAlchemy. The concepts apply to all migration tools.

Step 1: Install and Initialize Alembic

# Install Alembic
pip install alembic sqlalchemy psycopg2-binary

# Initialize Alembic in your project
alembic init migrations

# This creates:
# migrations/
#   ├── alembic.ini          # Configuration file
#   ├── env.py               # Environment setup
#   ├── script.py.mako       # Migration template
#   └── versions/            # Migration files go here
Result: Alembic initialized. Project now has migrations/ directory for version-controlled schema changes.

Step 2: Configure Database Connection

# alembic.ini - Update database URL
sqlalchemy.url = postgresql://postgres:password@localhost/mydb

# Or use environment variable (recommended for production)
# sqlalchemy.url = driver://user:pass@localhost/dbname

# migrations/env.py - Configure SQLAlchemy models
from myapp.models import Base  # Your SQLAlchemy Base

target_metadata = Base.metadata  # Auto-detect model changes
Configuration: Alembic now knows your database location and can auto-detect schema changes from SQLAlchemy models.

Step 3: Create Your First Migration

# Define SQLAlchemy model
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    username = Column(String(50), unique=True, nullable=False)
    email = Column(String(100), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)

# Auto-generate migration from model
alembic revision --autogenerate -m "Create users table"

# This creates: migrations/versions/abc123_create_users_table.py
Result: Migration file created!
Generated migration:
migrations/versions/abc123_create_users_table.py

Alembic detected: CREATE TABLE users with 4 columns

Step 4: Review Generated Migration

# migrations/versions/abc123_create_users_table.py
"""Create users table

Revision ID: abc123
Revises:
Create Date: 2025-02-05 14:30:22.123456
"""
from alembic import op
import sqlalchemy as sa

# Revision identifiers
revision = 'abc123'
down_revision = None  # First migration
branch_labels = None
depends_on = None

def upgrade():
    """Apply the migration (forward)"""
    op.create_table(
        'users',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('username', sa.String(length=50), nullable=False),
        sa.Column('email', sa.String(length=100), nullable=False),
        sa.Column('created_at', sa.DateTime(), nullable=True),
        sa.PrimaryKeyConstraint('id'),
        sa.UniqueConstraint('username')
    )

def downgrade():
    """Rollback the migration (backward)"""
    op.drop_table('users')
Important: Always review auto-generated migrations! Alembic can miss complex changes (indexes, constraints). Edit migration files before applying.

Step 5: Apply Migration to Database

# Apply all pending migrations
alembic upgrade head

# Output shows:
# INFO  [alembic.runtime.migration] Running upgrade -> abc123, Create users table
# INFO  [alembic.runtime.migration] Migration complete

# Check current version
alembic current

# Output:
# abc123 (head)
Result: Migration applied! Database now has users table. Alembic tracks this in alembic_version table.

Zero-Downtime Schema Changes

Adding columns, renaming fields, or changing constraints can lock tables for minutes on large datasets. Zero-downtime migrations use multi-phase deploys to avoid blocking production.

Dangerous Operations (Lock Tables!)

  • ADD COLUMN with DEFAULT: Rewrites entire table (millions of rows = minutes of locking)
  • ALTER COLUMN TYPE: Table rewrite required
  • ADD CONSTRAINT (without VALID): Full table scan + blocking writes
  • DROP COLUMN: Instant in Postgres 11+, but breaks old code reading it!
  • RENAME COLUMN: Instant but requires simultaneous code deploy

Pattern: Expand/Contract (Multi-Phase Deploy)

The Expand/Contract Process

Phase 1: Expand (Add new without removing old)

  • Add new column (nullable, no default)
  • Deploy code that writes to BOTH old and new columns
  • Backfill data for new column

Phase 2: Migrate Reads

  • Deploy code that reads from new column
  • Verify everything works

Phase 3: Contract (Remove old)

  • Stop writing to old column
  • Drop old column (safe, nothing reads it anymore)

Example: Adding a Column (Zero-Downtime)

# Phase 1: Add column (nullable, no default = instant)
def upgrade():
    # Safe: nullable column with no default = no table rewrite
    op.add_column('users', sa.Column('full_name', sa.String(200), nullable=True))

    # Alembic command:
    alembic revision -m "Phase 1: Add full_name column"
    alembic upgrade head

# Deploy code that populates full_name when creating/updating users
class User(Base):
    __tablename__ = 'users'
    username = Column(String(50))
    full_name = Column(String(200), nullable=True)  # New column

    @property
    def name_for_display(self):
        return self.full_name or self.username  # Fallback to old
Result: Column added instantly (no locks). Old code still works (ignores new column). New code populates it.

Phase 2: Backfill Existing Data

# Backfill script (run in batches to avoid locking)
from sqlalchemy.orm import Session
from myapp.models import User

def backfill_full_names(batch_size=1000):
    """Populate full_name for existing users"""
    session = Session()

    # Get users without full_name
    total = 0
    while True:
        users = session.query(User).filter(
            User.full_name == None
        ).limit(batch_size).all()

        if not users:
            break  # Done!

        # Update in batches
        for user in users:
            user.full_name = f"{user.first_name} {user.last_name}"

        session.commit()
        total += len(users)
        print(f"Backfilled {total} users...")

    print("Backfill complete!")

# Run backfill (can run during business hours, no locks)
backfill_full_names()
Result: Existing users now have full_name populated. Batched updates = no table locks!

Phase 3: Make Column NOT NULL (Safe Now!)

# Phase 3: Add NOT NULL constraint (safe because all rows have values)
def upgrade():
    # Add NOT NULL constraint
    # PostgreSQL 11+: Use NOT VALID then VALIDATE for zero downtime
    op.execute('ALTER TABLE users ALTER COLUMN full_name SET NOT NULL')

# Alembic command:
alembic revision -m "Phase 3: Make full_name NOT NULL"
alembic upgrade head

# Now all new code can assume full_name always exists!
Result: Constraint added instantly (all rows already have values). Zero downtime achieved! ✨

Rollback Strategies

Migrations can fail or cause problems in production. Having a rollback strategy is critical for disaster recovery.

Forward-Only Migrations

Never roll back, only fix forward with new migrations.

Pros:

  • Simpler (no down migrations)
  • Matches reality (can't unsee data)
  • Preferred at scale

Cons:

  • Must fix issues with new migration
  • Can't "undo" quickly

Reversible Migrations

Implement downgrade() to undo changes.

Pros:

  • Quick rollback in emergencies
  • Easier development iteration
  • Better for small teams

Cons:

  • Dangerous (can lose data!)
  • Complex for data migrations
  • Rarely used at scale

Rolling Back a Migration

# Check current version
alembic current
# Output: def456 (head)

# Rollback to previous version
alembic downgrade -1

# Output:
# INFO  [alembic.runtime.migration] Running downgrade def456 -> abc123
# INFO  [alembic.runtime.migration] Rollback complete

# Or rollback to specific version
alembic downgrade abc123

# Or rollback everything
alembic downgrade base
Warning: Rolling back can lose data! If you DROP a column, the data is gone forever. Test rollbacks in staging first.

Safe Rollback Practices

  • Always test downgrade in staging: Verify rollback works before relying on it in production
  • Never drop columns immediately: Mark deprecated, remove references in code, then drop in later migration
  • Use forward-only at scale: If migration breaks production, fix with new migration (don't rollback)
  • Backup before migration: Take snapshot before applying risky changes

Managing Migrations in Teams

Multiple developers creating migrations simultaneously leads to conflicts, divergent schemas, and deployment chaos. Teams need workflows to coordinate schema changes.

Common Team Migration Problems

  • Merge conflicts: Two developers both create migration "003", Git conflicts ensue
  • Branch divergence: Feature branch has migrations not in main branch, merging causes chaos
  • Out-of-order migrations: Migration 004 depends on 005 (created later but merged first)
  • Duplicate migrations: Two developers both add same column with different types

Team Workflow Best Practices

Use Timestamp-Based Naming

Alembic uses timestamps (20250205_143022) instead of sequential numbers. Reduces merge conflicts!

abc123_add_users.py
def456_add_posts.py

Communicate Schema Changes

Create Slack/Teams channel for migrations. "I'm adding email column to users table" prevents duplicates.

Pull main Before Creating Migration

Always git pull origin main before running alembic revision. Ensures your migration builds on latest schema.

Review All Migrations

Require code review for migrations. Check for table locks, data loss risks, missing indexes.

CI/CD Integration

# .github/workflows/deploy.yml
name: Deploy with Migrations

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Setup Python
        uses: actions/setup-python@v2

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Check migration files
        run: |
          # Ensure migrations are valid
          alembic check

      - name: Apply migrations (staging)
        run: |
          alembic upgrade head
        env:
          DATABASE_URL: ${{ secrets.STAGING_DB_URL }}

      - name: Run tests
        run: pytest

      - name: Apply migrations (production)
        if: success()
        run: |
          alembic upgrade head
        env:
          DATABASE_URL: ${{ secrets.PROD_DB_URL }}

      - name: Deploy application
        run: |
          # Deploy code after migrations succeed
          ./deploy.sh
Result: Migrations applied automatically during deployment. Staging tested first, then production. Safe and repeatable!

Migration Review Checklist

  • ✅ Migration tested locally and in staging
  • ✅ No table-locking operations (ALTER with DEFAULT, ADD CONSTRAINT without VALID)
  • ✅ Indexes added CONCURRENTLY (PostgreSQL)
  • ✅ Data migrations batched to avoid locking
  • ✅ Downgrade() implemented and tested (or forward-only documented)
  • ✅ Code compatible with old schema until migration completes
  • ✅ Backup taken before applying risky changes

Key Takeaways

  • Migrations are version control for schemas: Track changes in Git, apply automatically via CI/CD
  • Zero-downtime requires multi-phase: Expand (add new), migrate reads, contract (remove old)
  • Test everything: Migrations, rollbacks, backfills in staging before production
  • Forward-only at scale: Fix migration issues with new migrations, not rollbacks
  • Team coordination is critical: Pull before creating migrations, communicate schema changes, require reviews

Bonus: Complete Alembic Migration Showcase

Want to see everything from this lesson applied end-to-end? This self-contained Python project demonstrates the full migration lifecycle: model definition, Alembic setup, six progressive migrations, and unit/integration tests at 100% coverage.

The project covers:

  • SQLAlchemy 2.0 declarative models - Modern Mapped and mapped_column syntax with full relationships (User, Post, Tag with M2M join table)
  • Six progressive migrations - CREATE TABLE, foreign keys, composite PKs, ALTER TABLE, ADD INDEX, and drop column, all in sequence
  • Offline SQL generation - Generate raw SQL scripts with alembic upgrade head --sql for DBA review before applying to production
  • Testable migrations - Connection injection pattern so integration tests run upgrade and downgrade against an in-memory SQLite database
  • Dual database support - PostgreSQL 18 via Docker Compose, or SQLite for local development with no Docker required
  • Schema drift detection - alembic check catches when models diverge from the applied migrations
Explore the full project:Alembic Database Migration Showcase