Files
fastapi-conventions/patterns/persistence.md
T
2026-06-01 21:42:05 +00:00

4.1 KiB

Persistence

Keep session lifetime and write boundaries explicit. Route handlers can orchestrate straightforward database work, but persistence concerns should stay visible instead of hiding behind ambient sessions or magical commits.

Why this convention exists

FastAPI apps often stay clean until persistence gets sloppy. Then you get:

  • hidden session creation inside helpers
  • unclear transaction boundaries
  • handlers packed with ORM choreography
  • tests that only pass because the database layer was mocked out of existence

The stronger pattern in mature examples is straightforward:

  • sessions arrive through dependencies
  • read and write boundaries are visible in the calling flow
  • sync and async persistence are deliberate choices
  • tests exercise real session lifetime and cleanup

The convention

  1. Provide sessions through dependencies.
  2. Keep transaction and commit boundaries visible.
  3. Make sync versus async persistence an explicit stack decision.
  4. Let routes do simple persistence orchestration, but move reusable business rules out once the flow stops being trivial.
  5. Test against real persistence seams when practical.

Follow it when stored state matters

Typical fits:

  • routes that read or write the database
  • flows where transaction scope matters
  • auth-aware queries
  • cleanup-sensitive tests

Avoid these mistakes

Do not invent a repository or service layer for every one-line query.

Do not hide session creation deep inside helper functions.

Do not force async persistence just because the web layer is async. Choose sync or async deliberately based on the real stack.

Preferred shapes

Session through dependency

def get_db() -> Generator[Session, None, None]:
    with Session(engine) as session:
        yield session

This makes resource lifetime explicit and testable.

Visible write boundary

item = Item.model_validate(item_in, update={"owner_id": current_user.id})
session.add(item)
session.commit()
session.refresh(item)
return item

This is honest about where state changes happen.

Explicit partial-update flow

update_dict = item_in.model_dump(exclude_unset=True)
item.sqlmodel_update(update_dict)
session.add(item)
session.commit()
session.refresh(item)

Again: no hidden writes, no mystery transaction.

Counterexamples

Hidden ambient session access

def create_item(item_in: ItemCreate) -> Item:
    session = Session(engine)
    ...

Bad because session lifetime is buried in the wrong layer.

Commit hidden deep in helpers

If callers cannot tell where the write boundary is, transaction reasoning gets ugly fast.

Mock-only persistence tests

If no test ever exercises real session lifetime, query shape, or cleanup behavior, the persistence layer will surprise you in production.

Source signals

Full-stack FastAPI Template

  • backend/app/api/deps.py:21-27 provides DB access through a yielded session dependency.
  • backend/app/api/routes/items.py:21-45 keeps read query shape explicit in the route, including auth-aware filtering and response shaping.
  • backend/app/api/routes/items.py:68-72 shows a visible create, commit, and refresh boundary.
  • backend/app/api/routes/items.py:86-95 shows the same visibility on update.
  • backend/app/api/routes/items.py:106-112 shows delete followed by an explicit commit.
  • backend/tests/conftest.py:15-24 keeps test session lifetime and cleanup explicit.

SQLAlchemy and Starlette lifespan

  • examples/asyncio/async_orm.py:63-104 in SQLAlchemy shows explicit async session and transaction handling instead of hidden ambient state.
  • examples/inheritance/joined.py:93-120 shows the sync equivalent with visible Session(...) and commit() boundaries.
  • starlette/applications.py:46-48 reinforces that long-lived app resources belong in lifespan rather than being initialized lazily inside request-time persistence helpers.

Bottom line

Persistence should be visible.

Sessions should come from clear boundaries.

Transactions should not be magical.

If a reader cannot tell where data is loaded, changed, and committed, the persistence design needs work.