| name | database_migrations |
| description | Safely create, test, and deploy Alembic database migrations for AARLP |
Database Migration Management Skill
Purpose
Handle all database schema changes for the AARLP platform using Alembic. This skill ensures safe, reversible migrations that work in development, staging, and production environments.
Quick Reference
alembic revision --autogenerate -m "Add column to jobs table"
alembic upgrade head
alembic downgrade -1
alembic current
alembic history --verbose
Migration Workflow
1. Pre-Migration Checklist
2. Creating Migrations
Auto-Generate from Models
alembic revision --autogenerate -m "descriptive_message"
Example Messages:
"Add status column to jobs table"
"Create candidates table with FK to jobs"
"Add index on job_id and created_at"
"Rename column title to job_title"
Manual Migration (Complex Changes)
alembic revision -m "migrate_data_from_old_to_new_format"
3. Review Generated Migration
Always review auto-generated migrations! Alembic may miss:
- Data migrations
- Index drops/creates
- Column renames (sees as drop + add)
- Constraint changes
def upgrade() -> None:
"""
ALWAYS review auto-generated code.
Add data migrations if needed.
"""
op.add_column('jobs', sa.Column('status', sa.String(), nullable=True))
op.execute("UPDATE jobs SET status = 'PENDING' WHERE status IS NULL")
op.alter_column('jobs', 'status', nullable=False)
def downgrade() -> None:
"""
CRITICAL: Always implement downgrade.
Never use pass or raise NotImplementedError in production.
"""
op.drop_column('jobs', 'status')
4. Testing Migrations
Local Testing
pg_dump aarlp > backup_$(date +%Y%m%d_%H%M%S).sql
alembic upgrade head
psql aarlp -c "\d jobs"
uvicorn app.main:app --reload
pytest tests/ -v
alembic downgrade -1
Staging Testing
git pull origin main
alembic upgrade head
systemctl restart aarlp-api
Common Migration Patterns
Pattern 1: Adding a Column
def upgrade() -> None:
op.add_column('jobs', sa.Column('salary_range', sa.String(), nullable=True))
op.execute("UPDATE jobs SET salary_range = 'Competitive' WHERE salary_range IS NULL")
op.alter_column('jobs', 'salary_range', nullable=False)
def downgrade() -> None:
op.drop_column('jobs', 'salary_range')
Pattern 2: Renaming a Column
def upgrade() -> None:
op.alter_column('jobs', 'jd_content', new_column_name='description')
def downgrade() -> None:
op.alter_column('jobs', 'description', new_column_name='jd_content')
Pattern 3: Adding Foreign Key
def upgrade() -> None:
op.add_column('candidates', sa.Column('job_id', sa.UUID(), nullable=True))
op.execute("""
UPDATE candidates c
SET job_id = (SELECT id FROM jobs WHERE jobs.id = c.job_id_temp)
""")
op.alter_column('candidates', 'job_id', nullable=False)
op.create_foreign_key(
'fk_candidates_job_id',
'candidates', 'jobs',
['job_id'], ['id'],
ondelete='CASCADE'
)
def downgrade() -> None:
op.drop_constraint('fk_candidates_job_id', 'candidates', type_='foreignkey')
op.drop_column('candidates', 'job_id')
Pattern 4: Adding Index
def upgrade() -> None:
op.create_index(
'ix_jobs_status_created_at',
'jobs',
['status', 'created_at'],
unique=False
)
def downgrade() -> None:
op.drop_index('ix_jobs_status_created_at', table_name='jobs')
Pattern 5: Data Migration
def upgrade() -> None:
"""Migrate data from old format to new format."""
op.execute("""
UPDATE jobs
SET requirements = jsonb_build_object(
'required', requirements_old,
'preferred', '{}'::jsonb
)
WHERE requirements_old IS NOT NULL
""")
from sqlalchemy.orm import Session
bind = op.get_bind()
session = Session(bind=bind)
jobs = session.execute("SELECT id, old_field FROM jobs").fetchall()
for job_id, old_value in jobs:
new_value = transform_value(old_value)
session.execute(
"UPDATE jobs SET new_field = :val WHERE id = :id",
{"val": new_value, "id": job_id}
)
session.commit()
def downgrade() -> None:
op.execute("""
UPDATE jobs
SET requirements_old = requirements->>'required'
WHERE requirements IS NOT NULL
""")
Pattern 6: Enum Changes
def upgrade() -> None:
op.execute("COMMIT")
op.execute("ALTER TYPE job_status ADD VALUE IF NOT EXISTS 'ARCHIVED'")
def downgrade() -> None:
pass
def upgrade() -> None:
"""
Remove 'DEPRECATED_STATUS' from job_status enum.
Strategy:
1. Create new enum without deprecated value
2. Alter column to new enum type
3. Drop old enum
4. Rename new enum to original name
"""
op.execute("""
CREATE TYPE job_status_new AS ENUM (
'PENDING', 'ACTIVE', 'ARCHIVED'
-- Excluded: 'DEPRECATED_STATUS'
)
""")
op.execute("""
UPDATE jobs
SET status = 'ARCHIVED'
WHERE status = 'DEPRECATED_STATUS'
""")
op.execute("""
ALTER TABLE jobs
ALTER COLUMN status TYPE job_status_new
USING status::text::job_status_new
""")
op.execute("DROP TYPE job_status")
op.execute("ALTER TYPE job_status_new RENAME TO job_status")
def downgrade() -> None:
"""Reverse: Add back the removed enum value."""
op.execute("COMMIT")
op.execute("ALTER TYPE job_status ADD VALUE 'DEPRECATED_STATUS'")
AARLP-Specific Patterns
Jobs Table Changes
from app.jobs.models import Job
def upgrade() -> None:
op.add_column('jobs',
sa.Column('workflow_status', sa.String(), nullable=True)
)
op.execute("UPDATE jobs SET workflow_status = 'PENDING'")
op.alter_column('jobs', 'workflow_status', nullable=False)
def downgrade() -> None:
op.drop_column('jobs', 'workflow_status')
Candidates Table Changes
def upgrade() -> None:
op.add_column('candidates',
sa.Column('semantic_score', sa.Float(), nullable=True)
)
op.create_index(
'ix_candidates_job_id_semantic_score',
'candidates',
['job_id', sa.text('semantic_score DESC')]
)
def downgrade() -> None:
op.drop_index('ix_candidates_job_id_semantic_score')
op.drop_column('candidates', 'semantic_score')
Production Deployment
Pre-Deployment
-
Review Migration
alembic upgrade head --sql
-
Timing Considerations
- Large table alterations? Use
CONCURRENTLY for indexes
- Data migrations? Estimate duration on staging
- Breaking changes? Plan downtime window
-
Backup Strategy
pg_dump aarlp_production > backup_pre_migration_$(date +%Y%m%d).sql
During Deployment
pg_dump aarlp_production > /backups/pre_migration.sql
alembic upgrade head
alembic current
psql aarlp_production -c "\d+ jobs"
systemctl restart aarlp-api
journalctl -u aarlp-api -f
curl http://localhost:8000/health
Rollback Plan
alembic downgrade -1
psql aarlp_production < /backups/pre_migration.sql
git revert <commit>
git push
systemctl restart aarlp-api
Advanced Techniques
Concurrent Index Creation (No Locks)
def upgrade() -> None:
from alembic import op
op.execute("COMMIT")
op.execute("""
CREATE INDEX CONCURRENTLY ix_jobs_created_at
ON jobs (created_at)
""")
def downgrade() -> None:
op.execute("COMMIT")
op.execute("DROP INDEX CONCURRENTLY ix_jobs_created_at")
Multi-Step Migrations (Large Tables)
def upgrade() -> None:
op.add_column('jobs', sa.Column('new_field', sa.String(), nullable=True))
def upgrade() -> None:
op.execute("""
UPDATE jobs
SET new_field = old_field
WHERE new_field IS NULL
LIMIT 10000
""")
def upgrade() -> None:
op.alter_column('jobs', 'new_field', nullable=False)
Troubleshooting
| Issue | Solution |
|---|
| "Target database is not up to date" | Run alembic upgrade head first |
| "Can't locate revision ABC" | Check alembic_version table, ensure all revisions exist |
| Migration auto-generates drop table | Review carefully! Likely model removed |
| Circular import in migration | Import models inside functions, not at module level |
| Enum change breaks | Use raw SQL with op.execute() |
| Foreign key constraint fails | Ensure referenced rows exist, use data migration |
Testing Migrations
import pytest
from alembic import command
from alembic.config import Config
def test_migration_upgrade_downgrade():
"""Test that migrations are reversible."""
config = Config("alembic.ini")
command.upgrade(config, "head")
command.downgrade(config, "-1")
command.upgrade(config, "head")
Best Practices Checklist
Related Skills
Resources