30–40 min read · Hands-on
Your AI backend ships, users store conversations, and a week later you need to add a cost_usd column to the messages table. How do you add that column to the live production database without deleting the existing data?
Base.metadata.create_all() from Lesson 22 cannot help — it only creates tables that don’t exist yet. Manually running ALTER TABLE SQL against production is dangerous and untracked. Database migrations are the answer: version-controlled, repeatable, reversible SQL changes that you can apply in order on any environment. Alembic is the migration tool for SQLAlchemy — it is the standard for Python AI backends, and it is expected knowledge in AI engineering roles that touch production databases.
A migration is a Python script that describes one specific change to a database schema — adding a column, dropping a table, changing a column type. Each migration has two functions:
upgrade() — applies the change (e.g. adds the column)downgrade() — reverses it (e.g. drops the column)Alembic keeps track of which migrations have been applied in a special table in your database (alembic_version). When you run alembic upgrade head, it applies only the migrations that haven’t been applied yet — in the correct order.
This gives you three things development-only tools like create_all cannot provide:
uv add alembic
Initialise Alembic in your project:
alembic init alembic
This creates an alembic/ directory and an alembic.ini file:
your-project/
├── alembic/
│ ├── env.py # migration environment configuration
│ ├── versions/ # individual migration files go here
│ └── script.py.mako # template for new migration files
├── alembic.ini # Alembic configuration
├── main.py
└── database.py
Open alembic.ini and find the sqlalchemy.url line. Replace it with a reference to an environment variable (never hardcode credentials in a committed file):
# alembic.ini
sqlalchemy.url = %(DATABASE_URL)s
Then in alembic/env.py, read the variable from the environment:
import os
from dotenv import load_dotenv
load_dotenv()
# Override the sqlalchemy.url with the env var
config.set_main_option("sqlalchemy.url",
os.getenv("DATABASE_URL", "postgresql+psycopg2://postgres:password@localhost:5432/aidb")
)
psycopg2 or the newer psycopg) for the Alembic connection URL, and keep the async URL (asyncpg) only for the application's runtime connection. Install: uv add psycopg2-binary.
Alembic’s autogenerate feature compares your SQLAlchemy models against the live database and generates the SQL to make them match. To enable this, open alembic/env.py and add your models’ metadata:
from database import Base # your SQLAlchemy Base
# ... existing env.py code ...
target_metadata = Base.metadata # ← replace "target_metadata = None" with this
The first migration creates all your tables. Run autogenerate, which compares your models to the empty database and generates the SQL:
# Generate a migration script called "create_initial_tables"
alembic revision --autogenerate -m "create_initial_tables"
This creates a file in alembic/versions/ like abc123def456_create_initial_tables.py. Open it and read what it generated — always review autogenerated migrations before running them:
"""create initial tables
Revision ID: abc123def456
Revises:
Create Date: 2026-01-15 14:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
def upgrade() -> None:
op.create_table(
'conversations',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('model', sa.String(100), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_table(
'messages',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('conversation_id', sa.Integer(), nullable=False),
sa.Column('role', sa.String(20), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('tokens_used', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=True),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
def downgrade() -> None:
op.drop_table('messages')
op.drop_table('conversations')
Apply the migration:
alembic upgrade head
head means “apply all unapplied migrations up to the latest one”. Check the database — the tables now exist.
Two weeks later, you want to track the cost in USD of each message. Add the column to your SQLAlchemy model first:
class Message(Base):
# ... existing columns ...
cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
Then generate a new migration:
alembic revision --autogenerate -m "add_cost_usd_to_messages"
Alembic compares the model definition against the current database schema and generates only the diff — in this case, an ALTER TABLE:
def upgrade() -> None:
op.add_column('messages', sa.Column('cost_usd', sa.Float(), nullable=True))
def downgrade() -> None:
op.drop_column('messages', 'cost_usd')
Apply it:
alembic upgrade head
| Command | What it does |
|---|---|
alembic revision --autogenerate -m "name" |
Generate a new migration by diffing models against the DB |
alembic revision -m "name" |
Create an empty migration to fill in manually |
alembic upgrade head |
Apply all unapplied migrations |
alembic upgrade +1 |
Apply the next migration only |
alembic downgrade -1 |
Undo the last applied migration |
alembic downgrade base |
Undo all migrations (empty DB) |
alembic current |
Show which migration the DB is currently at |
alembic history |
Show the full migration history |
alembic show <revision_id> |
Show the SQL a migration would run |
Once Alembic is set up, the workflow for every schema change is the same:
alembic revision --autogenerate -m "description"alembic upgrade head against your dev databasealembic upgrade head as part of your deploy processalembic revision -m "name" and fill in the op.* calls.
In a Dockerised setup, you run migrations before starting the application. The simplest approach is an entrypoint script:
#!/bin/bash
# entrypoint.sh
set -e
echo "Running database migrations..."
alembic upgrade head
echo "Starting application..."
exec uvicorn main:app --host 0.0.0.0 --port 8000
Update the Dockerfile to use it:
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
CMD ["./entrypoint.sh"]
Now every time the container starts, it first applies any pending migrations, then starts the server. This is safe because Alembic’s migration tracking is idempotent — running upgrade head when already at head does nothing.
alembic init alembic and configure env.py to use your DATABASE_URL env var and your Base.metadataalembic revision --autogenerate -m "create_initial_tables"conversations and messages tablesalembic upgrade headcost_usd float column (nullable) to your Message model, generate a second migration, and apply italembic history to confirm both migrations are listedalembic downgrade -1, confirm the column is gone, then alembic upgrade head to re-applyalembic/versions/, both applied. alembic current shows the latest revision.
Every production AI backend uses database migrations. Running through add, apply, rollback, re-apply builds the muscle memory for the workflow before you need it under pressure.
Answer each question from memory, then click to reveal.
--autogenerate compares your SQLAlchemy models against the current database schema and generates the migration code automatically. Plain alembic revision creates an empty migration file with blank upgrade() and downgrade() functions that you fill in manually — useful for changes autogenerate cannot detect (renames, custom SQL, etc.).
Alembic’s migration runner is synchronous — it does not use asyncio. Asyncpg requires an async event loop to operate. Using asyncpg in a synchronous context would crash. Alembic uses a separate connection URL (with a sync driver) only for applying migrations; your FastAPI application keeps its own async connection URL for runtime queries.
→ asyncpg vs psycopg2Nothing — Alembic checks the alembic_version table in the database, sees that the current revision matches the latest migration file, and exits with “No migrations to apply.” This makes it safe to run upgrade head on every container startup without worrying about double-applying migrations.