Database Migrations¶
This guide explains how to manage database schema changes using Alembic, the migration tool for SQLAlchemy and SQLModel.
Overview¶
The application uses Alembic to manage database schema migrations. All schema changes must be defined as SQLModel classes and captured in versioned migration scripts.
Warning
Never modify the database schema manually. Always generate migrations from code changes.
Workflow¶
graph LR
A[Update SQLModel] --> B[Generate Migration]
B --> C[Review SQL]
C --> D[Apply Migration]
D --> E[Commit to Git]
Creating Migrations¶
1. Update Your Models¶
Modify your SQLModel classes in app/modules/*/models.py:
# app/modules/user/models.py
class User(SQLModel, table=True):
__tablename__ = "users"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
email: str = Field(unique=True, index=True, max_length=255)
full_name: str | None = Field(default=None, max_length=255)
is_active: bool = Field(default=True)
# NEW: Add a phone number field
phone: str | None = Field(default=None, max_length=20)
2. Generate Migration Script¶
Use the just migrate-gen command with a descriptive message:
This runs alembic revision --autogenerate -m "message" and creates a new
migration file in alembic/versions/:
3. Review the Generated SQL¶
Always review the generated migration before applying it:
# alembic/versions/abc123def456_add_phone_field_to_users_table.py
def upgrade() -> None:
op.add_column('users', sa.Column('phone', sa.String(length=20)))
def downgrade() -> None:
op.drop_column('users', 'phone')
Common issues to check:
- Data loss operations (e.g., dropping columns with data)
- Missing NOT NULL constraints on new columns
- Index creation on large tables (consider
CONCURRENTLY) - Foreign key constraints
4. Apply the Migration¶
This runs alembic upgrade head to apply all pending migrations.
5. Commit to Git¶
git add alembic/versions/abc123def456_*.py
git commit -m "feat(user): add phone field to user model"
Alembic Commands¶
All commands are wrapped in the justfile:
| Command | Alembic Equivalent | Description |
|---|---|---|
just migrate-gen "msg" |
alembic revision --autogenerate -m "msg" |
Generate migration |
just migrate-up |
alembic upgrade head |
Apply all pending migrations |
just migrate-down |
alembic downgrade -1 |
Rollback last migration |
just reset-db |
(stop, restart, apply) | Reset database completely |
Note
migrate-history and migrate-current are not wrapped in just.
Run Alembic directly for these:
Configuration¶
Alembic configuration is stored in alembic.ini
and alembic/env.py.
Database URL¶
The database URL is automatically loaded from environment variables via
Settings.DATABASE_URL:
# alembic/env.py
from app.core.config import settings
config.set_main_option("sqlalchemy.url", str(settings.DATABASE_URL))
SQLModel Metadata¶
All SQLModel tables are automatically discovered:
# alembic/env.py
from sqlmodel import SQLModel
from app.modules.user.models import User # Import all models
target_metadata = SQLModel.metadata
Best Practices¶
Naming Migrations¶
Use descriptive names that explain what changed:
✅ just migrate-gen "add email verification fields"
✅ just migrate-gen "create orders table"
✅ just migrate-gen "add index on user email"
❌ just migrate-gen "update database"
❌ just migrate-gen "changes"
Handling Data Migrations¶
For operations that require data transformation, use two-step migrations:
Step 1: Add new column as nullable
def upgrade() -> None:
op.add_column('users', sa.Column('email_verified', sa.Boolean(), nullable=True))
Step 2: Backfill data and add NOT NULL constraint
def upgrade() -> None:
# Backfill data
op.execute("UPDATE users SET email_verified = false WHERE email_verified IS NULL")
# Add constraint
op.alter_column('users', 'email_verified', nullable=False)
Complex Migrations¶
For complex changes, manually edit the generated migration:
def upgrade() -> None:
# Create new table
op.create_table('user_profiles',
sa.Column('id', sa.UUID(), primary_key=True),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
)
# Migrate data from old structure
op.execute("""
INSERT INTO user_profiles (id, user_id, ...)
SELECT gen_random_uuid(), id, ... FROM users
""")
Production Deployments¶
Option 1: Run Migrations in Dockerfile¶
# Add to Dockerfile
COPY alembic/ alembic/
COPY alembic.ini .
# Run migrations on container start
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0"]
Option 2: Separate Migration Job¶
Run migrations as a separate one-off job before deploying:
# Kubernetes Job
kubectl run migrations --image=myapp:latest --command -- alembic upgrade head
# Docker Compose
docker-compose run app alembic upgrade head
Rollback Strategy¶
Rolling Back Migrations¶
# Rollback last migration
just migrate-down
# Rollback to specific revision
uv run alembic downgrade abc123def456
Testing Rollbacks¶
Always test that downgrade() works:
Troubleshooting¶
"Target database is not up to date"¶
Solution: Apply pending migrations first:
"Can't locate revision identified by 'xyz'"¶
Solution: The migration history is out of sync. Check:
# What migrations exist in code?
ls alembic/versions/
# What revision is the database at?
uv run alembic current
Autogenerate Doesn't Detect Changes¶
Common causes:
- Model not imported in
alembic/env.py - Schema change not saved — ensure you've saved the model file before running autogenerate
- SQLModel metadata not set as target_metadata
Solution: Add import to alembic/env.py:
from app.modules.user.models import User
from app.modules.product.models import Product # Add new models here
Quick Reference¶
| Task | Command |
|---|---|
| Generate migration | just migrate-gen "message" |
| Apply migrations | just migrate-up |
| Rollback one step | just migrate-down |
| View history | uv run alembic history |
| View current revision | uv run alembic current |
| Stamp head (without running) | uv run alembic stamp head |
See Also¶
- Alembic Documentation
- SQLModel Documentation
- alembic/env.py — Migration configuration