Skip to content

Writing migrations

Alembic is configured with two branches — schema and general (data). See Architecture → Migrations for why, and alembic.ini for the configuration.

Apply migrations

PYTHONPATH=. alembic upgrade heads

The plural heads is important — it advances both branches. The running server does this automatically on startup via the lifespan hook in server/main.py.

Create a schema migration

For DDL changes (new tables, columns, indexes, FKs):

PYTHONPATH=. alembic revision --autogenerate \
  -m "Add foo column to bar" \
  --head=schema@head \
  --version-path=migrations/versions/schema

Alembic diffs the SQLAlchemy models in server/db/models.py against the live schema to produce an autogenerated migration. Always read the generated file before committing — autogenerate misses enum changes, check constraints, and server defaults, and can produce destructive ops that need manual review.

Create a data migration

For seed data, backfills, and enum fixes:

PYTHONPATH=. alembic revision -m "Backfill missing order language"

Without the --head / --version-path flags this lands in migrations/versions/general/ on the data branch. The generated file is effectively empty — you fill in upgrade() and downgrade() by hand. Use raw SQL via op.get_bind() when SQLAlchemy ORM churn would bloat the migration:

from alembic import op

def upgrade() -> None:
    conn = op.get_bind()
    conn.execute("UPDATE orders SET language = 'en' WHERE language IS NULL")

def downgrade() -> None:
    # Irreversible — document why.
    pass

Review checklist

Before merging a migration:

  • Ran locally against a populated shop database (not just a fresh one).
  • Verified alembic upgrade heads succeeds from the previous heads.
  • Verified alembic downgrade to the prior revision if the change is reversible.
  • Checked for accidental cross-branch dependencies (schema depending on data, or vice versa).
  • Added a corresponding SQLAlchemy model change if it's a schema migration.