1. Declarative Models & Mapped Columns
SQLAlchemy 2.0 introduced a fully typed declarative style built around
DeclarativeBase, Mapped[] and mapped_column().
You still get the same ORM underneath, but every column is now expressed as a
regular Python type annotation that your editor and mypy understand —
no more guessing whether a column returns str or str | None
from a bare Column(String) declaration in the legacy 1.x style.
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Shared base class -- every mapped model inherits from this."""
pass
Every model class inherits from that one Base, and each attribute is
declared as Mapped[SomeType]. The Mapped[] wrapper is what
tells SQLAlchemy's mapper "this attribute is backed by a database column" and gives
type checkers the real Python type; mapped_column() is where you
configure the column-level details — primary key, server default, nullability:
from datetime import datetime
from sqlalchemy import func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
description: Mapped[str | None] = mapped_column(default=None)
is_done: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
Nullability is inferred from the type itself: title: Mapped[str]
becomes a NOT NULL column because str doesn't include
None, while description: Mapped[str | None] becomes
nullable automatically — you rarely need to pass nullable=True
explicitly. server_default=func.now() asks Postgres itself to fill in
the timestamp at insert time, which is more reliable than computing it in Python,
since it stays correct even if a row is inserted through a raw SQL script that
bypasses your application entirely.
Because these are ordinary type annotations, the same Mapped[str | None] vocabulary you used for Pydantic response models in Week 2 now describes your database schema too. A model class becomes a single, honest source of truth for "what shape does a task actually have" — your editor autocompletes task.title correctly, and a typo like task.titel is a type error, not a silent None at runtime.
2. Session Lifecycle as a FastAPI Dependency
A Session is the ORM's unit of work: it tracks every object you load or
add, translates your query calls into SQL over a connection it borrows from a pool,
and stages changes until you explicitly commit(). Creating one starts
with an engine — the object that actually knows how to talk to
Postgres and manages the underlying connection pool — built once at startup from a
connection URL:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_session():
session = SessionLocal()
try:
yield session
finally:
session.close()
get_session() is a generator dependency: FastAPI calls
it, receives the value produced by yield, injects that
Session into your route, and — critically — resumes the generator after
the response has been sent, running whatever comes after yield as
guaranteed cleanup. That's what makes session.close() run for every
request, success or failure, without you having to remember it in each route.
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_session
router = APIRouter(prefix="/tasks", tags=["tasks"])
SessionDep = Annotated[Session, Depends(get_session)]
@router.get("/{task_id}")
def get_task(task_id: int, session: SessionDep):
...
A single module-level Session shared across requests would be a serious
bug, not a shortcut: Session objects are not thread-safe, so two
concurrent requests touching the same instance can corrupt each other's identity map
and issue queries on top of each other's uncommitted changes. Creating a fresh
Session per request — and closing it when the request ends — is what
keeps each request's work isolated and returns its connection to the pool promptly
instead of holding it open indefinitely.
pool_pre_ping=True makes the engine test a pooled connection with a cheap round trip before handing it to your session. Without it, a connection that Postgres or a load balancer silently dropped while idle in the pool surfaces as a confusing "server closed the connection unexpectedly" error on a completely unrelated request.
3. Select, Insert, Update & Transactions
SQLAlchemy 2.0 standardizes on the select() construct for every query,
whether you're using the ORM or writing Core SQL directly — the legacy
session.query(Task) style still works but is considered legacy. You
build a select() statement, then hand it to the session to execute:
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.task import Task
def find_task(session: Session, task_id: int) -> Task | None:
return session.scalar(select(Task).where(Task.id == task_id))
def list_tasks(session: Session) -> list[Task]:
return list(session.scalars(select(Task).order_by(Task.id)))
def create_task(session: Session, title: str, description: str | None) -> Task:
task = Task(title=title, description=description)
session.add(task)
session.commit()
session.refresh(task)
return task
def update_task(session: Session, task: Task, *, is_done: bool) -> Task:
task.is_done = is_done
session.commit()
return task
session.scalar(...) runs the statement and returns a single column
value or None — ideal for "find one row by id." session.scalars(...)
returns every matching row as ORM objects, which is what you iterate over for a list
endpoint. Neither of these touches the database until you actually call it; building
a select() object is just assembling SQL in memory.
session.add(task) doesn't write anything either — it just puts the new
object under the session's tracking. The actual INSERT (or
UPDATE, for changes to already-tracked objects like the
is_done assignment above) happens on flush, which
commit() triggers automatically. commit() is your
transaction boundary: everything the session has staged since the last commit is
written atomically, or none of it is. If anything raises before you reach
commit(), call session.rollback() to discard the staged
changes and leave the database exactly as it was — otherwise the session is left in
a broken, half-flushed state that will raise confusing errors on the next query.
# Quick sanity check against a running Postgres instance
poetry run python -c "
from app.db.session import SessionLocal
from app.repositories.tasks import create_task, find_task
with SessionLocal() as session:
t = create_task(session, 'Ship week 4', None)
print(find_task(session, t.id))
"
4. Hands-on Exercise
Move the Tasks API from memory to PostgreSQL
Take the Week 2–3 Tasks API and give it a real, persistent backing store using everything from this week.
Requirements:
- Add SQLAlchemy 2.0 and a PostgreSQL driver (
psycopg[binary]orasyncpg) to the project, and build the engine from aDATABASE_URLread via config/environment variables — never hardcoded in source. - Define a
Taskmodel withid,title,description: str | None,is_done: bool(defaultFalse) andcreated_atwith a server-side default. - Implement
get_session()as a generator dependency backed by asessionmaker, and inject it into every route withAnnotated[Session, Depends(get_session)]. - Replace every in-memory list operation in the existing list/get/create/update/delete routes with the matching
select(),session.add(), or attribute-assignment-plus-commit()call. - Raise
HTTPException(status_code=404)wheneverfind_taskreturnsNone, and make sure a failed request never leaves a partial commit behind. - Prove persistence: create a task, restart the Uvicorn process, and confirm
GET /tasks/{id}still returns it.
No local Postgres yet? docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=devpass postgres:16 gets you a disposable instance in seconds. Point DATABASE_URL at postgresql+psycopg://postgres:devpass@localhost:5432/postgres and you're unblocked without installing anything system-wide.
5. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why should one HTTP request typically use one short-lived Session?
Why should one HTTP request typically use one short-lived Session?
It gives the request a clear, self-contained transaction boundary — everything the request writes either commits together or rolls back together — and it guarantees the connection the session borrowed is returned to the pool as soon as the request finishes. A session shared across requests has neither guarantee: it isn't thread-safe, so concurrent requests can corrupt its tracked state, and it can hold a pooled connection open indefinitely.
Q2
In id: Mapped[int] = mapped_column(primary_key=True), what does wrapping the type in Mapped[int] give you that a bare id: int annotation would not?
In id: Mapped[int] = mapped_column(primary_key=True), what does wrapping the type in Mapped[int] give you that a bare id: int annotation would not?
Mapped[int] is what tells SQLAlchemy's declarative mapper that this attribute is backed by an actual database column, not just a regular class attribute — a bare id: int would be invisible to the mapper entirely. mapped_column() is the separate piece that configures the column itself (here, marking it the primary key); together they give you a column that's both correctly mapped at runtime and accurately typed for your editor and mypy.
Q3
Why does get_session() use yield instead of return?
Why does get_session() use yield instead of return?
yield turns the function into a generator-based FastAPI dependency: FastAPI runs the code before yield, hands your route the yielded Session, and — once the route has finished and a response is on its way — resumes the generator to run everything after yield as teardown. That's how session.close() in the finally block runs on every request, success or exception, without every route having to remember to call it manually.
Q4
What's the practical difference between title: Mapped[str] and description: Mapped[str | None] on the same model?
What's the practical difference between title: Mapped[str] and description: Mapped[str | None] on the same model?
SQLAlchemy 2.0 infers the column's nullability directly from whether the annotated type includes None. title: Mapped[str] becomes a NOT NULL column at the database level, while description: Mapped[str | None] becomes NULLABLE — both without passing nullable= explicitly to mapped_column(). Get the annotation wrong and the schema Alembic generates next week will be wrong too.