This manual provides instructions and operational runbooks for managing PostgreSQL database schema migrations in DGG-PM using Alembic across both Development and Production environments.
- Architecture & Overview
- Development Workflow
- Production Deployment & Runbook
- CLI Command Quick Reference
DGG-PM uses Alembic integrated natively into its Hexagonal Architecture:
- Configuration File:
alembic.iniat repository root. - Migration Directory:
src/adapters/db/migrations/env.py: Asynchronous migration environment utilizing SQLAlchemy's async engine overasyncpg.script.py.mako: Revision template matching modern Python 3.13 typing standards.versions/: Sequential migration files (e.g.,0001_initial_schema.py).
- Database Engine: Direct reuse of
DATABASE_URL(postgresql+asyncpg://...). No secondary synchronous DB drivers (psycopg2) are needed. - Metadata Target:
Base.metadata(src.adapters.db.tables).
Development commands can be run via make shortcuts or directly with uv run:
# Apply pending migrations
make db-migrate
# or: uv run alembic upgrade headLocal environment variables are managed in .env. Ensure your DATABASE_URL is set:
DATABASE_URL=postgresql+asyncpg://postgres:postgrespassword@localhost:5432/dgg_pm
AUTO_RUN_MIGRATIONS=trueNote
AUTO_RUN_MIGRATIONS defaults to false in code to prevent multi-replica startup concurrency races in production. It is set to true in .env for seamless local development. When true, starting the application via make run automatically checks and applies pending migrations on startup.
Follow this 4-step workflow whenever modifying domain entities or table schemas:
Modify or add ORM tables in src/adapters/db/tables.py. For example, adding a new column:
class TaskTable(Base):
...
estimated_hours = Column(Integer, nullable=True)Generate a new migration script using the helper command or raw Alembic:
# Using the helper command (automatically assigns next 4-digit sequential ID, e.g. 0002)
make db-revision MSG="add_estimated_hours_to_tasks"
# or: uv run python scripts/generate_revision.py -m "add_estimated_hours_to_tasks"
# Or using raw alembic directly (requires explicit --rev-id for sequential naming)
uv run alembic revision --autogenerate --rev-id "0002" -m "add_estimated_hours_to_tasks"This creates a new file under src/adapters/db/migrations/versions/0002_add_estimated_hours_to_tasks.py.
Important
Always review autogenerated migration scripts before committing. Autogenerate detects table, column, index, and constraint changes, but does not detect table renames (which appear as DROP + CREATE) or data backfills.
Check the upgrade() and downgrade() functions:
def upgrade() -> None:
op.add_column("tasks", sa.Column("estimated_hours", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("tasks", "estimated_hours")Ensure the migration script passes Ruff checks:
make format
make lintApply all pending migrations to bring your local database up to head:
make db-migrate
# or: uv run alembic upgrade headVerify current revision:
uv run alembic currentInspect revision history:
uv run alembic history --verboseVerify schema alignment and check for unmigrated drift:
make db-check
# or: uv run alembic checkRun the automated test suite (including migration validation tests):
uv run pytest tests/test_migrations.py
make testTip
Continuous Integration (CI):
All migrations, schema drift verification (alembic check), and downgrade/upgrade rollback roundtrips are automatically tested against a live PostgreSQL 16 service container on every push and PR via GitHub Actions (.github/workflows/ci.yml).
To verify that your downgrade function works cleanly:
# Step back 1 revision
uv run alembic downgrade -1
# Verify schema
uv run alembic current
# Re-apply to head
make db-migrateWhen developing locally, you can wipe and rebuild the database cleanly:
# Wipe schema and apply all migrations fresh
make db-clear
# Wipe schema, apply migrations to head, and seed mock projects/squads/tasks
make db-resetclear_db.py executes DROP SCHEMA public CASCADE; CREATE SCHEMA public; and runs Alembic migrations from scratch, guaranteeing a clean state.
In production deployments, you can choose between two operational models:
| Strategy | AUTO_RUN_MIGRATIONS |
When to Use | Advantages |
|---|---|---|---|
| Strategy A: Container Entrypoint / Job (Recommended) | false |
Multi-replica clusters (Kubernetes, AWS ECS, Docker Swarm) | Eliminates migration concurrency races across replicas. |
| Strategy B: App Startup Hook | true |
Single-container deployments (Docker Compose) | Fully automatic zero-touch deployments. |
Before rolling out updated application containers, optionally run a schema verification pass and apply migrations in a standalone one-off container:
# 1. (Recommended) Run schema drift verification pass against target DB
docker compose run --rm app alembic check
# 2. Run migrations using the production app container image
docker compose run --rm app alembic upgrade head
# 3. After migrations succeed, deploy/restart the app service
docker compose up -d --no-deps appIf the app container is already running:
docker compose exec app alembic upgrade headCheck current revision inside container:
docker compose exec app alembic currentTo perform zero-downtime updates in production without locking tables or breaking running application instances:
-
Adding Columns:
- Always make new columns
nullable=Trueor supply a server default:sa.Column("custom_label", sa.String(50), nullable=True)
- If a column must ultimately be
nullable=False:- Phase 1 (Expand): Add as nullable. Deploy app code that writes the new field.
- Phase 2 (Backfill): Backfill existing rows with non-null values.
- Phase 3 (Contract): Apply migration
op.alter_column('tasks', 'custom_label', nullable=False).
- Always make new columns
-
Renaming Columns or Tables:
- Never use
op.alter_columnrename in a single release—old running containers will fail immediately. - Use the Expand/Contract pattern:
- Add new column.
- Deploy code writing to both old and new columns.
- Backfill old values into new column.
- Switch code to read from new column.
- Drop old column in a subsequent release.
- Never use
-
Indexes:
- For high-volume production tables, create indexes concurrently to prevent read/write locks:
with op.get_context().autocommit_block(): op.create_index("ix_tasks_custom_search", "tasks", ["title"], postgresql_concurrently=True)
- For high-volume production tables, create indexes concurrently to prevent read/write locks:
If a deployment encounters critical issues and schema changes must be rolled back:
docker compose exec app alembic current# Downgrade by one revision:
docker compose exec app alembic downgrade -1
# Or downgrade to a specific known stable revision ID:
docker compose exec app alembic downgrade 0001docker compose up -d --build appdocker compose logs -f --tail 100 appCause: The database contains tables created before Alembic was introduced, and alembic_version is missing.
Solution:
- DGG-PM's
run_migrations()startup hook automatically detects unversioned tables and stamps the database tohead. - If running manually:
alembic stamp head
Cause: Two developers created branches and generated migrations off the same parent without merging revisions. Solution:
- Check the heads:
alembic heads
- Merge the branches into a single head revision:
alembic merge -m "merge_branch_a_and_branch_b" <rev_id_1> <rev_id_2>
- Apply the merged head:
alembic upgrade head
Cause: DDL or data migration failed mid-script. PostgreSQL wraps DDL in transactions, so failed migrations are rolled back atomically. Solution:
- Fix the error in the migration script.
- Ensure
alembic currentmatches the last successful revision. - Re-run
alembic upgrade head.
| Action | Local Dev (Makefile / uv) | Docker / Production |
|---|---|---|
| Apply all pending migrations | make db-migrate (or uv run alembic upgrade head) |
docker compose run --rm app alembic upgrade head |
| Generate autodetected migration | make db-revision MSG="<msg>" |
alembic revision --autogenerate -m "<msg>" |
| Check for schema drift against ORM | make db-check (or uv run alembic check) |
docker compose run --rm app alembic check |
| Check current database revision | uv run alembic current |
docker compose exec app alembic current |
| Show revision history | uv run alembic history |
docker compose exec app alembic history |
| Rollback one revision | uv run alembic downgrade -1 |
docker compose exec app alembic downgrade -1 |
| Stamp database to head without running DDL | uv run alembic stamp head |
docker compose exec app alembic stamp head |
| Wipe & rebuild clean database | make db-clear |
(Restricted to dev/test environments) |
| Wipe, rebuild & re-seed test data | make db-reset |
(Restricted to dev/test environments) |