Databases with SQLModel

FastAPI · Practical · 18 min

Most APIs are a thin layer over a database; getting sessions and models right avoids connection leaks and data exposure.

FastAPI has no built-in database layer; you bring one. SQLModel, by FastAPI's author, fits most directly: a class can be both a Pydantic model and a SQLAlchemy table, so one definition gives you validation and persistence. Plain SQLAlchemy 2.0 with separate Pydantic schemas is equally common, and the session pattern is the same.

The moving parts:

  • create_engine(url) once per process. The engine owns a connection pool. For SQLite with sync endpoints add connect_args={"check_same_thread": False}, because requests run on different threads.
  • SQLModel.metadata.create_all(engine) creates the tables in demos; real projects use Alembic migrations.
  • A session dependency that opens a Session, yields it to the endpoint, and closes it when the request is done. The yield makes it a dependency with cleanup code; the Advanced level explains exactly when that cleanup runs.

Queries use SQLAlchemy 2.0 style: session.exec(select(Hero).where(Hero.age > 30).limit(10)).all(), session.get(Hero, hero_id) for primary-key lookups, and session.add(obj), session.commit(), session.refresh(obj) to insert and then load database-generated values such as the id.

Keep the separate-models discipline from the response models lesson. table=True marks the table class; HeroCreate and HeroPublic are plain data models for input and output, so clients can't set id and never see columns like secret_name. Hero.model_validate(data) turns validated input into a table row.

SQLModel and SQLAlchemy bind query values safely, so never build SQL strings from request data with f-strings. And when a lookup finds nothing, raise a 404 rather than returning None.

Example

from typing import Annotated
from fastapi import Depends, FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select

class HeroBase(SQLModel):
    name: str = Field(index=True)
    age: int | None = None

class Hero(HeroBase, table=True):
    id: int | None = Field(default=None, primary_key=True)
    secret_name: str

class HeroCreate(HeroBase):
    secret_name: str

class HeroPublic(HeroBase):
    id: int

engine = create_engine("sqlite:///heroes.db", connect_args={"check_same_thread": False})
SQLModel.metadata.create_all(engine)

def get_session():
    with Session(engine) as session:
        yield session

SessionDep = Annotated[Session, Depends(get_session)]
app = FastAPI()

@app.post("/heroes", response_model=HeroPublic, status_code=201)
def create_hero(data: HeroCreate, session: SessionDep):
    hero = Hero.model_validate(data)
    session.add(hero)
    session.commit()
    session.refresh(hero)
    return hero

@app.get("/heroes", response_model=list[HeroPublic])
def list_heroes(session: SessionDep, limit: int = 10):
    return session.exec(select(Hero).limit(limit)).all()

Remember

  • One engine per process; one session per request via a yield dependency
  • table=True models map to tables; separate Create/Public models shape the API
  • add + commit + refresh to insert and read back generated ids
  • Use Alembic for schema changes in real projects

Watch out for

  • Creating a new engine per request, which throws away connection pooling.
  • Returning table objects without a response model and leaking internal columns.

Check yourself

  1. Why does get_session use yield instead of return?
  2. Why have HeroCreate and HeroPublic instead of using the Hero table class everywhere?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: fastapi.tiangolo.com/tutorial/sql-databases/

Databases with SQLModel · FastAPI · Topolearn