Cheatsheet for the daily model-change workflow.
Standard flow
1. Edit src/myapp/models.py (add column / table)
2. alembic revision --autogenerate -m "msg"
3. Review migrations/versions/<rev>_msg.py
4. alembic upgrade head
5. Update Pydantic schemas (if needed)
6. Update API handlers
7. Update tests
8. uvicorn reload picks up changes
Add a column (full example)
# 1. models.py
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str]
last_login_at: Mapped[datetime | None] = mapped_column( # NEW
DateTime(timezone=True), default=None,
)
# 2. Generate
alembic revision --autogenerate -m "add user.last_login_at"
# 3. Review (migrations/versions/<rev>_add_user_last_login_at.py)
def upgrade():
op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
def downgrade():
op.drop_column("users", "last_login_at")
# 4. Apply
alembic upgrade head
# 5. Update Pydantic
class UserRead(BaseModel):
id: int
email: str
last_login_at: datetime | None = None # NEW
model_config = {"from_attributes": True}
# 6. Handler
@app.post("/login")
async def login(form: OAuth2PasswordRequestForm = Depends(), db = Depends(get_db)):
user = await authenticate(form.username, form.password, db)
user.last_login_at = datetime.utcnow() # NEW
await db.commit()
...
# 7. Test
async def test_login_updates_last_login(client, db_session):
user = UserFactory.build()
db_session.add(user); await db_session.commit()
r = await client.post("/login", data={"username": user.email, "password": "..."})
await db_session.refresh(user)
assert user.last_login_at is not None
Rename a column (two-phase)
Phase 1:
# models.py — add new column
class User(Base):
name: Mapped[str | None] # OLD: still present
full_name: Mapped[str | None] # NEW
alembic revision --autogenerate -m "add full_name (phase 1)"
Manually fix the migration:
def upgrade():
op.add_column("users", sa.Column("full_name", sa.String, nullable=True))
op.execute("UPDATE users SET full_name = name")
Update code to read full_name, write both.
Phase 2 (next deploy):
# models.py — remove old
class User(Base):
full_name: Mapped[str] # NOT NULL now
alembic revision --autogenerate -m "drop user.name (phase 2)"
def upgrade():
op.alter_column("users", "full_name", nullable=False)
op.drop_column("users", "name")
Apply; code only uses full_name.
Adding a relationship
# models.py
class User(Base):
posts: Mapped[list["Post"]] = relationship(back_populates="author")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str]
author: Mapped[User] = relationship(back_populates="posts")
alembic revision --autogenerate -m "add posts table"
alembic upgrade head
Update Pydantic:
class PostCreate(BaseModel):
title: str
class PostRead(BaseModel):
id: int
author_id: int
title: str
model_config = {"from_attributes": True}
Handler:
@app.post("/posts", response_model=PostRead, status_code=201)
async def create_post(
data: PostCreate,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
post = Post(author_id=user.id, title=data.title)
db.add(post)
await db.commit()
await db.refresh(post)
return post
Pydantic-only changes (no migration)
If only the API contract changes (e.g., add a computed_field, rename via alias): no migration.
class UserRead(BaseModel):
id: int
email: str
display_name: str = Field(alias="email") # output only
@computed_field
@property
def is_internal(self) -> bool:
return self.email.endswith("@company.com")
No DB change; no migration.
Adding index
# models.py
class User(Base):
email: Mapped[str] = mapped_column(unique=True, index=True) # add index=True
alembic revision --autogenerate -m "add ix_users_email"
For prod (CONCURRENTLY):
def upgrade():
op.execute("CREATE INDEX CONCURRENTLY ix_users_email ON users (email)")
Dev loop
# Terminal 1: API
uvicorn src.myapp.main:app --reload
# Terminal 2: editor; make changes
# Terminal 3:
alembic revision --autogenerate -m "..."
alembic upgrade head
uvicorn reload picks up Pydantic / handler changes. DB changes need migration.
Common mistakes
- Editing models without generating migration — schema drift; CI catches.
- Migration without app code change — handler still uses old shape.
- Skipping
alembic upgrade headbefore generating — migration includes stale changes. - Forgetting to update Pydantic Read schema — field exists in DB but not in API.
Read this next
If you want my model-change checklist + git hooks, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .