Skip to content

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.

shell
uv run alembic revision --autogenerate -m "add tasks table"

When this is run:

  1. Alembic loads alembic/env.py, which imports release_tracker.models. That import is what brings the Task and Project classes into SQLModel.metadata.
  2. Alembic connects to the database and reads the current schema (so far, just projects).
  3. The diff (the tasks table, the taskstatus and taskpriority enum types, the foreign key, the index on project_id) becomes a new file in alembic/versions/.

The new file looks something like this (the revision ID is generated, so yours will be different):

alembic/versions/c4a1d3e2b8f7_add_tasks_table.py
"""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:

  1. Every column in the model is present, with the expected type and nullability.
  2. Foreign keys point at the right column (project_idprojects.id).
  3. Indexes match what you specified. index=True on project_id should produce a matching op.create_index call.
  4. 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

shell
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:

shell
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:

shell
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:

alembic/env.py
# 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.