Alembic Schema Evolution
The db is currently at one revision: the initial migration that created the projects table.
The Task model in the code doesn't have a matching table yet.
We can use alembic to automatically generate a script to catch the database up with the models in our code.
Autogenerate the Migration
alembic revision --autogenerate compares the current state of the database against the SQLModel metadata in code and writes a migration script with the difference.
uv run alembic revision --autogenerate -m "add tasks table"
When this is run:
- Alembic loads
alembic/env.py, which importsrelease_tracker.models. That import is what brings theTaskandProjectclasses intoSQLModel.metadata. - Alembic connects to the database and reads the current schema (so far, just
projects). - The diff (the
taskstable, thetaskstatusandtaskpriorityenum types, the foreign key, the index onproject_id) becomes a new file inalembic/versions/.
The new file looks something like this (the revision ID is generated, so yours will be different):
"""add tasks table
Revision ID: c4a1d3e2b8f7
Revises: f209633e476a
Create Date: 2026-05-06 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision: str = "c4a1d3e2b8f7"
down_revision: Union[str, Sequence[str], None] = "f209633e476a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tasks",
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("details", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column(
"status",
sa.Enum(
"planned", "in_progress", "blocked", "done", name="taskstatus"
),
nullable=False,
),
sa.Column(
"priority",
sa.Enum("low", "medium", "high", "urgent", name="taskpriority"),
nullable=False,
),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("project_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["project_id"],
["projects.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_tasks_project_id"), "tasks", ["project_id"], unique=False
)
def downgrade() -> None:
op.drop_index(op.f("ix_tasks_project_id"), table_name="tasks")
op.drop_table("tasks")
down_revision points at the previous migration's ID, which is how alembic chains revisions into a linear history.
Always Read the Generated Script
Autogenerate is convenient but not magic. Open the file and read it. The things to confirm before applying it:
- Every column in the model is present, with the expected type and nullability.
- Foreign keys point at the right column (
project_id→projects.id). - Indexes match what you specified.
index=Trueonproject_idshould produce a matchingop.create_indexcall. - The downgrade undoes the upgrade. A migration that runs forward but can't run backward is a deployment risk.
Treat the generated script as a draft. Review, edit, then apply.
Apply the Migration
uv run alembic upgrade head
head is the most recent revision in the migration chain. upgrade head applies every pending migration in order. The tasks table now exists in the database, with its enum types, foreign key, and index.
To confirm:
uv run alembic current
That prints the revision ID the database is currently at.
Rolling Back
The downgrade function in each migration is what reverses it.
To roll back the most recent migration:
uv run alembic downgrade -1
The -1 is a relative reference: "one revision behind the current one." The tasks table goes away. Running alembic upgrade head again puts it back.
Migrations live in version control
Every migration file under alembic/versions/ is a permanent part of the project history. Don't edit a migration after it's been applied to a shared environment. If you need to change the schema again, generate a new migration on top.
What env.py Does for You
alembic/env.py was wired up earlier to import release_tracker.models. That's the line that lets autogenerate see the Task model:
# Import our models so Alembic can see them!
from release_tracker import models # noqa: F401
If a model isn't reachable from this import, autogenerate won't know it exists, and the generated migration will skip it silently.
The # noqa: F401 is there because the import looks unused to ruff, but it is needed because importing the module is what registers the table on SQLModel.metadata.