Skip to content

Database Migrations & Schema Evolution

Objective: Databases are living systems. Schema changes are inevitable. Handle them without breaking prod, corrupting data, or waking ops in the night.

Databases are living systems. Schema changes are inevitable. Handle them without breaking prod, corrupting data, or waking ops in the night.

0) Prerequisites (Read Once, Live by Them)

The Five Commandments

  1. Schema evolution is continuous, not one-off
  2. Plan for ongoing changes
  3. Design for evolution from day one
  4. Never assume "this is the final schema"
  5. Embrace change as a first-class concern

  6. Always test migrations in staging with prod-like data

  7. Use production data snapshots (sanitized)
  8. Test with realistic data volumes
  9. Validate performance impact
  10. Never test migrations in production

  11. Favor additive changes over destructive ones

  12. Backward-compatible migrations
  13. Add before you remove
  14. Use feature flags for schema changes
  15. Plan rollback strategies

  16. Write migrations as code, versioned in Git

  17. No manual schema changes in production
  18. Version control everything
  19. Review migrations like code
  20. Track migration history

  21. Rollbacks must be possible, but roll-forwards are usually safer

  22. Design for forward migration
  23. Plan rollback strategies
  24. Test both directions
  25. Document rollback procedures

Why These Principles: Database migrations require understanding schema evolution, data safety, and production operations. Understanding these patterns prevents data chaos and enables reliable database management.

1) Core Principles

The Migration Reality

# What you thought migrations were
migration_fantasy:
  "simplicity": "Just run ALTER TABLE and you're done"
  "speed": "Schema changes are instant"
  "safety": "Nothing can go wrong"
  "rollback": "Just run the reverse SQL"

# What migrations actually are
migration_reality:
  "simplicity": "Complex orchestration of multiple steps"
  "speed": "Can take hours on large tables"
  "safety": "One wrong move and you're toast"
  "rollback": "Often impossible or extremely expensive"

Why Reality Checks Matter: Understanding the true nature of database migrations enables proper planning and risk management. Understanding these patterns prevents data chaos and enables reliable database management.

Migration Lifecycle

## Migration Lifecycle

### Planning Phase
- [ ] Analyze current schema and data
- [ ] Design migration strategy
- [ ] Plan rollback strategy
- [ ] Estimate downtime and performance impact

### Development Phase
- [ ] Write migration scripts
- [ ] Test on development data
- [ ] Validate with staging data
- [ ] Review migration code

### Deployment Phase
- [ ] Backup production database
- [ ] Run migration in maintenance window
- [ ] Validate migration results
- [ ] Monitor system performance

### Post-Deployment Phase
- [ ] Verify application functionality
- [ ] Monitor for issues
- [ ] Clean up old schema elements
- [ ] Document lessons learned

Why Lifecycle Management Matters: Proper migration lifecycle enables safe schema evolution and reduces production risks. Understanding these patterns prevents data chaos and enables reliable database management.

2) Tooling

Alembic (Python/SQLAlchemy)

# alembic/env.py
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys

# Add your model's MetaData object here
from myapp.models import Base
target_metadata = Base.metadata

# Alembic Config object
config = context.config

# Interpret the config file for Python logging
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

def run_migrations_offline():
    """Run migrations in 'offline' mode."""
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()

def run_migrations_online():
    """Run migrations in 'online' mode."""
    connectable = engine_from_config(
        config.get_section(config.config_ini_section),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection, target_metadata=target_metadata
        )

        with context.begin_transaction():
            context.run_migrations()

if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

Why Alembic Matters: Alembic provides version-controlled database migrations with rollback support and dependency management. Understanding these patterns prevents migration chaos and enables reliable database management.

Alembic Configuration

# alembic.ini
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql://user:pass@localhost/mydb

[post_write_hooks]
hooks = black
black.type = console_scripts
black.entrypoint = black
black.options = -l 79 REVISION_SCRIPT_FILENAME

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

Why Alembic Configuration Matters: Proper Alembic configuration enables consistent migration management and logging. Understanding these patterns prevents migration chaos and enables reliable database management.

Flyway (Language-Agnostic)

-- V1__Create_users_table.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);

-- V2__Add_user_profiles.sql
CREATE TABLE user_profiles (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    first_name VARCHAR(50),
    last_name VARCHAR(50),
    bio TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_user_profiles_user_id ON user_profiles(user_id);

-- V3__Add_geospatial_support.sql
-- Enable PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;

-- Add geometry column to user_profiles
ALTER TABLE user_profiles 
ADD COLUMN location GEOMETRY(POINT, 4326);

-- Create spatial index
CREATE INDEX idx_user_profiles_location ON user_profiles 
USING GIST (location);

-- V4__Add_telemetry_table.sql
CREATE TABLE telemetry (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    sensor_id VARCHAR(50) NOT NULL,
    timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
    temperature DOUBLE PRECISION,
    humidity DOUBLE PRECISION,
    pressure DOUBLE PRECISION,
    location GEOMETRY(POINT, 4326),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Create time-series optimized indexes
CREATE INDEX idx_telemetry_timestamp ON telemetry (timestamp DESC);
CREATE INDEX idx_telemetry_user_timestamp ON telemetry (user_id, timestamp DESC);
CREATE INDEX idx_telemetry_location ON telemetry USING GIST (location);

-- Partition by time for better performance
CREATE TABLE telemetry_2024_01 PARTITION OF telemetry
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

Why Flyway Matters: Flyway provides simple, version-controlled database migrations with rollback support. Understanding these patterns prevents migration chaos and enables reliable database management.

Sqitch (Dependency-Aware)

-- sqitch.plan
%syntax-version=1.0.0
%project=myapp
%uri=https://github.com/myorg/myapp

users 2024-01-15T10:00:00Z "Create users table" [main]
user_profiles 2024-01-15T10:30:00Z "Add user profiles" [users]
geospatial 2024-01-15T11:00:00Z "Add geospatial support" [user_profiles]
telemetry 2024-01-15T11:30:00Z "Add telemetry table" [users geospatial]

-- deploy/users.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);

-- revert/users.sql
DROP TABLE users CASCADE;

-- verify/users.sql
SELECT 1 FROM users LIMIT 1;

Why Sqitch Matters: Sqitch provides dependency-aware migrations with explicit rollback and verification steps. Understanding these patterns prevents migration chaos and enables reliable database management.

3) Zero-Downtime Strategies

Additive Changes (Safe)

# Migration: Add new column without breaking existing code
"""Add user preferences table

Revision ID: 001_add_user_preferences
Revises: 
Create Date: 2024-01-15 10:00:00.000000

"""
from alembic import op
import sqlalchemy as sa

revision = '001_add_user_preferences'
down_revision = None
branch_labels = None
depends_on = None

def upgrade():
    # Step 1: Add new table
    op.create_table('user_preferences',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('user_id', sa.Integer(), nullable=False),
        sa.Column('theme', sa.String(50), nullable=True, default='light'),
        sa.Column('notifications', sa.Boolean(), nullable=True, default=True),
        sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
        sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
        sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
        sa.PrimaryKeyConstraint('id')
    )

    # Step 2: Create indexes
    op.create_index('idx_user_preferences_user_id', 'user_preferences', ['user_id'])

    # Step 3: Add default preferences for existing users
    op.execute("""
        INSERT INTO user_preferences (user_id, theme, notifications, created_at, updated_at)
        SELECT id, 'light', true, NOW(), NOW()
        FROM users
        WHERE NOT EXISTS (
            SELECT 1 FROM user_preferences WHERE user_id = users.id
        )
    """)

def downgrade():
    # Step 1: Drop indexes
    op.drop_index('idx_user_preferences_user_id', table_name='user_preferences')

    # Step 2: Drop table
    op.drop_table('user_preferences')

Why Additive Changes Matter: Additive changes enable safe schema evolution without breaking existing functionality. Understanding these patterns prevents production chaos and enables reliable database management.

Column Rename Strategy (Zero-Downtime)

# Migration: Rename column with zero downtime
"""Rename username to user_handle

Revision ID: 002_rename_username_to_user_handle
Revises: 001_add_user_preferences
Create Date: 2024-01-15 11:00:00.000000

"""
from alembic import op
import sqlalchemy as sa

revision = '002_rename_username_to_user_handle'
down_revision = '001_add_user_preferences'
branch_labels = None
depends_on = None

def upgrade():
    # Step 1: Add new column
    op.add_column('users', sa.Column('user_handle', sa.String(50), nullable=True))

    # Step 2: Copy data from old column to new column
    op.execute("UPDATE users SET user_handle = username WHERE user_handle IS NULL")

    # Step 3: Make new column NOT NULL (after data is copied)
    op.alter_column('users', 'user_handle', nullable=False)

    # Step 4: Create unique index on new column
    op.create_index('idx_users_user_handle', 'users', ['user_handle'], unique=True)

    # Note: Don't drop old column yet - wait for application code to be updated

def downgrade():
    # Step 1: Drop new column index
    op.drop_index('idx_users_user_handle', table_name='users')

    # Step 2: Drop new column
    op.drop_column('users', 'user_handle')

Why Column Rename Strategy Matters: Zero-downtime column renames enable safe schema evolution without breaking existing applications. Understanding these patterns prevents production chaos and enables reliable database management.

View-Based Compatibility Layer

-- Create view for backward compatibility during column rename
CREATE OR REPLACE VIEW users_compat AS
SELECT 
    id,
    username,  -- Old column name
    user_handle,  -- New column name
    email,
    created_at,
    updated_at
FROM users;

-- Grant permissions to application
GRANT SELECT ON users_compat TO app_user;

-- Later, after application is updated to use new column:
-- Drop the compatibility view
DROP VIEW users_compat;

Why View-Based Compatibility Matters: Views provide backward compatibility during schema changes without duplicating data. Understanding these patterns prevents application chaos and enables reliable database management.

4) PostGIS / Geospatial Considerations

Concurrent Index Creation

-- Bad: Blocks table during index creation
CREATE INDEX idx_telemetry_location ON telemetry USING GIST (location);

-- Good: Creates index without blocking table
CREATE INDEX CONCURRENTLY idx_telemetry_location ON telemetry USING GIST (location);

-- For large tables, monitor progress
SELECT 
    schemaname,
    tablename,
    indexname,
    indexdef
FROM pg_indexes 
WHERE indexname = 'idx_telemetry_location';

Why Concurrent Index Creation Matters: Concurrent index creation prevents table locks on large tables, enabling zero-downtime schema changes. Understanding these patterns prevents production chaos and enables reliable database management.

Spatial Reference System (SRID) Validation

-- Check SRID before altering geometry columns
SELECT 
    f_table_name,
    f_geometry_column,
    srid,
    type
FROM geometry_columns 
WHERE f_table_name = 'telemetry';

-- Validate SRID before migration
SELECT 
    ST_SRID(location) as current_srid,
    COUNT(*) as feature_count
FROM telemetry 
WHERE location IS NOT NULL
GROUP BY ST_SRID(location);

-- Update SRID if needed (careful with this!)
-- UPDATE telemetry SET location = ST_SetSRID(location, 4326) WHERE ST_SRID(location) != 4326;

Why SRID Validation Matters: SRID validation prevents geospatial data corruption during schema changes. Understanding these patterns prevents data chaos and enables reliable geospatial database management.

Partitioning for Large Geospatial Tables

-- Create partitioned table for time-series geospatial data
CREATE TABLE telemetry_partitioned (
    id SERIAL,
    user_id INTEGER NOT NULL,
    sensor_id VARCHAR(50) NOT NULL,
    timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
    temperature DOUBLE PRECISION,
    humidity DOUBLE PRECISION,
    pressure DOUBLE PRECISION,
    location GEOMETRY(POINT, 4326),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    PRIMARY KEY (id, timestamp)
) PARTITION BY RANGE (timestamp);

-- Create monthly partitions
CREATE TABLE telemetry_2024_01 PARTITION OF telemetry_partitioned
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

CREATE TABLE telemetry_2024_02 PARTITION OF telemetry_partitioned
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

-- Create spatial indexes on each partition
CREATE INDEX CONCURRENTLY idx_telemetry_2024_01_location 
ON telemetry_2024_01 USING GIST (location);

CREATE INDEX CONCURRENTLY idx_telemetry_2024_02_location 
ON telemetry_2024_02 USING GIST (location);

Why Partitioning Matters: Partitioning enables efficient management of large geospatial datasets with reduced lock times. Understanding these patterns prevents performance chaos and enables reliable geospatial database management.

FDW Schema Evolution

-- For parquet_s3_fdw: Schema changes mean metadata updates, not file rewrites
-- Create foreign table
CREATE FOREIGN TABLE telemetry_parquet (
    id INTEGER,
    user_id INTEGER,
    sensor_id VARCHAR(50),
    timestamp TIMESTAMP WITH TIME ZONE,
    temperature DOUBLE PRECISION,
    humidity DOUBLE PRECISION,
    pressure DOUBLE PRECISION,
    location GEOMETRY(POINT, 4326)
) SERVER parquet_s3_server
OPTIONS (
    filename 's3://my-bucket/telemetry/2024/01/',
    format 'parquet'
);

-- When schema changes in source files:
-- 1. Update foreign table definition
ALTER FOREIGN TABLE telemetry_parquet 
ADD COLUMN new_field VARCHAR(100);

-- 2. Update server options if needed
ALTER SERVER parquet_s3_server 
OPTIONS (SET filename 's3://my-bucket/telemetry/2024/01/v2/');

-- 3. Refresh metadata
IMPORT FOREIGN SCHEMA public FROM SERVER parquet_s3_server INTO public;

Why FDW Schema Evolution Matters: FDW schema changes require metadata updates rather than file modifications, enabling efficient schema evolution. Understanding these patterns prevents integration chaos and enables reliable federated database management.

5) Testing & Validation

Migration Testing with Docker

# docker-compose.test.yml
version: '3.9'
services:
  postgres:
    image: postgis/postgis:15-3.4
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
    ports:
      - "5432:5432"
    volumes:
      - ./test-data:/docker-entrypoint-initdb.d

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  app:
    build: .
    environment:
      DATABASE_URL: postgresql://testuser:testpass@postgres:5432/testdb
      REDIS_URL: redis://redis:6379/0
    depends_on:
      - postgres
      - redis
    command: python -m pytest tests/migrations/

Why Migration Testing Matters: Automated migration testing prevents production failures and ensures schema changes work correctly. Understanding these patterns prevents deployment chaos and enables reliable database management.

Migration Validation Scripts

# tests/migrations/test_migration_validation.py
import pytest
import psycopg2
from sqlalchemy import create_engine, text
from alembic import command
from alembic.config import Config

class TestMigrationValidation:
    """Test that migrations work correctly and don't break data."""

    @pytest.fixture
    def db_engine(self):
        """Create test database engine."""
        return create_engine("postgresql://testuser:testpass@localhost:5432/testdb")

    @pytest.fixture
    def alembic_cfg(self):
        """Create Alembic configuration."""
        config = Config("alembic.ini")
        config.set_main_option("sqlalchemy.url", "postgresql://testuser:testpass@localhost:5432/testdb")
        return config

    def test_migration_upgrade(self, db_engine, alembic_cfg):
        """Test that migration upgrade works."""
        # Run migration
        command.upgrade(alembic_cfg, "head")

        # Validate schema
        with db_engine.connect() as conn:
            # Check tables exist
            result = conn.execute(text("""
                SELECT table_name 
                FROM information_schema.tables 
                WHERE table_schema = 'public'
                ORDER BY table_name
            """))
            tables = [row[0] for row in result]
            assert 'users' in tables
            assert 'user_preferences' in tables
            assert 'telemetry' in tables

    def test_migration_downgrade(self, db_engine, alembic_cfg):
        """Test that migration downgrade works."""
        # Upgrade first
        command.upgrade(alembic_cfg, "head")

        # Then downgrade
        command.downgrade(alembic_cfg, "base")

        # Validate schema is clean
        with db_engine.connect() as conn:
            result = conn.execute(text("""
                SELECT table_name 
                FROM information_schema.tables 
                WHERE table_schema = 'public'
            """))
            tables = [row[0] for row in result]
            assert len(tables) == 0

    def test_data_integrity(self, db_engine, alembic_cfg):
        """Test that data integrity is maintained during migration."""
        # Insert test data before migration
        with db_engine.connect() as conn:
            conn.execute(text("""
                INSERT INTO users (username, email) 
                VALUES ('testuser', 'test@example.com')
            """))
            conn.commit()

        # Run migration
        command.upgrade(alembic_cfg, "head")

        # Validate data is still there
        with db_engine.connect() as conn:
            result = conn.execute(text("SELECT COUNT(*) FROM users"))
            count = result.scalar()
            assert count == 1

    def test_geospatial_data(self, db_engine, alembic_cfg):
        """Test that geospatial data is handled correctly."""
        # Run migration
        command.upgrade(alembic_cfg, "head")

        # Insert geospatial test data
        with db_engine.connect() as conn:
            conn.execute(text("""
                INSERT INTO telemetry (user_id, sensor_id, timestamp, location)
                VALUES (1, 'sensor_001', NOW(), ST_GeomFromText('POINT(-122.4194 37.7749)', 4326))
            """))
            conn.commit()

        # Validate geospatial data
        with db_engine.connect() as conn:
            result = conn.execute(text("""
                SELECT ST_AsText(location), ST_SRID(location)
                FROM telemetry
                WHERE sensor_id = 'sensor_001'
            """))
            row = result.fetchone()
            assert row[0] == 'POINT(-122.4194 37.7749)'
            assert row[1] == 4326

Why Migration Validation Matters: Comprehensive migration testing prevents data corruption and ensures schema changes work correctly. Understanding these patterns prevents production chaos and enables reliable database management.

6) Automation in CI/CD

GitHub Actions Migration Pipeline

# .github/workflows/migrations.yml
name: Database Migrations

on:
  push:
    branches: [ main, develop ]
    paths: [ 'alembic/**', 'migrations/**' ]
  pull_request:
    branches: [ main ]
    paths: [ 'alembic/**', 'migrations/**' ]

jobs:
  test-migrations:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgis/postgis:15-3.4
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
    - uses: actions/checkout@v4

    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: '3.11'
        cache: 'pip'

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

    - name: Run migration tests
      run: |
        pytest tests/migrations/ -v
      env:
        DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb

    - name: Test migration dry-run
      run: |
        alembic upgrade head --sql > migration.sql
        echo "Migration SQL generated successfully"

    - name: Validate migration SQL
      run: |
        # Check for dangerous operations
        if grep -i "drop table" migration.sql; then
          echo "WARNING: DROP TABLE found in migration"
          exit 1
        fi

        if grep -i "alter table.*drop column" migration.sql; then
          echo "WARNING: DROP COLUMN found in migration"
          exit 1
        fi

        echo "Migration SQL validation passed"

  deploy-migrations:
    runs-on: ubuntu-latest
    needs: test-migrations
    if: github.ref == 'refs/heads/main'
    environment: production
    steps:
    - uses: actions/checkout@v4

    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: '3.11'
        cache: 'pip'

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

    - name: Run production migration
      run: |
        alembic upgrade head
      env:
        DATABASE_URL: ${{ secrets.DATABASE_URL }}

    - name: Validate migration results
      run: |
        python scripts/validate_migration.py
      env:
        DATABASE_URL: ${{ secrets.DATABASE_URL }}

Why CI/CD Migration Automation Matters: Automated migration testing and deployment prevents production failures and ensures consistent schema changes. Understanding these patterns prevents deployment chaos and enables reliable database management.

Migration Safety Checks

# scripts/validate_migration.py
import os
import sys
import psycopg2
from sqlalchemy import create_engine, text

def validate_migration():
    """Validate that migration completed successfully."""
    db_url = os.getenv("DATABASE_URL")
    if not db_url:
        print("ERROR: DATABASE_URL not set")
        sys.exit(1)

    engine = create_engine(db_url)

    with engine.connect() as conn:
        # Check that all tables exist
        result = conn.execute(text("""
            SELECT table_name 
            FROM information_schema.tables 
            WHERE table_schema = 'public'
            ORDER BY table_name
        """))
        tables = [row[0] for row in result]
        expected_tables = ['users', 'user_preferences', 'telemetry']

        for table in expected_tables:
            if table not in tables:
                print(f"ERROR: Table {table} not found")
                sys.exit(1)

        # Check row counts
        result = conn.execute(text("SELECT COUNT(*) FROM users"))
        user_count = result.scalar()
        print(f"Users: {user_count}")

        # Check geospatial data integrity
        result = conn.execute(text("""
            SELECT COUNT(*) 
            FROM telemetry 
            WHERE location IS NOT NULL
        """))
        geo_count = result.scalar()
        print(f"Geospatial records: {geo_count}")

        # Check SRID consistency
        result = conn.execute(text("""
            SELECT DISTINCT ST_SRID(location) as srid
            FROM telemetry 
            WHERE location IS NOT NULL
        """))
        srids = [row[0] for row in result]
        if len(srids) > 1:
            print(f"WARNING: Multiple SRIDs found: {srids}")
        else:
            print(f"SRID consistency: {srids[0] if srids else 'No geospatial data'}")

        print("Migration validation completed successfully")

if __name__ == "__main__":
    validate_migration()

Why Migration Safety Checks Matter: Post-migration validation ensures data integrity and schema consistency. Understanding these patterns prevents data chaos and enables reliable database management.

7) Anti-Patterns

Common Migration Mistakes

# What NOT to do
migration_anti_patterns:
  "manual_prod_changes": "Never apply manual schema changes in production",
  "no_version_control": "Never skip version control for migrations",
  "destructive_changes": "Never make destructive changes without planning",
  "peak_hour_changes": "Never apply schema changes during peak hours",
  "no_testing": "Never skip testing migrations",
  "no_rollback_plan": "Never deploy without rollback plan",
  "fdw_static": "Never treat FDW schemas as static",
  "no_backup": "Never skip database backup before migration",
  "no_monitoring": "Never skip monitoring during migration",
  "no_validation": "Never skip post-migration validation"

Why Anti-Patterns Matter: Understanding common mistakes prevents production failures and data corruption. Understanding these patterns prevents migration chaos and enables reliable database management.

Migration Horror Stories

## Migration Horror Stories

### The DROP TABLE Incident
**What happened**: Developer ran `DROP TABLE users` in production
**Result**: Lost all user data, 4-hour outage
**Lesson**: Always backup before destructive operations

### The Index Lock Disaster
**What happened**: Created index on 100M row table without CONCURRENTLY
**Result**: 2-hour table lock, application down
**Lesson**: Use CONCURRENTLY for large table indexes

### The SRID Mismatch
**What happened**: Changed SRID without updating existing data
**Result**: All geospatial queries returned wrong results
**Lesson**: Always validate SRID consistency

### The FDW Metadata Sync
**What happened**: Changed parquet schema without updating FDW metadata
**Result**: All federated queries failed
**Lesson**: FDW schemas need metadata updates, not file changes

Why Horror Stories Matter: Learning from others' mistakes prevents similar failures and improves migration practices. Understanding these patterns prevents production chaos and enables reliable database management.

8) TL;DR Runbook

Essential Commands

# Create new migration
alembic revision -m "Add user preferences table"

# Run migration
alembic upgrade head

# Rollback migration
alembic downgrade -1

# Check migration status
alembic current

# Generate migration SQL (dry run)
alembic upgrade head --sql

# Test migration on staging
alembic upgrade head --sql > migration.sql
psql staging_db < migration.sql

Essential Patterns

# Essential migration patterns
migration_patterns:
  "version_control": "Version-control all migrations (Alembic/Flyway)",
  "additive_changes": "Prefer additive over destructive changes",
  "test_snapshots": "Test on prod-like data snapshots",
  "zero_downtime": "Use zero-downtime strategies (add  backfill  drop)",
  "concurrent_indexes": "Always index concurrently in PostGIS",
  "ci_cd_automation": "Automate in CI/CD with dry-runs + PR reviews",
  "backup_first": "Always backup before migration",
  "monitor_during": "Monitor during migration execution",
  "validate_after": "Validate results after migration",
  "rollback_ready": "Have rollback plan ready"

Quick Reference

## Emergency Migration Response

### If Migration Fails
1. **Stop the migration immediately**
2. **Check database state**
3. **Restore from backup if needed**
4. **Analyze failure cause**
5. **Fix and retry**

### If Data is Corrupted
1. **Stop all applications**
2. **Restore from backup**
3. **Investigate corruption cause**
4. **Fix migration script**
5. **Test thoroughly before retry**

### If Rollback is Needed
1. **Stop all applications**
2. **Run rollback migration**
3. **Validate data integrity**
4. **Restart applications**
5. **Monitor for issues**

Why This Runbook: These patterns cover 90% of migration needs. Master these before exploring advanced migration scenarios.

9) The Machine's Summary

Database migrations require understanding schema evolution, data safety, and production operations. When used correctly, effective migrations enable safe schema changes, prevent data corruption, and maintain system reliability. The key is understanding zero-downtime strategies, proper testing, and automation.

The Dark Truth: Without proper migration practices, your database becomes a liability. Database migrations are your weapon. Use them wisely.

The Machine's Mantra: "In the additive changes we trust, in the testing we find safety, and in the automation we find the path to reliable schema evolution."

Why This Matters: Database migrations enable schema evolution that can handle complex data systems, prevent data corruption, and provide insights into database design while ensuring technical accuracy and reliability.


This guide provides the complete machinery for database migrations. The patterns scale from simple table changes to complex geospatial schema evolution, from basic Alembic usage to advanced zero-downtime strategies.