This guide covers database schema migrations using Alembic, integrated into the API Forge CLI for both bundled and external PostgreSQL deployments.
API Forge uses Alembic for database schema migrations with automatic model discovery from SQLModel table definitions. The system:
- Auto-discovers all SQLModel tables - no manual import lists to maintain
- Handles port-forwarding automatically for bundled Kubernetes PostgreSQL
- Supports both bundled (in-cluster) and external PostgreSQL databases
- Integrates with the CLI for a seamless workflow
alembic.ini- Alembic configuration file (project root)migrations/env.py- Migration environment with dynamic model discoverymigrations/versions/- Individual migration scriptssrc/app/entities/loader.py- Dynamic table discovery usingSQLModel.metadata- CLI commands -
uv run api-forge-cli k8s db migrate ...
- Model Registration: All SQLModel classes with
table=Trueautomatically register withSQLModel.metadatawhen imported - Dynamic Discovery:
src/app/entities/loader.pyusesrglob("table.py")to find all table modules and imports them - Autogeneration: Alembic compares
SQLModel.metadata(your models) against the database schema to detect changes - Port-Forwarding: CLI automatically establishes
kubectl port-forwardfor bundled PostgreSQL before running Alembic
After defining your SQLModel tables:
# Auto-generate migration from model changes
uv run api-forge-cli k8s db migrate revision "initial schema" --autogenerateThis will:
- Scan all
table.pyfiles insrc/app/entities/ - Compare models against the database schema
- Generate a migration file in
migrations/versions/
# Check what was generated
cat migrations/versions/2025*_initial_schema.pyReview the upgrade() and downgrade() functions to ensure correctness.
# Apply to database
uv run api-forge-cli k8s db migrate upgrade# Check current migration state
uv run api-forge-cli k8s db migrate currentAll commands work with both bundled and external PostgreSQL.
# Auto-generate from model changes (recommended)
uv run api-forge-cli k8s db migrate revision "add user email" --autogenerate
# Create empty template for manual SQL
uv run api-forge-cli k8s db migrate revision "custom index" --no-autogenerate
# Generate SQL without applying
uv run api-forge-cli k8s db migrate upgrade --sql > migration.sql# Apply all pending migrations
uv run api-forge-cli k8s db migrate upgrade
# Apply to specific revision
uv run api-forge-cli k8s db migrate upgrade abc123
# Apply one migration at a time
uv run api-forge-cli k8s db migrate upgrade +1# Rollback to specific revision
uv run api-forge-cli k8s db migrate downgrade abc123
# Rollback one migration
uv run api-forge-cli k8s db migrate downgrade -1
# Rollback all (to empty database)
uv run api-forge-cli k8s db migrate downgrade base
# Generate rollback SQL without applying
uv run api-forge-cli k8s db migrate downgrade -1 --sql > rollback.sql# Show current migration version
uv run api-forge-cli k8s db migrate current
# Show current head revision(s)
uv run api-forge-cli k8s db migrate heads
# Show a specific migration's details
uv run api-forge-cli k8s db migrate show 19becf30b774
# Show all migrations
uv run api-forge-cli k8s db migrate history
# Show detailed history with verbose flag
uv run api-forge-cli k8s db migrate history --verboseWhen multiple developers generate migrations in parallel, Alembic can end up with multiple heads. These commands help resolve that cleanly.
# View current heads
uv run api-forge-cli k8s db migrate heads
# Merge all current heads into a single head
uv run api-forge-cli k8s db migrate merge --message "merge heads"
# Merge specific revisions
uv run api-forge-cli k8s db migrate merge --message "merge" -r abc123 -r def456stamp sets the database's Alembic revision without running migrations.
This is useful for baselining an existing database or repairing the version table.
# Mark DB as up-to-date with the latest migration
uv run api-forge-cli k8s db migrate stamp head
# Mark DB as a specific revision
uv run api-forge-cli k8s db migrate stamp 19becf30b774When you add a new entity to your application:
- Create the table model in
src/app/entities/<domain>/<entity>/table.py:
from sqlmodel import Field
from src.app.entities.core._base import EntityTable
class ProductTable(EntityTable, table=True):
"""Product table model."""
name: str = Field(max_length=255)
price: float = Field(gt=0)
sku: str = Field(max_length=100, index=True)- Generate migration (auto-detected, no imports needed):
uv run api-forge-cli k8s db migrate revision "add product table" --autogenerate-
Review the generated migration file
-
Apply to database:
uv run api-forge-cli k8s db migrate upgradeThat's it! The dynamic loader finds your new table.py automatically.
- Update the table model in
src/app/entities/<domain>/<entity>/table.py:
class UserTable(EntityTable, table=True):
# ... existing fields ...
# Add new field
phone_number: str | None = Field(default=None, max_length=20)- Generate migration:
uv run api-forge-cli k8s db migrate revision "add user phone number" --autogenerate- Review and apply:
cat migrations/versions/*_add_user_phone_number.py
uv run api-forge-cli k8s db migrate upgradeBefore applying to production:
- Apply migration in development/staging:
uv run api-forge-cli k8s db migrate upgrade-
Test the application with the new schema
-
Test rollback:
uv run api-forge-cli k8s db migrate downgrade -1
# Verify app still works
uv run api-forge-cli k8s db migrate upgradeLet Alembic detect changes automatically:
# ✅ Recommended
uv run api-forge-cli k8s db migrate revision "add column" --autogenerate
# ❌ Avoid (unless you need custom SQL)
uv run api-forge-cli k8s db migrate revision "add column" --no-autogenerateAlembic may not always generate perfect migrations. Always review:
def upgrade() -> None:
# Check for:
# - Correct column types
# - Proper null/default handling
# - Index creation
# - Foreign key constraints
op.add_column('users', sa.Column('email', sa.String(255), nullable=True))
# Add data migration if needed
op.execute("UPDATE users SET email = concat(username, '@example.com')")
# Then enforce constraint
op.alter_column('users', 'email', nullable=False)Always implement proper downgrade():
def upgrade() -> None:
op.add_column('users', sa.Column('status', sa.String(20)))
def downgrade() -> None:
op.drop_column('users', 'status')# Apply migration
uv run api-forge-cli k8s db migrate upgrade
# Test app functionality
# Test rollback
uv run api-forge-cli k8s db migrate downgrade -1
# Test app still works
# Re-apply
uv run api-forge-cli k8s db migrate upgradeFor operations that modify existing data:
def upgrade() -> None:
# 1. Add column as nullable
op.add_column('products', sa.Column('category', sa.String(50), nullable=True))
# 2. Populate data
op.execute("""
UPDATE products
SET category = 'general'
WHERE category IS NULL
""")
# 3. Make non-nullable
op.alter_column('products', 'category', nullable=False)Once a migration is applied to any environment (dev, staging, prod):
- ❌ Never edit it
- ✅ Create a new migration to fix issues
- One logical change per migration
- Makes rollback easier
- Simplifies code review
# ✅ Good - focused migrations
uv run api-forge-cli k8s db migrate revision "add user email"
uv run api-forge-cli k8s db migrate revision "add email index"
# ❌ Bad - too many changes
uv run api-forge-cli k8s db migrate revision "update user schema"- Generate migration in development:
uv run api-forge-cli k8s db migrate revision "production change" --autogenerate-
Test thoroughly in staging environment
-
Commit migration file to version control
- Backup database:
kubectl exec -n api-forge-prod postgresql-0 -- pg_dump -U postgres appdb > backup.sql- Apply migration:
uv run api-forge-cli k8s db migrate upgrade- Verify:
uv run api-forge-cli k8s db migrate current
uv run api-forge-cli k8s db verify- Deploy application with new code
If issues occur:
# 1. Rollback application deployment
kubectl rollout undo deployment/api-forge -n api-forge-prod
# 2. Rollback migration
uv run api-forge-cli k8s db migrate downgrade -1
# 3. Verify
uv run api-forge-cli k8s db verifyCause: Previous migration was interrupted
Solution:
# Check current state
uv run api-forge-cli k8s db migrate current
# Force to specific revision
uv run api-forge-cli k8s db migrate upgrade abc123Cause: Table model not being imported
Solution: Verify your table.py file exists in src/app/entities/:
# Should list your table.py files
find src/app/entities -name "table.py"
# Test import
uv run python -c "from src.app.entities.loader import get_metadata; print(get_metadata().tables.keys())"Cause: Bundled PostgreSQL pod not ready
Solution:
# Check pod status
kubectl get pods -n api-forge-prod -l app=postgresql
# Restart port-forward by retrying command
uv run api-forge-cli k8s db migrate currentCause: Manual changes made to database outside migrations
Solution:
# Generate migration to align
uv run api-forge-cli k8s db migrate revision "fix schema drift" --autogenerate
# Review carefully - may need manual editing
cat migrations/versions/*_fix_schema_drift.pyCause: Branches in migration history (multiple developers)
Solution:
# View heads
uv run api-forge-cli k8s db migrate heads
# Merge branches
uv run api-forge-cli k8s db migrate revision "merge branches" --mergeFor teams working on multiple features:
# Create branch label
uv run api-forge-cli k8s db migrate revision "feature a" --autogenerate --branch-label feature_a
# Create another branch
uv run api-forge-cli k8s db migrate revision "feature b" --autogenerate --branch-label feature_b
# Merge branches
uv run api-forge-cli k8s db migrate revision "merge features" --mergeFor complex operations not detectable by autogenerate:
# Create empty template
uv run api-forge-cli k8s db migrate revision "optimize indexes" --no-autogenerateEdit the generated file:
def upgrade() -> None:
# Custom SQL
op.execute("""
CREATE INDEX CONCURRENTLY idx_users_email_lower
ON users (LOWER(email))
""")
def downgrade() -> None:
op.execute("DROP INDEX idx_users_email_lower")Generate SQL without database connection:
# Generate upgrade SQL
uv run api-forge-cli k8s db migrate upgrade --sql > upgrade.sql
# Apply manually
psql -h localhost -U postgres appdb < upgrade.sqlSet by CLI automatically, but available for manual use:
DATABASE_URL- Complete PostgreSQL connection string (set by CLI)
project/
├── alembic.ini # Alembic config
├── migrations/
│ ├── env.py # Migration environment
│ ├── script.py.mako # Migration template
│ ├── README.md # Technical reference
│ └── versions/ # Migration scripts
│ └── 20251224_0327_initial.py
└── src/
└── app/
└── entities/
├── loader.py # Dynamic table discovery
└── core/
└── user/
└── table.py # User table model
If you encounter issues:
- Check this guide's Troubleshooting section
- Review
migrations/README.mdfor technical details - Check Alembic logs for detailed error messages
- Verify database connectivity:
uv run api-forge-cli k8s db verify
For Alembic-specific documentation: https://alembic.sqlalchemy.org/