Stop relying on create_all. Record every change to the database as a versioned, reversible step, and apply it the same way on every machine.
Where StudyDesk is now
src/studydesk/
├── __init__.py ← lifespan runs Base.metadata.create_all()
├── models.py ← Student, User, Job
└── ...
Dockerfile, docker-compose.yml
Tables are created by create_all at startup. By the end of this article, the schema is managed by Alembic: an alembic/ folder with a baseline migration and a second migration that adds a phone column to students, applied with alembic upgrade head on your laptop and inside Docker, and create_all is gone.
Part 1: The Column That Never Appeared
The office wants a phone number for each student. Aman adds a line to models.py:
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
He restarts the API. POST /students with a phone → 500:
psycopg.errors.UndefinedColumn: column "phone" of relation "students" does not exist
The model has the column. The database does not. create_all ran at startup and did nothing, because the students table already existed. create_all only creates missing tables; it never alters existing ones.
"Drop the table and let it recreate," Aman says. "On production?" asks Neha. "With three hundred real students?"
That is the problem migrations solve. A migration is a small script that changes the database from one version to the next (ALTER TABLE students ADD COLUMN phone ...) and knows how to undo itself. Migrations are kept in version control, numbered, and applied in order. Every environment — your laptop, Karan's laptop, the Docker container, production — runs the same scripts and ends up with the same schema, without losing a row.
Part 2: What Alembic Is
Alembic is the migration tool made by the SQLAlchemy authors. The pieces:
| Piece | What it is |
|---|---|
alembic.ini |
Config file at the project root |
alembic/env.py |
A Python file that tells Alembic how to connect and which models to look at |
alembic/versions/ |
One Python file per migration, each with upgrade() and downgrade() |
alembic_version table |
A tiny table Alembic creates in your database to remember which migration is currently applied |
The workflow:
- Change a model in
models.py. alembic revision --autogenerate -m "describe the change"— Alembic compares your models to the live database and writes a migration file for the difference.- Read the generated file. Fix anything it got wrong.
alembic upgrade head— apply it.- Commit the migration file with your code.
Everyone else pulls and runs alembic upgrade head. Done.
Part 3: Setting Up Alembic
Install and initialise
uv add alembic
uv run alembic init alembic
The second command creates:
studydesk/
├── alembic.ini
└── alembic/
├── env.py
├── README
├── script.py.mako ← template for new migration files
└── versions/ ← empty for now
alembic.ini
The generated file is long and mostly comments. Only one line matters, and we leave it blank on purpose, because the database URL must come from .env, not from a file in Git:
sqlalchemy.url =
Find that line (it will have a placeholder like driver://user:pass@localhost/dbname) and clear the value. Everything else can stay as generated.
alembic/env.py
This is the file to rewrite. The generated version has many commented-out options; here is the full, clean version for StudyDesk.
alembic/env.py
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from studydesk.config import DATABASE_URL
from studydesk.database import Base
from studydesk import models # noqa: F401 (imported so every table registers on Base)
# Alembic Config object: gives access to values in alembic.ini
config = context.config
# Set the database URL from our .env, not from alembic.ini
config.set_main_option("sqlalchemy.url", DATABASE_URL)
# Set up Python logging from alembic.ini
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# The models Alembic compares the database against
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Generate SQL without connecting (alembic upgrade head --sql)."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Connect to the database and run migrations."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
Reading it
from studydesk.config import DATABASE_URL then config.set_main_option("sqlalchemy.url", DATABASE_URL) The URL is injected from .env at run time. alembic.ini stays secret-free.
from studydesk import models # noqa: F401 Importing models runs its class definitions, which registers Student, User, and Job on Base.metadata. Without this import, Base.metadata would be empty and autogenerate would think every table should be dropped. The # noqa: F401 comment tells Ruff "yes, this import looks unused, that is intentional".
target_metadata = Base.metadata What Alembic compares against the live database.
compare_type=True Also detect column type changes (e.g. String(50) → String(100)), not only added or removed columns.
Offline vs online — generated boilerplate. Online connects and runs; offline prints the SQL instead. We use online.
Why studydesk is importable here
alembic/env.py lives at the project root, outside src/. It can still import studydesk because uv installed our package into .venv (Article 1). Always run Alembic through uv run so that venv is active.
Part 4: The Baseline Migration
The database already has three tables made by create_all. Alembic knows nothing about them yet. We need a baseline: a first migration that describes the current state.
Generate it
uv run alembic revision --autogenerate -m "baseline: students, users, jobs"
Alembic connects, finds that Base.metadata has three tables and the database has... the same three tables. It would normally generate an empty migration. We want a migration that creates them, so that a brand-new database (Karan's laptop, a CI runner, production) can be built from zero. Easiest way: generate against an empty database.
Create a scratch database just for this step:
psql "postgresql://postgres:postgres@localhost:5432/postgres" -c "CREATE DATABASE studydesk_scratch;"
Then run autogenerate pointing at it, by overriding the URL for one command:
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/studydesk_scratch \
uv run alembic revision --autogenerate -m "baseline: students, users, jobs"
(Windows PowerShell: $env:DATABASE_URL="postgresql+psycopg://postgres:postgres@localhost:5432/studydesk_scratch"; uv run alembic revision --autogenerate -m "baseline: students, users, jobs" then Remove-Item Env:DATABASE_URL.)
Output:
INFO [alembic.autogenerate.compare] Detected added table 'jobs'
INFO [alembic.autogenerate.compare] Detected added table 'students'
INFO [alembic.autogenerate.compare] Detected added table 'users'
Generating .../alembic/versions/a1b2c3d4e5f6_baseline_students_users_jobs.py ... done
Drop the scratch database; its only job was to be empty:
psql "postgresql://postgres:postgres@localhost:5432/postgres" -c "DROP DATABASE studydesk_scratch;"
Read the generated file
Your revision ID will differ; the content should match this closely.
alembic/versions/a1b2c3d4e5f6_baseline_students_users_jobs.py
"""baseline: students, users, jobs
Revision ID: a1b2c3d4e5f6
Revises:
Create Date: 2026-10-31 18:40:12.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"jobs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("kind", sa.String(length=50), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("result", sa.JSON(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("max_attempts", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_jobs_kind"), "jobs", ["kind"], unique=False)
op.create_index(op.f("ix_jobs_status"), "jobs", ["status"], unique=False)
op.create_table(
"students",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("age", sa.Integer(), nullable=False),
sa.Column("city", sa.String(length=50), nullable=False),
sa.Column("marks", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_students_email"), "students", ["email"], unique=True)
op.create_table(
"users",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("hashed_password", sa.String(length=255), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")
op.drop_index(op.f("ix_students_email"), table_name="students")
op.drop_table("students")
op.drop_index(op.f("ix_jobs_status"), table_name="jobs")
op.drop_index(op.f("ix_jobs_kind"), table_name="jobs")
op.drop_table("jobs")
Reading a migration file
revision / down_revision Every migration has a unique ID and points to the one before it. down_revision = None means this is the first. Together they form a chain, and head is the newest link.
upgrade() Operations to move the database forward. op.create_table, op.create_index, op.add_column, and so on. op is Alembic's toolbox of schema operations.
downgrade() The exact reverse, so alembic downgrade -1 can undo this step. Autogenerate writes both.
Always read what autogenerate produced. It is reliable for adding and removing tables and columns. It cannot see Python-side default= values (only server_default), it sometimes misses renames (it sees "drop column X, add column Y"), and it cannot write data migrations ("fill the new column from the old one"). The file is a draft; you own it.
Tell the existing database it is already at this version
Your local studydesk database already has the three tables. Running upgrade would try to create them again and fail. Instead, stamp it: record in alembic_version that the baseline is already applied, without running it.
uv run alembic stamp head
INFO [alembic.runtime.migration] Running stamp_revision -> a1b2c3d4e5f6
Check:
uv run alembic current
a1b2c3d4e5f6 (head)
A new, empty database would instead run uv run alembic upgrade head and get all three tables from the migration. Both paths end at the same version.
Part 5: Removing create_all
Alembic owns the schema now. Two places still call create_all; both go.
src/studydesk/__init__.py
import logging
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import func, select
from .config import ALLOWED_ORIGINS, HOST, PORT, RELOAD
from .database import DbSession
from .models import Student
from .routers import auth, jobs, students
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Schema is managed by Alembic (run `alembic upgrade head` before starting).
yield
app = FastAPI(
title="StudyDesk API",
description="Student records API for a coaching institute.",
version="0.5.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(students.router)
app.include_router(jobs.router)
@app.get("/", tags=["General"])
def home():
return {"message": "Welcome to StudyDesk API"}
@app.get("/db-check", tags=["General"], summary="Check the database connection")
def db_check(db: DbSession):
total = db.scalar(select(func.count()).select_from(Student))
return {"database": "ok", "students_in_db": total}
def main() -> None:
"""Start the server. Host, port and reload come from the environment."""
uvicorn.run("studydesk:app", host=HOST, port=PORT, reload=RELOAD)
The lifespan is kept (empty) so there is an obvious place for future startup work.
src/studydesk/seed.py
import json
from pathlib import Path
from .config import ADMIN_EMAIL, ADMIN_PASSWORD
from .database import SessionLocal
from .models import Student, User
from .security import hash_password
DATA_FILE = Path(__file__).parent / "data" / "students.json"
def seed_students(db) -> None:
if db.query(Student).count() > 0:
print("students: table already has data, skipping.")
return
with open(DATA_FILE, "r", encoding="utf-8") as f:
records = json.load(f)
for record in records:
record.pop("grade", None)
db.add(Student(**record))
db.commit()
print(f"students: inserted {len(records)}.")
def seed_admin(db) -> None:
if not ADMIN_EMAIL or not ADMIN_PASSWORD:
print("admin: ADMIN_EMAIL / ADMIN_PASSWORD not set, skipping.")
return
existing = db.query(User).filter(User.email == ADMIN_EMAIL.lower()).first()
if existing is not None:
print("admin: already exists, skipping.")
return
db.add(
User(
email=ADMIN_EMAIL.lower(),
hashed_password=hash_password(ADMIN_PASSWORD),
role="admin",
)
)
db.commit()
print(f"admin: created {ADMIN_EMAIL}.")
def seed() -> None:
db = SessionLocal()
try:
seed_students(db)
seed_admin(db)
finally:
db.close()
if __name__ == "__main__":
seed()
The seed now assumes the tables exist. Order on a fresh machine: alembic upgrade head, then python -m studydesk.seed.
Part 6: The Real Change — Adding phone
Now the change that started all this.
Step 1: Change the model
src/studydesk/models.py
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from .database import Base
class Student(Base):
__tablename__ = "students"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
age: Mapped[int] = mapped_column(Integer, nullable=False)
city: Mapped[str] = mapped_column(String(50), nullable=False)
marks: Mapped[int] = mapped_column(Integer, nullable=False)
def __repr__(self) -> str:
return f"<Student id={self.id} name={self.name!r} marks={self.marks}>"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="staff")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
def __repr__(self) -> str:
return f"<User id={self.id} email={self.email!r} role={self.role}>"
class Job(Base):
__tablename__ = "jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
kind: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True)
result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
def __repr__(self) -> str:
return f"<Job id={self.id} kind={self.kind} status={self.status}>"
nullable=True is important. The table already has rows. If the new column were NOT NULL with no default, PostgreSQL would refuse to add it ("column contains null values"). New columns on existing tables are almost always nullable or have a server_default.
Step 2: Generate the migration
uv run alembic revision --autogenerate -m "add phone to students"
INFO [alembic.autogenerate.compare] Detected added column 'students.phone'
Generating .../alembic/versions/b7c8d9e0f1a2_add_phone_to_students.py ... done
Step 3: Read it
alembic/versions/b7c8d9e0f1a2_add_phone_to_students.py
"""add phone to students
Revision ID: b7c8d9e0f1a2
Revises: a1b2c3d4e5f6
Create Date: 2026-10-31 18:55:40.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "b7c8d9e0f1a2"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("students", sa.Column("phone", sa.String(length=20), nullable=True))
def downgrade() -> None:
op.drop_column("students", "phone")
Exactly the ALTER TABLE we wanted, and down_revision points at the baseline. The chain is now None → a1b2c3 → b7c8d9.
Step 4: Apply it
uv run alembic upgrade head
INFO [alembic.runtime.migration] Running upgrade a1b2c3d4e5f6 -> b7c8d9e0f1a2, add phone to students
Check in psql:
\d students
The phone column is there. SELECT count(*) FROM students; → still 10. No data lost.
Step 5: Try the undo
uv run alembic downgrade -1
The column disappears. uv run alembic current shows the baseline. Then bring it back:
uv run alembic upgrade head
That round trip is the point: every change is reversible and recorded.
Part 7: Exposing phone Through the API
The database has the column; the API must accept and return it.
src/studydesk/schemas.py
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, computed_field, field_validator
def clean_text(value: str) -> str:
"""Strip spaces, reject digits, convert to Title Case."""
value = value.strip()
if any(char.isdigit() for char in value):
raise ValueError("must not contain digits")
return value.title()
# ---------- students ----------
class StudentBase(BaseModel):
"""Fields shared by every student shape."""
name: str = Field(min_length=2, max_length=50, description="Full name", examples=["Priya Sharma"])
email: EmailStr = Field(description="Email address", examples=["priya@example.com"])
phone: str | None = Field(
default=None,
pattern=r"^\+?[0-9]{10,15}$",
description="Phone number, digits only, optional + prefix",
examples=["+919876543210"],
)
age: int = Field(ge=5, le=100, description="Age in years", examples=[17])
city: str = Field(min_length=2, max_length=50, description="City", examples=["Ahmedabad"])
marks: int = Field(ge=0, le=100, description="Marks out of 100", examples=[88])
@field_validator("name", "city")
@classmethod
def validate_text(cls, value: str) -> str:
return clean_text(value)
class StudentCreate(StudentBase):
"""Input schema for POST /students."""
class StudentUpdate(BaseModel):
"""Input schema for PUT /students/{id}. Every field is optional."""
name: str | None = Field(default=None, min_length=2, max_length=50, examples=["Priya Sharma"])
email: EmailStr | None = Field(default=None, examples=["priya@example.com"])
phone: str | None = Field(default=None, pattern=r"^\+?[0-9]{10,15}$", examples=["+919876543210"])
age: int | None = Field(default=None, ge=5, le=100, examples=[17])
city: str | None = Field(default=None, min_length=2, max_length=50, examples=["Ahmedabad"])
marks: int | None = Field(default=None, ge=0, le=100, examples=[88])
@field_validator("name", "city")
@classmethod
def validate_text(cls, value: str | None) -> str | None:
if value is None:
return value
return clean_text(value)
class StudentOut(StudentBase):
"""Output schema: what every student response looks like."""
model_config = ConfigDict(from_attributes=True)
id: int = Field(gt=0, description="Unique student ID", examples=[1])
@computed_field
@property
def grade(self) -> str:
"""Letter grade calculated from marks."""
if self.marks >= 85:
return "A"
if self.marks >= 70:
return "B"
if self.marks >= 50:
return "C"
return "D"
class StudentListOut(BaseModel):
"""Output schema for GET /students."""
total: int
total_pages: int
offset: int
limit: int
count: int
students: list[StudentOut]
# ---------- users and auth ----------
class UserCreate(BaseModel):
"""Input schema for POST /auth/register."""
email: EmailStr = Field(examples=["ritu@studydesk.com"])
password: str = Field(min_length=8, max_length=128, examples=["a-strong-password"])
class LoginRequest(BaseModel):
"""Input schema for POST /auth/login."""
email: EmailStr = Field(examples=["ritu@studydesk.com"])
password: str = Field(min_length=1, max_length=128, examples=["a-strong-password"])
class UserOut(BaseModel):
"""Output schema for a user. Never includes the password hash."""
model_config = ConfigDict(from_attributes=True)
id: int
email: EmailStr
role: Literal["staff", "admin"]
is_active: bool
created_at: datetime
class Token(BaseModel):
"""Output schema for POST /auth/login."""
access_token: str
token_type: str = "bearer"
# ---------- jobs ----------
class JobOut(BaseModel):
"""Output schema for a background job."""
model_config = ConfigDict(from_attributes=True)
id: int
kind: str
status: Literal["pending", "running", "done", "failed"]
payload: dict
result: dict | None
error: str | None
attempts: int
max_attempts: int
created_at: datetime
started_at: datetime | None
finished_at: datetime | None
phone is optional everywhere, with a simple pattern: optional +, then 10 to 15 digits. crud.py and the router need no changes: Student(**payload.model_dump()) picks up the new field automatically, and setattr in update_student handles it.
Restart, log in, PUT /students/1 with {"phone": "+919876543210"} → 200, phone in the response. Existing students show "phone": null.
Part 8: Migrations in Docker
The container must run alembic upgrade head before the API starts, and the migration files must be inside the image.
Dockerfile
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /app
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
UV_PYTHON_DOWNLOADS=never \
UV_COMPILE_BYTECODE=1
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project
COPY src ./src
COPY alembic.ini ./
COPY alembic ./alembic
RUN uv sync --frozen --no-dev
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["studydesk"]
Two new COPY lines. The .dockerignore from Article 9 needs no change.
docker-compose.yml
services:
db:
image: postgres:16
container_name: studydesk-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: studydesk
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d studydesk"]
interval: 5s
timeout: 5s
retries: 10
migrate:
build: .
container_name: studydesk-migrate
command: ["alembic", "upgrade", "head"]
env_file:
- .env
environment:
DATABASE_URL: postgresql+psycopg://postgres:postgres@db:5432/studydesk
depends_on:
db:
condition: service_healthy
api:
build: .
container_name: studydesk-api
env_file:
- .env
environment:
DATABASE_URL: postgresql+psycopg://postgres:postgres@db:5432/studydesk
HOST: 0.0.0.0
PORT: "8000"
RELOAD: "false"
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
worker:
build: .
container_name: studydesk-worker
command: ["studydesk-worker"]
env_file:
- .env
environment:
DATABASE_URL: postgresql+psycopg://postgres:postgres@db:5432/studydesk
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
volumes:
postgres_data:
Reading the change
migrate service A one-shot container: same image, runs alembic upgrade head, then exits. Because .env is loaded, env.py gets DATABASE_URL from the environment just as it would on your laptop (the Compose environment: override points it at db).
condition: service_completed_successfully The API and worker wait until the migration container has finished with exit code 0. If a migration fails, the API never starts with a half-migrated schema. This replaces create_all in the startup order.
If the Docker database was created in Article 9
That volume already has tables from create_all, so the first migrate run would fail trying to create them. Stamp it once, the same way as locally:
docker compose up -d db
docker compose run --rm migrate alembic stamp a1b2c3d4e5f6
docker compose up --build
(Use your real baseline revision ID.) migrate then applies only the phone migration. A fresh volume (docker compose down -v first) needs no stamp; it runs both migrations from zero.
Part 9: The Everyday Commands
| Command | What it does |
|---|---|
uv run alembic current |
Which revision the database is at |
uv run alembic history |
The whole chain, oldest to newest |
uv run alembic revision --autogenerate -m "msg" |
Draft a migration from model changes |
uv run alembic revision -m "msg" |
Empty migration, for hand-written changes (data fixes, renames) |
uv run alembic upgrade head |
Apply everything not yet applied |
uv run alembic upgrade +1 / downgrade -1 |
One step forward / back |
uv run alembic downgrade base |
Undo everything (drops all tables) |
uv run alembic stamp head |
Mark as applied without running |
Rules the team follows
- Never edit a migration that has been applied anywhere else. Write a new one.
- Autogenerate, then read. Especially check
downgrade()and anything involving renames or defaults. - New columns on existing tables are nullable or have a
server_default. - Migration files are committed with the model change, in the same commit.
alembic upgrade headruns before the app starts, everywhere.
Where StudyDesk is after this article
studydesk/
├── alembic.ini
├── alembic/
│ ├── env.py ← reads DATABASE_URL from .env, targets Base.metadata
│ ├── script.py.mako
│ └── versions/
│ ├── a1b2c3d4e5f6_baseline_students_users_jobs.py
│ └── b7c8d9e0f1a2_add_phone_to_students.py
├── Dockerfile ← copies alembic/
├── docker-compose.yml ← migrate service
└── src/studydesk/
├── __init__.py ← no create_all
├── models.py ← Student.phone
├── schemas.py ← phone in Base, Update, Out
├── seed.py ← no create_all
└── ...
On a fresh machine:
cp .env.example .env # fill in values
uv sync
uv run alembic upgrade head
uv run python -m studydesk.seed
uv run studydesk
Concept Quiz
Answers are below. No coding needed.
Q1. Why did adding phone to models.py not add it to the database?
Q2. What is a migration, and what two functions does every migration file contain?
Q3. What is the alembic_version table for?
Q4. Why do we leave sqlalchemy.url empty in alembic.ini?
Q5. Why must env.py import studydesk.models even though it never uses the name?
Q6. What does alembic stamp head do, and when is it the right command instead of upgrade?
Q7. Why was the phone column declared nullable=True?
Q8. Name two things autogenerate cannot do reliably.
Q9. In Compose, what does condition: service_completed_successfully on the migrate dependency guarantee?
Q10. A teammate edited an already-applied migration file to fix a mistake. Why is that a problem, and what should they do instead?
Answers
create_allonly creates tables that are missing. Thestudentstable already existed, so it was left untouched; it never alters existing tables.- A versioned script that changes the schema from one state to the next.
upgrade()applies the change;downgrade()reverses it. - It stores the ID of the currently applied migration so Alembic knows where the database is in the chain and which migrations still need to run.
- The URL contains a password.
env.pyinjects it from.envat run time so no secret is committed. - Importing the module runs the class definitions, which register the tables on
Base.metadata. Without it,target_metadatais empty and autogenerate would propose dropping every table. - It writes the revision ID into
alembic_versionwithout running the migration. Use it when the database already has the schema (created bycreate_all) and only needs to be marked as up to date. - The table already had rows. A
NOT NULLcolumn with no default cannot be added to a non-empty table because existing rows would violate it. - Detect renames (it sees drop + add), see Python-side
default=values, and write data migrations that transform existing rows. - The API and worker start only after the migration container exits with code 0. A failed migration prevents the app from starting against a broken schema.
- Other environments already applied the old version; they will never re-run it, so the fix never reaches them, and their databases diverge. Write a new migration that corrects the mistake.
Epilogue
The phone column lands on Aman's laptop, in the Docker volume, and on Karan's machine, with ten students intact in each. The change is a file in Git with a timestamp and a reason, and it can be undone with one command.
Neha looks at the growing project. "Students, users, jobs, auth, a worker, migrations. How do you know none of it broke when you added phone?" "I clicked through /docs." "All twelve routes? With the admin and the staff user? Every time?"
In the next article: pytest and API testing — a test database, fixtures for sessions and logged-in users, and a suite that checks every route in seconds.
Comments
Join the discussionNo comments yet. Be the first to comment.