This document provides comprehensive procedures for safely applying database migrations in production environments.
Before running any migration in production:
- Backup database - Create a full database backup
- Review migration code - Ensure migrations have been reviewed and approved
- Test in staging - Apply and test migrations in a production-like staging environment
- Check disk space - Ensure sufficient disk space for the migration (indexes, new columns, etc.)
- Verify dependencies - Confirm application code is compatible with both old and new schema
- Plan maintenance window - Schedule during low-traffic period if downtime is required
- Notify stakeholders - Alert team members and users if necessary
- Prepare rollback plan - Know how to revert the migration if issues occur
# Create a full database backup
export TIMESTAMP=$(date +%Y%m%d_%H%M%S)
export BACKUP_FILE="transcripts_backup_${TIMESTAMP}.dump"
# Using pg_dump
pg_dump -h <host> -U <user> -d transcripts -F c -f ${BACKUP_FILE}
# Verify backup file exists and has reasonable size
ls -lh ${BACKUP_FILE}
# Optional: Test restore to a temporary database
createdb transcripts_restore_test
pg_restore -h <host> -U <user> -d transcripts_restore_test ${BACKUP_FILE}
dropdb transcripts_restore_test# Check current migration revision
python scripts/run_migrations.py current
# View pending migrations
python scripts/run_migrations.py history | grep -A 5 "head"
# Verify database health
psql $DATABASE_URL -c "SELECT pg_database_size('transcripts');"
psql $DATABASE_URL -c "SELECT count(*) FROM jobs;"
psql $DATABASE_URL -c "SELECT count(*) FROM videos;"Option A: Zero Downtime (Recommended)
# Ensure application is compatible with both old and new schema
# Run migrations while application is live
python scripts/run_migrations.py upgrade
# Monitor logs for errors
tail -f /var/log/app.logOption B: With Downtime
# Stop application services
docker compose stop api worker
# or: systemctl stop transcript-api transcript-worker
# Run migrations
python scripts/run_migrations.py upgrade
# Verify schema changes
psql $DATABASE_URL -c "\dt"
psql $DATABASE_URL -c "\di" # Check indexes
# Start application services
docker compose start api worker
# or: systemctl start transcript-api transcript-worker# Verify migration applied
python scripts/run_migrations.py current
# Check application health
curl http://localhost:8000/health
# Verify database functions
psql $DATABASE_URL << EOF
-- Check triggers still work
SELECT tgname, tgtype FROM pg_trigger WHERE tgname LIKE '%tsv%';
-- Check indexes are valid
SELECT schemaname, tablename, indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY tablename, indexname;
-- Check foreign key constraints
SELECT conname, conrelid::regclass, confrelid::regclass, contype
FROM pg_constraint
WHERE contype = 'f';
EOF
# Monitor application logs for errors
docker compose logs -f --tail=100 api worker
# Test core functionality
curl -X POST http://localhost:8000/jobs -H "Content-Type: application/json" -d '{"kind":"single","input_url":"https://youtube.com/watch?v=test"}'# Watch for errors in logs
docker compose logs -f api worker | grep -i error
# Monitor database performance
psql $DATABASE_URL << EOF
SELECT
schemaname,
tablename,
n_tup_ins as inserts,
n_tup_upd as updates,
n_tup_del as deletes
FROM pg_stat_user_tables
ORDER BY tablename;
EOF
# Check for slow queries
psql $DATABASE_URL << EOF
SELECT
query,
calls,
total_exec_time / 1000 as total_seconds,
mean_exec_time / 1000 as mean_seconds
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
EOF# If migration fails, Alembic typically rolls back automatically
# Check current state
python scripts/run_migrations.py current
# If partially applied, manually downgrade
python scripts/run_migrations.py downgrade -1
# Investigate error
docker compose logs migrations# Downgrade the migration
python scripts/run_migrations.py downgrade -1
# Verify downgrade
python scripts/run_migrations.py current
# Restart services with old schema
docker compose restart api worker# Stop all services immediately
docker compose stop api worker
# Restore from backup
dropdb transcripts
createdb transcripts
pg_restore -h <host> -U <user> -d transcripts ${BACKUP_FILE}
# Verify restore
psql $DATABASE_URL -c "SELECT count(*) FROM jobs;"
# Stamp database at backup revision
python scripts/run_migrations.py current # Check what revision backup was at
python scripts/run_migrations.py stamp <revision_id>
# Restart services
docker compose start api workerdef upgrade() -> None:
"""Add new optional column."""
# Add column as nullable initially
op.execute("ALTER TABLE videos ADD COLUMN thumbnail_url TEXT")
def downgrade() -> None:
"""Remove column."""
op.execute("ALTER TABLE videos DROP COLUMN IF EXISTS thumbnail_url")Deployment:
- Deploy migration (column added as nullable)
- Deploy new code that uses the column
- Optionally backfill data in a follow-up migration
- Optionally add NOT NULL constraint in another migration if needed
Phase 1 - Stop Writing:
def upgrade() -> None:
"""Stop using column (no schema change yet)."""
pass # Deploy code that doesn't write to column
def downgrade() -> None:
passPhase 2 - Remove Column:
def upgrade() -> None:
"""Remove unused column."""
op.execute("ALTER TABLE videos DROP COLUMN IF EXISTS old_field")
def downgrade() -> None:
"""Restore column."""
op.execute("ALTER TABLE videos ADD COLUMN old_field TEXT")Deployment:
- Deploy code that stops writing to column
- Wait for in-flight requests to complete
- Deploy migration that removes column
def upgrade() -> None:
"""Add index concurrently to avoid locks."""
# CONCURRENT requires autocommit mode
op.execute("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_videos_created ON videos(created_at)")
def downgrade() -> None:
"""Remove index."""
op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_videos_created")Note: CREATE INDEX CONCURRENTLY cannot run inside a transaction block. You may need to set:
# In migration file
revision = "abc123"
down_revision = "xyz789"
def upgrade() -> None:
# Get connection and set isolation level
connection = op.get_bind()
connection.execute("COMMIT") # Commit to enable autocommit mode (required for CREATE INDEX CONCURRENTLY)
connection.execute("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_videos_created ON videos(created_at)")
def downgrade() -> None:
connection = op.get_bind()
connection.execute("COMMIT") # Commit to enable autocommit mode (required for DROP INDEX CONCURRENTLY)
connection.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_videos_created")Phase 1 - Add New Column:
def upgrade() -> None:
"""Add new column with better name."""
op.execute("ALTER TABLE videos ADD COLUMN youtube_video_id TEXT")
def downgrade() -> None:
op.execute("ALTER TABLE videos DROP COLUMN IF EXISTS youtube_video_id")Phase 2 - Dual Write:
Deploy code that writes to both youtube_id and youtube_video_id.
Phase 3 - Backfill:
def upgrade() -> None:
"""Copy data from old to new column."""
op.execute("UPDATE videos SET youtube_video_id = youtube_id WHERE youtube_video_id IS NULL")
def downgrade() -> None:
pass # Data stays in new columnPhase 4 - Switch Read:
Deploy code that reads from youtube_video_id.
Phase 5 - Remove Old Column:
def upgrade() -> None:
"""Remove old column."""
op.execute("ALTER TABLE videos DROP COLUMN IF EXISTS youtube_id")
def downgrade() -> None:
op.execute("ALTER TABLE videos ADD COLUMN youtube_id TEXT")When using blue-green deployments, both versions of the application must work with the same database schema during the switchover period.
Strategy:
- Backwards-compatible changes only: New schema must work with old code
- Deploy schema first: Apply migrations before deploying new application version
- Graceful degradation: Old application should handle new columns/tables gracefully
- Forward-compatible: Old code should work with new schema
def upgrade() -> None:
"""Add new feature table."""
op.execute("""
CREATE TABLE IF NOT EXISTS new_feature (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
video_id UUID REFERENCES videos(id) ON DELETE CASCADE,
data JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
def downgrade() -> None:
"""Remove feature table."""
op.execute("DROP TABLE IF EXISTS new_feature CASCADE")Deployment:
- Apply migration (old code ignores new table)
- Deploy new code (uses new table)
- Verify functionality
- Switch traffic to new deployment
- Decommission old deployment
For tables with millions of rows:
DO:
- Use
ADD COLUMN ... DEFAULT NULL(instant in PostgreSQL 11+) - Create indexes with
CONCURRENTLYoption - Batch large updates in data migrations
- Test on production-sized dataset first
- Monitor query performance after migration
DON'T:
- Use
ADD COLUMN ... DEFAULT <value>without NOT NULL (requires full table rewrite in older PostgreSQL) - Create indexes without CONCURRENTLY (locks table)
- Update all rows in a single transaction
- Add constraints without validation
# Create production-sized test database
pg_dump -h prod-host -U user -d transcripts -F c > prod_backup.dump
createdb transcripts_perf_test
pg_restore -d transcripts_perf_test prod_backup.dump
# Test migration performance
export DATABASE_URL="postgresql+psycopg://user:pass@localhost/transcripts_perf_test"
time python scripts/run_migrations.py upgrade
# Check table sizes
psql $DATABASE_URL << EOF
SELECT
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
EOFSymptoms: Migration runs but never completes
Causes:
- Table locked by long-running query
- Index creation on large table
- Transaction waiting for locks
Solutions:
# Check for locks
psql $DATABASE_URL << EOF
SELECT
pid,
usename,
application_name,
state,
query,
age(clock_timestamp(), query_start) AS age
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY age DESC;
EOF
# Check blocking queries
psql $DATABASE_URL << EOF
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));
EOF
# If necessary, terminate blocking query
psql $DATABASE_URL -c "SELECT pg_terminate_backend(<pid>);"Prevention:
# Check disk space before migration
df -h /var/lib/postgresql
# Estimate index size
psql $DATABASE_URL << EOF
SELECT pg_size_pretty(pg_relation_size('segments')) AS table_size;
-- Index will be approximately same size as table
EOFRecovery:
- Free up disk space
- Migrate to larger volume
- Drop unused indexes/tables
Diagnosis:
# Check application logs
docker compose logs api worker | grep -i error
# Verify schema matches expectations
psql $DATABASE_URL -c "\d videos"Solutions:
- Rollback migration:
python scripts/run_migrations.py downgrade -1 - Fix application code
- Create corrective migration
Before merging a migration PR:
-
Code Review
- Both
upgrade()anddowngrade()implemented - SQL syntax is correct
- Uses IF EXISTS/IF NOT EXISTS for idempotency
- No hardcoded values (use config/settings)
- Both
-
Testing
- Tests pass in CI
- Manual upgrade/downgrade tested locally
- Tested with sample data
- Tested on production-sized dataset (if applicable)
-
Performance
- Estimated migration duration documented
- Large table modifications use appropriate techniques
- Indexes created with CONCURRENTLY if needed
-
Safety
- No data loss in downgrade (if possible)
- Backwards compatible with current code
- No breaking changes without coordination
-
Documentation
- Migration purpose documented in docstring
- Special deployment instructions noted
- Related to issue/ticket
In case of migration issues:
- Check CI logs: GitHub Actions logs for migration tests
- Check application logs:
docker compose logs api worker - Check database logs:
docker compose logs db - Database health:
curl http://localhost:8000/health - Rollback: Follow rollback procedures above