Alembic Migrations
alembic upgrade head is one of the first commands run after every deployment โ it applies any schema changes to the live database.
The Problem Migrations Solve
Imagine you have a production database with 10,000 real bookings. You need to add a new column cancelled_reason to the bookings table. How do you do it safely?
- You can't just edit your Python model โ the actual database table doesn't change
- You can't drop the table and recreate it โ you'd lose all 10,000 bookings
- You need to run
ALTER TABLE bookings ADD COLUMN cancelled_reason TEXT - And you need to track whether this change has already been applied (don't run it twice)
Migrations are version-controlled SQL scripts that describe how to change a database schema from one version to the next. Alembic manages this for SQLAlchemy projects.
How Alembic Tracks What's Applied
Alembic creates a table called alembic_version in your database with a single column: the current migration version ID. When you run alembic upgrade head, it:
- Reads the version from
alembic_version - Finds all migration files newer than that version
- Runs their
upgrade()functions in order - Updates
alembic_versionto the latest
Migration chain (each has an ID):
None โโโบ a1b2c3d4e5f6 โโโบ b2c3d4e5f6a1 โโโบ c3d4e5f6a1b2
(initial schema) (add index) (add column)
โฒ
โ alembic_version currently = "a1b2c3d4e5f6"
โ
โโ alembic upgrade head โ runs b2c... then c3d..., updates version
The Project's Migration File
There's one migration in this project โ the "initial schema" that creates all tables at once. From migrations/versions/a1b2c3d4e5f6_initial_schema.py:
"""initial schema
Revision ID: a1b2c3d4e5f6
Revises: # None = this is the first migration
Create Date: 2026-06-21
"""
import sqlalchemy as sa
from alembic import op
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = None # what version this builds on
def upgrade() -> None:
# Creates the shops table
op.create_table(
"shops",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("slug", sa.String(100), nullable=False),
sa.Column("name_th", sa.String(200), nullable=False),
sa.Column("name_en", sa.String(200), nullable=False),
sa.Column("category", sa.String(50), nullable=False),
sa.Column("slot_increment_min", sa.Integer(), nullable=False, server_default="30"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
# ... more create_table calls for resources, services, bookings, etc.
def downgrade() -> None:
# Reverses upgrade() โ drops tables in reverse order
op.drop_table("bookings")
op.drop_table("resource_services")
# ... etc
Key Commands
# Apply all pending migrations (most common command)
docker compose exec fastapi alembic upgrade head
# Roll back the last migration
alembic downgrade -1
# See current migration version
alembic current
# See migration history
alembic history
# Generate a new migration from model changes (autogenerate)
alembic revision --autogenerate -m "add cancelled_reason column"
# Apply a specific revision
alembic upgrade a1b2c3d4e5f6
How Autogenerate Works
When you run alembic revision --autogenerate, Alembic:
- Imports your SQLAlchemy models (by reading
env.py) - Compares the models to the current database schema
- Generates an
upgrade()with the differences (new columns, tables, etc.) - Generates a
downgrade()that reverses them
Async Migrations
Because the project uses async SQLAlchemy, the env.py has an async migration path:
async def run_async_migrations():
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online():
asyncio.run(run_async_migrations()) # starts event loop just for migrations
๐ง Self-Check Quiz
1. You modify a Python model to add a new column. Does the database table automatically gain that column?
--autogenerate) and run alembic upgrade head. Without this, the database doesn't have the new column, and any attempt to insert a value into it will fail.2. What does alembic upgrade head do and what is "head"?
upgrade head applies all migrations that haven't been applied yet, in order, until the database is at the most recent version. If the database is already at head, the command does nothing. It reads alembic_version table to know where it currently is.3. What does the downgrade() function do and when would you use it?
upgrade() did (drops tables, removes columns, etc.). You'd use alembic downgrade -1 if a migration was applied by mistake and you need to roll back. In production, downgrade is risky if data has already been written to the new columns โ use with caution and always back up first.4. Why does the project only have one migration file (the "initial schema") instead of many?
5. Why can't alembic revision --autogenerate detect a column rename?
op.alter_column() or op.rename_column().