SQL databases¶
FastAPI applications usually reach PostgreSQL through SQLAlchemy 2.0: declarative models, a session per
request, select() statements. The binary embeds its own implementation of the SQLAlchemy session (identity
map, unit of work, relationships, loaders) and of its SQL compiler, on sqlx, for PostgreSQL only. Async and
synchronous sessions are both supported.
Two mapped classes with a relationship, the Pydantic schemas read from them (from_attributes), and a
CRUD router: create (an IntegrityError turned into a 409), list with pagination and a filter, read with
selectinload, partial update (exclude_unset), delete, and an aggregate with an outer join. The session
dependency and the engine are in docs_src/db.py; the tables are created by the
lifespan. Like every example on this site, it is compiled and compared with FastAPI in CI (how).
import os
from typing import Annotated, AsyncIterator
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql+psycopg://postgres@localhost/py2axum_docs")
engine = create_async_engine(DATABASE_URL)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_session() -> AsyncIterator[AsyncSession]:
"""One session per request: committed after the endpoint returns, rolled back if it raises."""
async with SessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
SessionDep = Annotated[AsyncSession, Depends(get_session)]
class Hero(Base):
__tablename__ = "heroes"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), index=True)
age: Mapped[int | None]
team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id"))
team: Mapped["Team | None"] = relationship(back_populates="heroes")
class Team(Base):
__tablename__ = "teams"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True)
headquarters: Mapped[str]
heroes: Mapped[list[Hero]] = relationship(back_populates="team", order_by=Hero.id)
class TeamIn(BaseModel):
name: str = Field(min_length=1, max_length=50)
headquarters: str
class TeamOut(TeamIn):
model_config = ConfigDict(from_attributes=True)
id: int
class HeroIn(BaseModel):
name: str = Field(min_length=1, max_length=50)
age: int | None = Field(default=None, ge=0)
team_id: int | None = None
class HeroUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=50)
age: int | None = Field(default=None, ge=0)
team_id: int | None = None
class HeroOut(HeroIn):
model_config = ConfigDict(from_attributes=True)
id: int
class HeroWithTeam(HeroOut):
team: TeamOut | None
class TeamWithHeroes(TeamOut):
heroes: list[HeroOut]
@router.post("/teams", response_model=TeamOut, status_code=status.HTTP_201_CREATED)
async def create_team(data: TeamIn, session: SessionDep):
team = Team(**data.model_dump())
session.add(team)
try:
await session.flush()
except IntegrityError:
raise HTTPException(status_code=409, detail="A team with this name already exists")
return team
@router.post("/heroes", response_model=HeroOut, status_code=status.HTTP_201_CREATED)
async def create_hero(data: HeroIn, session: SessionDep):
if data.team_id is not None and await session.get(Team, data.team_id) is None:
raise HTTPException(status_code=422, detail="Unknown team")
hero = Hero(**data.model_dump())
session.add(hero)
await session.flush()
return hero
@router.get("/heroes", response_model=list[HeroOut])
async def list_heroes(
session: SessionDep,
offset: int = 0,
limit: Annotated[int, Query(le=100)] = 100,
name: str | None = None,
):
stmt = select(Hero).order_by(Hero.id).offset(offset).limit(limit)
if name:
stmt = stmt.where(Hero.name.ilike(f"%{name}%"))
return (await session.scalars(stmt)).all()
@router.get("/heroes/{hero_id}", response_model=HeroWithTeam)
async def read_hero(hero_id: int, session: SessionDep):
hero = await session.get(Hero, hero_id, options=[selectinload(Hero.team)])
if hero is None:
raise HTTPException(status_code=404, detail="Hero not found")
return hero
@router.patch("/heroes/{hero_id}", response_model=HeroOut)
async def update_hero(hero_id: int, data: HeroUpdate, session: SessionDep):
hero = await session.get(Hero, hero_id)
if hero is None:
raise HTTPException(status_code=404, detail="Hero not found")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(hero, key, value)
await session.flush()
return hero
@router.delete("/heroes/{hero_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_hero(hero_id: int, session: SessionDep):
hero = await session.get(Hero, hero_id)
if hero is None:
raise HTTPException(status_code=404, detail="Hero not found")
await session.delete(hero)
@router.get("/teams/{team_id}", response_model=TeamWithHeroes)
async def read_team(team_id: int, session: SessionDep):
team = await session.get(Team, team_id, options=[selectinload(Team.heroes)])
if team is None:
raise HTTPException(status_code=404, detail="Team not found")
return team
@router.get("/stats")
async def team_stats(session: SessionDep):
stmt = (
select(Team.name, func.count(Hero.id).label("heroes"), func.avg(Hero.age).label("average_age"))
.outerjoin(Hero, Hero.team_id == Team.id)
.group_by(Team.name)
.order_by(Team.name)
)
rows = (await session.execute(stmt)).all()
return [{"team": row.name, "heroes": row.heroes, "average_age": row.average_age} for row in rows]
What is native¶
- Declarative models (
Mapped[...],mapped_column, types,default=/server_default=/onupdate=(value, callable or SQL),unique,nullable,ForeignKey(a foreign key without a type takes the referenced column's), composite primary keys,Identity(),JSON/JSONB(none_as_null=; onJSONB, the operatorscontains(@>),contained_by(<@),has_key(?),has_any(?|),has_all(?&), their argument typed like SQLAlchemy types it: a list given tohas_any/has_allis bound as JSONB, which PostgreSQL rejects in both implementations; on both, an index by a str or an int,col["k"]/col[0], and its accessorsas_string(),as_integer(),as_float(),as_boolean(),as_numeric(precision, scale),as_json(), rendered as SQLAlchemy renders them,CAST(col ->> 'k' AS VARCHAR); a JSON path,col[("a", "b")], is refused at translation, and a bare index compared or used in arithmetic is refused at run time: compare an accessor),Uuid/UUIDandMapped[uuid.UUID](read asuuid.UUID, a str bound to it is cast by PostgreSQL as with psycopg;as_uuid=Falseis refused),Numeric(Decimal, or float withasdecimal=False),Enumcolumns,LargeBinary(bytes),ARRAY(String)(lists;contains/contained_by/overlap/any), deferred columns,deferred(Column(...), group=, raiseload=)andmapped_column(deferred=True, deferred_group=, deferred_raiseload=)(left out of what a query loads: reading one then loads it, with the other unloaded columns of its group, MissingGreenlet in an async session; withraiseloadit raisesInvalidRequestErrorinstead;select(Model.col)andsession.refresh(obj, ["col"])read it), and the column loader optionsload_only(*cols, raiseload=),defer(col, raiseload=),undefer(col),undefer("*")andundefer_group(name): inselect(...).options(),session.get(..., options=[...]), after a relationship loader (selectinload(A.b).load_only(B.x),.options(...)) ordefaultload(A.b)(kept for the relationship's lazy load), applied as SQLAlchemy applies them (the primary key always loaded; an object of the identity map that has not loaded a column gets it from the row; an expired object's deferred columns follow the mapping again); the SELECT still names every column (the SQL text differs, the objects do not),composite()(composite(mapped_column("x1"), mapped_column("y1"))typed by the fields of the dataclass ofMapped[Point],composite(Point, x1, y1),composite("x1", "y1"),composite(Cls.factory, ...),column_template="home_%s";Mapped[Point | None]gives None when every column is NULL; the value's columns are a dataclass's fields in order or its__composite_values__()): on an instance the object built when the row loads, kept in__dict__until a flush or a refresh of its columns (then built again, as SQLAlchemy's handlers do), set column by column; on the class its columns, selected as one value and compared column by column (==,!=,<... ANDed,== Noneas all NULL). One difference: setting one of its columns directly rebuilds the object at the next read (SQLAlchemy keeps the stale one until a flush), a SQL column name other than the attribute (extra_data = Column("metadata", JSON): SQL uses the column name, rows andsession.getthe attribute key,Model.attr.nameis the column's name),T.with_variant(V, "postgresql")(V; other dialects' variants are ignored),col.op("...")(value)(the value typed like the column, as SQLAlchemy does), a column type returned by a project function (def _enum(cls, name): return Enum(cls, name=name, ...), inlined; its arguments must be literals or names), projectTypeDecorators (process_bind_param/process_result_valuewithoutself/dialect), the declarative base'stype_annotation_map(a dict literal; a generic is matched as written,list[int]taking the entry oflistis refused) and SQLAlchemy's defaults forMapped[Decimal](Numeric) andMapped[datetime.time](Time). Methods of mapped classes:__init__(the class's own constructor, run on the new object, positional arguments included), plain,@property,@staticmethodand@classmethod(cls(...)builds an instance), andsqlalchemy.ext.hybrid's@hybrid_property/@hybrid_method(Python on an instance, SQL on the class), with@x.expression/@x.setter(or@x.inplace.expression/@x.inplace.setter, over@classmethodor not; the other hybrid modifiers are refused); other decorators (@validates...) are refused. - Session: identity map (weak, like SQLAlchemy), autoflush, implicit transaction,
get(scalar, tuple, list or dict identities),add/add_all(a detached object is persistent in the session again; another instance with its key raisesInvalidRequestError),delete/flush/commit,rollback(the objects a flush of the transaction deleted are persistent again, expired),refresh,close(the objects are detached, then the transaction is rolled back; the session stays usable),obj in session,Session(engine)/AsyncSession(engine)in an endpoint (bind=,expire_on_commit=,autoflush=; itswithblock closes it),expunge/expunge_all(the objects are detached: reading what they have not loaded raisesDetachedInstanceError),expire_all,expire(obj)(with therefresh-expirecascade ofcascade="all") andexpire(obj, ["col", "rel"])(only those attributes; their changes not flushed are lost),connection()(a readiness probe:close()on it ends the transaction, then statements raiseResourceClosedErrorandcommit()"This transaction is inactive" untilrollback()/close()),expire_on_commit, savepoints (begin_nested()thencommit()/rollback()),session.bind,get_bind(). A flushed UPDATE that matches no row (deleted by another statement) raisesStaleDataError(sqlalchemy.orm.exc), as SQLAlchemy does. Server-generated values (identity,server_default, SQL defaults) are fetched withRETURNINGat insert, likeeager_defaults="auto";__mapper_args__ = {"eager_defaults": True}also fetches the SQLonupdate=values with the UPDATE (other__mapper_args__keys are refused). - Relationships (many-to-one, one-to-many, many-to-many through
secondary=naming a module-levelTable, by its variable or its table name),lazy=select/selectin/joined/noload/raise,selectinload()chains,back_populates/backref(not on a many-to-many relationship: SQLAlchemy keeps both collections in step at once, which is refused), cascades (save-update, delete, delete-orphan; none on many-to-many, whose association rows are written at flush and deleted with their owner),passive_deletes,order_by=(target columns,.desc(), or a string SQLAlchemy evaluates such as"[Child.a, Child.b.desc()]"), self-referential relationships (adjacency list: one-to-many by default, many-to-one withremote_side=naming the referenced column; a row made its own parent raisesCircularDependencyErrorat flush, as withoutpost_update). - Inheritance (
__mapper_args__withpolymorphic_onon the base andpolymorphic_identityon each class): a row is built as the class of its discriminator (every mapped subclass is known, even one the code never names), the discriminator is set when an object is created, and a subclass's own columns are read on access after a query on the base (MissingGreenletin an async session, like SQLAlchemy). Single table (no__tablename__on the subclass): a query on a subclass reads only the rows of its identities and those of its subclasses (also throughselect_from, columns, joins, bulkupdate()/delete()). Joined table (the subclass's own table, its primary key a foreign key to the parent's under the same name): a subclass reads its tables joined on the key, the unit of work writes one INSERT/UPDATE/DELETE per table, ORM bulkinsert(Subclass)goes row by row, bulkupdate()writes the table of the columns it sets (FROMthe others its criteria name) anddelete()the subclass's table. Loading subclass columns with the query (what an async session can then read without IO):selectin_polymorphic(Base, [Sub, ...]), also chained (selectinload(A.rel).selectin_polymorphic([...])) or inside.options();with_polymorphic(Base, [Sub, ...] | "*")(its subclass namespacespoly.Sub.colin the criteria, the subclass tables LEFT OUTER JOINed, also throughA.rel.of_type(poly)in a join or aselectinload());__mapper_args__ = {"polymorphic_load": "selectin" | "inline"}; options on a subclass's relationship next to a query on its base (selectinload(Sub.rel)).A.rel.of_type(Sub)joins along a relationship to a subclass. Refused: concrete inheritance,with_polymorphic(aliased=, flat=, selectable=), an alias of a joined subclass, a bulk update of columns of two tables. - Write-only collections (
WriteOnlyMapped[...]orlazy="write_only", one-to-many or many-to-many, withpassive_deletes=True): never loaded; members given to a new object's constructor,add()/add_all()/remove()applied at the next flush (foreign key set or cleared, orphans deleted, association rows written), andselect(),insert()(one-to-many, executemany andreturning()included),update()anddelete()bound to the parent. Eager loading and replacing the collection of a persistent object raiseInvalidRequestErrorlike SQLAlchemy;DynamicMapped/lazy="dynamic"are refused. - Core tables: a module-level
Table(name, metadata, Column(name, ...), ...)(constraints and indexes are the DDL's business;TypedColumnsand otherTable()options are refused) is a value:t.c.col,t.c["a", "b"],t.name,select(t)(its columns, rows of values),insert(t),update(t),delete(t),t.select(),t.alias()(a.c.col), in joins like a mapped class.bindparam("name", value=None)takes its value from the execution's parameters (StatementError"A value is required for bind parameter" without one); a list of dicts runs aninsert()/update()/delete()once per dict (executemany): the keys of the first dict are the statement's (a later dict lacking one raises before anything runs, its other keys are ignored), keys that are notbindparam()names are SET / VALUES columns, the rowcounts are summed (an INSERT's is -1 over psycopg). Through theSession, a list with an ORM entity'supdate(...).where(...)raises SQLAlchemy'sInvalidRequestError(bulk synchronize);session.connection().execute(stmt, params)runs it as Core, never synchronized.insert(Model).values(...)with a list: one INSERT per dict (the ORM's leaves aNoneout),returning()rows in the order of the dicts. - Core:
select(entities, columns, labels,*cols),where/filter_by(the FROM list is the tables of the columns and of the WHERE criteria; a nested SELECT with more than one FROM leaves out the enclosing statement's, SQLAlchemy's auto-correlation, scalar subqueries of the columns included), joins (full=Trueas FULL OUTER JOIN; without an ON clause, from the foreign key between the two sides,join_from(left, right)taking its left side, a target alias or subquery included; explicit, inferred from the single foreign key, relationship,join(Target, Model.rel)also to an alias,join(alias.rel), through a many-to-many association table;select_from(A).join(B.rel_to_A)starts from B like SQLAlchemy), relationship criteria (Child.parent == obj/!= obj/== None,Parent.children.contains(obj),any()/has(),with_parent(obj, Parent.rel)),filter_by()on columns, composites and hybrid properties (an unknown name raises SQLAlchemy'sInvalidRequestError), a named alias as the key of its entity in a row (row.u1),join(A.rel.of_type(alias)),join(A.rel.and_(criteria))(also from an alias,alias.rel.of_type(...)),add_columns(...),aliased(Model, name="x"),execution_options(populate_existing=, autoflush=, yield_per=)on a SELECT (also given tosession.execute(stmt, execution_options={...})), ORM bulk INSERT (session.execute(insert(Model), [dicts]),scalars(insert(Model).returning(Model), [dicts]): one statement per dict in order,Nonevalues left out unlessexecution_options(render_nulls=True),returning(..., sort_by_parameter_order=True)) and bulk UPDATE by primary key (execute(update(Model), [dicts]), applied to the loaded objects), their results as SQLAlchemy's (first()None,all()ResourceClosedError...);insert().values([dicts])takes the columns of the first dict and those with a Python default, aselect()value is its scalar subquery,aliased, subqueries,exists(exists(select),select.exists(), andexists().where(...), whose FROM is the tables of its criteria less those of the enclosing statement: SQLAlchemy's auto-correlation),in_(lists, selects,tuple_),like/ilike/startswith/contains,regexp_match(~,~*withflags="i"; other flags raise),is_/is_not,is_distinct_from,case,literal,cast,extract,func.*(withFILTER),group_by/having/order_by/limit/offset/distinct(on)(an expression built once and used in the columns and inGROUP BYshares its bound parameters, like SQLAlchemy's bind objects; two identical expressions built apart do not, and PostgreSQL rejects the grouping, as it does for SQLAlchemy),with_for_update,update()/delete()(withsynchronize_session;returning, of columns only for a DELETE),insert()(core and postgresql dialect: several rows, Python column defaults,on_conflict_do_update(index_elements= | constraint=, set_=, where=),on_conflict_do_nothing(aconstraint=name SQLAlchemy would not quote),excluded,returning),text()with:namedparameters (andtext(...).bindparams(name=value), also inside awhere(); a list of dicts runs it once per dict, executemany: no rows,rowcountsummed),+with a string as||like SQLAlchemy,Result.scalars/all/first/one/scalar/unique/ mappings, rows with attribute access (row.total,_mapping,_asdict()). - Table and column names quoted like SQLAlchemy's PostgreSQL dialect (
"user","order"."end"); a label used as an operand is its expression (x.label("n") > 3),select(...).label("n")a labeled scalar subquery. - SQL typing like SQLAlchemy: arithmetic and
FILTERkeep the column type,func.round/avguntyped (Decimal); untyped integers are bound as int2/int4/int8 like psycopg; NUMERIC results areDecimal. - Database errors are SQLAlchemy's classes over psycopg's (
IntegrityError,DataError...), their message(psycopg.errors.<class of the SQLSTATE>) <server message>(NumericValueOutOfRange,UniqueViolation...),e.origthe psycopg exception (its class name and message). An integer bound to anInteger/SmallIntegercolumn is cast like SQLAlchemy's psycopg dialect does (::INTEGER): out of range, aDataError. - The PostgreSQL session time zone: sqlx forces UTC, the runtime applies the one psycopg would see
(role/database setting, then server config, or
PY2AXUM_DB_TIMEZONE), unless the engine sets one. - Session parameters of
create_async_engine/create_engine(connect_args=...): psycopg's libpqoptions(-c name=value,-cname=value,--name=value,\escapes) and asyncpg'sserver_settingsdict are set on every pool connection; aTimeZoneamong them is the zone timestamptz are decoded in (psycopg). Otherconnect_argskeys (timeouts,sslmode, ...) have no effect. - The driver named by
DATABASE_URL(postgresql+psycopg://orpostgresql+asyncpg://): psycopg returns timestamptz in the session's zone, asyncpg asdatetime.timezone.utc(Pydantic writesZ); an ORM-enabledinsert(Model)withoutreturningreportsrowcount-1 over psycopg, the rows inserted over asyncpg. obj.__dict__of a mapped object:_sa_instance_statethen the loaded attributes (a snapshot). After an INSERT it holds what SQLAlchemy's does: the columns given, client-side and server-side defaults (fetched byRETURNING), the key, a foreign key whose relationship was set; a column never set and without an insert default (default=Noneincluded) readsNonewithout IO but stays out of it until set, refreshed or returned by a laterSELECT(which loads it from the row). A collection assigned whole sets each member's back reference at once; members appended or removed have it set at flush.create_async_engine(...)is the binary's pool (one database,DATABASE_URL; its options are ignored butconnect_args, above);async with engine.connect() as conn(rolled back on exit),async with engine.begin() as conn(committed on exit, rolled back when the block raises); the same withwithon acreate_engine(...)engine.await conn.run_sync(Base.metadata.create_all)(e.g. in the lifespan): the DDL is compiled at translation time by SQLAlchemy itself (it must be installed next to py2axum; 2.x). The mapped classes of the base, from the modules the application imports (plus those imported in the calling function), are rebuilt as real SQLAlchemy classes from their source by a static evaluator that only calls SQLAlchemy: column types and options,Mapped[...]annotations and the base'stype_annotation_map, mixins,__table_args__, the base'smetadata = MetaData(naming_convention=...), module-levelTable(...)andIndex(...), project enums, projectTypeDecorators (theirimpl), project functions whose body isreturn <type>. Python-side defaults never run (only their presence matters: a primary key with a default is not SERIAL). At run time the statements are replayed withcheckfirst=Truelike SQLAlchemy's PostgreSQL dialect: every named enum type absent frompg_typeis created (even when its table exists), then each table absent frompg_classwith its indexes and comments, then the foreign keys of cycles (use_alter) of the tables created. The DDL is the one of the SQLAlchemy that ran the translation.- Synchronous sessions (
create_engine,sessionmaker,Sessionparameters ofdefendpoints, a generator dependencys = maker()/try: yield s/finally: s.close()orwith maker() as s: yield s): the same session semantics, run on the async pool (FastAPI's threadpool is not modelled: only the observable behaviour is). Reading an expired column or a relationship that is not loaded emits the SQL like SQLAlchemy's lazy loader (autoflush first;ObjectDeletedErrorwhen the row is gone), including while aresponse_modelor a model built from ORM objects (Page(items=rows)) reads the attributes,@propertys of the model included. Awaiting a synchronous session's method (await db.execute(...)) runs it, then raises CPython'sTypeError. An application uses one kind of session dependency (sync or async), not both.
The session dependency is described in Dependencies; large lists read straight from the
session are streamed (Large list responses). The binary does not run migrations:
run Alembic (or create_all, above) before it serves.
Model.__table__for introspection: the table'sname, itscolumns/c(key in,[key]or index, iteration,len,keys(),values(),get()), each column'sname,key,nullable,primary_keyandtype; a type supportsisinstance(col.type, JSON)against any SQLAlchemy type class,str()andrepr()(computed by SQLAlchemy at translation time, which needs it installed next to py2axum). Any other attribute raises aTypeErrornaming what is supported.
What stays in Python¶
-
execute(select/delete(...), parameters), an INSERT or UPDATE with its ownwhere()/values()and a parameter list (executemany withbindparam()),update(...).returning(...)with a parameter list: refused at translation. -
connect_args: other libpqoptionsswitches are refused (a literal string, at translation; a computed one raisesValueErrorwhen the engine is created). create_all, refused at their line:@declared_attr,Sequence, DDL event listeners (before_create/after_create...), tables in another schema,Enum(metadata=...), TypeDecorators that overrideload_dialect_impl/__init__,values_callableother thanlambda x: [e.value for e in x], any value the evaluator cannot build, a model module only imported inside another function, any otherrun_syncfunction.
Differences¶
- Known difference: the binary issues one UPDATE per modified object; when several stale rows of the
same table are flushed together, SQLAlchemy's
StaleDataErrormessage counts the whole batch ("expected to update 2 row(s)"), the binary's the first object ("1 row(s)"). parent.children.append(x)(orremove(x)) sets the foreign key andx.parentat flush, not before it (SQLAlchemy setsx.parentimmediately through the backref event); assigning the whole collection sets it at once.- In an async session, reading what needs IO (an expired or deferred column, the primary key of an object
expired as a whole, a relationship not loaded) raises what the locked SQLAlchemy raises:
MissingGreenletup to 2.0; since 2.1 aStatementError(sqlalchemy.exc.MissingGreenlet) greenlet_spawn has not been called; ...whose.orig(also__cause__) is theMissingGreenlet:except MissingGreenletdoes not catch it,except StatementError(orSQLAlchemyError) does. - Known difference:
str()of a database error or of aStatementErrorstops at its first line (e.args[0]), without SQLAlchemy's[SQL: ...],[parameters: ...]and background-link lines (the binary's SQL is not SQLAlchemy's text; for a lazy one-to-many load SQLAlchemy's parameters even name a memory address).e.orig(alsoe.__cause__) is there, with its own message;e.statementande.paramsare not (AttributeError). - Known difference: in
create_all, a cycle of foreign keys partly created already is closed byALTER TABLEwhere SQLAlchemy, which only sorts the tables it creates, may inline the constraint: same resulting schema.