DB¶
SQLAlchemy async session management with transactions, table locking, advisory locking, and row-change polling.
Info
This module has been coded and tested to be compatible with PostgreSQL only.
Overview¶
The db module is built around one object, Database, which owns the engine and sessionmaker and exposes the FastAPI dependency, a commit-before-response middleware, session/transaction context managers, and table locking. Free helpers cover savepoint-aware transactions, advisory locks, many-to-many association tables, and row-change polling.
Setup¶
Create one Database for your app. Provide a URL (the facade builds and disposes the engine) or pass an existing engine= you own (e.g. for Alembic or event.listen). The session factory is built internally with expire_on_commit=False.
from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_toolsets.db import Database
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
app = FastAPI()
db.install(app) # commit middleware + engine disposal on shutdown
@app.get("/users")
async def list_users(session: AsyncSession = Depends(db)): ...
The Database instance is the dependency: use it directly as Depends(db). The whole request runs as a single transaction (CRUD writes use savepoints under it).
The URL may be a plain string or a Pydantic PostgresDsn. In URL mode you can tune the engine: pass connect_args for DBAPI-level options and any other keyword for create_async_engine (e.g. pool_size, echo, pool_pre_ping):
from pydantic_settings import BaseSettings
from pydantic import PostgresDsn
class Settings(BaseSettings):
database_url: PostgresDsn
settings = Settings()
db = Database(
settings.database_url,
pool_size=20,
pool_pre_ping=True,
connect_args={"server_settings": {"application_name": "myapp"}},
)
Session class¶
session_class selects the class the internal factory builds sessions from. It defaults to plain AsyncSession.
To use the lifecycle events of the models module, pass EventSession here. It is the only place to wire them in v5: Database owns the session factory, so a factory you build yourself is no longer the one serving requests.
from fastapi_toolsets.models import EventSession
db = Database("postgresql+asyncpg://...", session_class=EventSession)
Session execution options¶
Added in v6.0
session_execution_options applies execution options to every statement the sessions run, including the flush and eager loads. It works with url and with engine, so several Database objects can share one engine with different options, for example one schema per tenant:
engine = create_async_engine("postgresql+asyncpg://...")
tenant_a = Database(
engine=engine,
session_execution_options={"schema_translate_map": {None: "tenant_a"}},
)
Options passed as extra keywords in URL mode (Database(url, execution_options=...)) still configure the engine, not the sessions.
Committing before the response¶
db.install(app) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. The dependency commits only if the middleware did not: when a function-scoped dependency unwinds before the response, or when the response never passes through the middleware. Either way the request is committed exactly once.
The request is committed as a single transaction:
- Read-after-write: a follow-up request sees the write.
- Atomicity: multi-write endpoints roll back as a unit on failure.
- Errors roll back: on a raised exception the session rolls back and nothing is committed.
If the commit itself fails, for example on a unique constraint that was never flushed in the endpoint, the middleware answers with the app's exception handler for that error, such as the constraint violation handler of init_exceptions_handlers. Without a handler for that error class, the request ends in a 500.
Without install, the session commits in the dependency teardown, which runs after the response has been sent.
Streaming / SSE endpoints
For a StreamingResponse / EventSourceResponse, the commit fires at the start of the stream. A stream that writes must open a short-lived session per write with db.session(); the start-time commit will not flush writes made later during the stream.
Lifespan¶
db.install(app) disposes the engine on shutdown, composing around your own lifespan:
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
await warm_cache() # your startup
yield
await flush_metrics() # your shutdown
app = FastAPI(lifespan=lifespan)
db.install(app) # your shutdown runs first, then the engine is disposed
If you have no lifespan of your own, db.lifespan works standalone as FastAPI(lifespan=db.lifespan). Engine disposal is idempotent and is a no-op when you passed your own engine=.
Session context manager¶
Use db.session() for sessions outside request handlers (e.g. background tasks, CLI commands). It commits on clean exit and rolls back on exception:
Transactions¶
transaction opens a transaction on a session, using a savepoint when one is already open so it nests safely:
from fastapi_toolsets.db import transaction
async def create_user_with_role(session):
async with transaction(session):
...
async with transaction(session): # uses a savepoint
...
When you have a Database, db.begin() opens a session already inside a transaction:
async with db.begin() as session:
session.add(User(name="ada")) # commits on exit, rolls back on exception
Table locking¶
db.lock_tables acquires PostgreSQL table-level locks for a critical section. By default it opens a dedicated session internally and releases the lock when the context exits (or locks on a session you pass):
from fastapi_toolsets.db import LockMode
async with db.lock_tables([User], mode=LockMode.EXCLUSIVE) as session:
# No other transaction can modify User until this block exits
...
Available lock modes are defined in LockMode: ACCESS_SHARE, ROW_SHARE, ROW_EXCLUSIVE, SHARE_UPDATE_EXCLUSIVE, SHARE, SHARE_ROW_EXCLUSIVE, EXCLUSIVE, ACCESS_EXCLUSIVE.
Pass timeout to limit how long the lock waits. On timeout, a LockTimeoutError is raised instead of a raw database error:
Locking on the request's own session¶
The dedicated session above is a second connection, on top of the one the request already holds through Depends(db), and it is a different backend from the request's. Two consequences:
- The request needs two simultaneous connections. Size the pool for concurrent locking requests, not concurrent users. Behind a transaction-mode pooler (PgBouncer, a CNPG
Pooler) wheredefault_pool_sizeis below peak concurrency, this self-deadlocks: every server connection is pinned by a request transaction waiting for a second one. SQLAlchemy'spool_timeoutnever fires, because the wait happens inside the pooler. - The block must not touch the request session under a conflicting mode (
SHARE,SHARE_ROW_EXCLUSIVE,EXCLUSIVE,ACCESS_EXCLUSIVE).timeoutapplies only to the dedicated session, so the request session waits with no bound at all, and PostgreSQL sees no deadlock, because the lock holder is merely idle in transaction.
Pass session= to avoid both: the lock is taken on the transaction the request already owns, on one connection, and the block is free to use that same session.
@app.post("/orders")
async def create_order(session=Depends(db)):
async with db.lock_tables([Order], session=session, mode=LockMode.EXCLUSIVE):
# Same session, same connection: writing through it cannot self-block
return await OrderCrud.create(session, data)
Note
On this path the caller keeps its transaction: lock_tables commits and rolls back nothing, so the lock is held until the request's transaction ends rather than released at block exit. Only the acquisition is wrapped in a savepoint, so a LockTimeoutError leaves the transaction usable and a handler can answer 409 instead of poisoning the commit. Opening that savepoint flushes pending ORM changes, and SET LOCAL lock_timeout stays in effect on the transaction after the block.
Whichever path you take, connect_args={"server_settings": {"statement_timeout": "30s", "idle_in_transaction_session_timeout": "60s"}} turns a freeze into an error.
Advisory locking¶
advisory_lock acquires a PostgreSQL advisory lock on a session you provide. By default it is session-level and released when the context exits:
from fastapi_toolsets.db import advisory_lock
# Blocking exclusive lock: waits until the lock is free
async with advisory_lock(session=session, key=42):
...
# Non-blocking: yields False immediately if already held
async with advisory_lock(session=session, key=42, nowait=True) as acquired:
if not acquired:
raise HTTPException(409, "Resource is locked")
# Blocking with a timeout: raises LockTimeoutError if not acquired in time
async with advisory_lock(session=session, key=42, timeout="5s"):
...
# Shared lock: multiple readers allowed simultaneously, blocks exclusive writers
async with advisory_lock(session=session, key=42, shared=True):
...
# Two-integer key for namespacing (e.g. lock_type + resource_id)
async with advisory_lock(session=session, key=(1, user_id)):
...
# Transaction-level: released by the commit, not at block exit
async with advisory_lock(session=session, key=(1, user_id), xact=True):
...
Choosing between session-level and transaction-level¶
xact=True switches to pg_advisory_xact_lock, which PostgreSQL releases at commit or rollback. Reach for it whenever the protected read has to see the previous holder's write. A check-then-insert is the usual case:
@app.post("/claims")
async def claim(session=Depends(db)):
async with advisory_lock(session=session, key=(team_id, question_id), xact=True):
if await ClaimCrud.first(session, [Claim.question_id == question_id]):
raise ConflictError(detail="Already claimed")
return await ClaimCrud.create(session, data)
A session-level lock is wrong here. db.install commits the request session at http.response.start, which is after the handler returns and therefore after the async with block released the lock. Two concurrent requests both read "not present" and both insert. The lock is held over exactly the wrong interval.
Keep the session-level default when the critical section is self-contained, for example one worker at a time over work that is not read back through the same transaction.
Note
A session-level lock is tied to the database connection, not the SQLAlchemy transaction, so it is released when the context exits even if the surrounding transaction is still open. For the same reason it is unsafe behind a transaction-mode pooler, which hands the server connection to another client between statements. xact=True does not have that problem: the lock and the pooler's connection assignment end together.
timeout issues SET LOCAL lock_timeout on the session you pass, so it stays in effect for the rest of that transaction.
Row-change polling¶
wait_for_row_change polls a row until a specific column changes value:
from fastapi_toolsets.db import wait_for_row_change
# Wait up to 30s for order.status to change
await wait_for_row_change(
session=session,
model=Order,
pk_value=order_id,
columns=["status"],
interval=1.0,
timeout=30.0,
)
Creating a database¶
create_database (in fastapi_toolsets.db.testing) connects to server_url and issues a CREATE DATABASE statement:
from fastapi_toolsets.db.testing import create_database
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
await create_database(db_name="myapp_test", server_url=SERVER_URL)
For test isolation with automatic cleanup, use create_worker_database from the pytest module, which handles drop-before, create, and drop-after.
Cleaning up tables¶
cleanup_tables (in fastapi_toolsets.db.testing) truncates all tables:
from fastapi_toolsets.db.testing import cleanup_tables
@pytest.fixture(autouse=True)
async def clean(db_session):
yield
await cleanup_tables(session=db_session, base=Base)
Many-to-Many helpers¶
The three m2m_* helpers modify a many-to-many association table with direct SQL, without loading the ORM collection.
m2m_add: insert associations¶
m2m_add inserts one or more rows into a secondary table:
from fastapi_toolsets.db import m2m_add
async with db.lock_tables([Tag]) as session:
tag = await TagCrud.create(session, TagCreate(name="python"))
await m2m_add(session, post, Post.tags, tag)
Pass ignore_conflicts=True to skip associations that already exist:
m2m_remove: delete associations¶
m2m_remove deletes specific association rows. Removing a non-existent association is a no-op:
from fastapi_toolsets.db import m2m_remove, transaction
async with transaction(session):
await m2m_remove(session, post, Post.tags, tag1, tag2)
m2m_set: replace the full set¶
m2m_set replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association:
from fastapi_toolsets.db import m2m_set, transaction
# Replace all tags
async with transaction(session):
await m2m_set(session, post, Post.tags, tag_a, tag_b)
# Clear all tags
async with transaction(session):
await m2m_set(session, post, Post.tags)
All three helpers raise TypeError if the relationship attribute is not a Many-to-Many (i.e. has no secondary table).