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.
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.
| Tool | Language | Format | Best For | Rollback |
|---|---|---|---|---|
| Alembic | Python | Python DSL | Flask/FastAPI apps, SQLAlchemy users | ✅ Yes |
| Flyway | Java | SQL files | Enterprise Java, multi-DB support | ⚠️ Paid |
| Liquibase | Java | XML/YAML/JSON | Complex enterprise migrations, database-agnostic | ✅ Yes |
| Prisma Migrate | Node.js | Declarative schema | TypeScript/JS apps, modern DX | ⚠️ Limited |
| Rails Migrations | Ruby | Ruby DSL | Ruby on Rails apps | ✅ Yes |
| golang-migrate | Go | SQL files | Go 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
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
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
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')
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)
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
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()
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!
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
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.pyCommunicate 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
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
Mappedandmapped_columnsyntax 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 --sqlfor 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 checkcatches when models diverge from the applied migrations