šÆ What You Will Learn
Commit at the wrong moment and a crash mid-request leaves your database in a broken state ā money debited but never credited. This post is about making multi-step writes all-or-nothing, and keeping your connection pool healthy under load.
- Why per-operation commits corrupt data (see the failure first)
- Session vs transaction vs connection ā the mental model
- Atomic writes: one commit, rollback on any error
- The clean
with session.begin()unit-of-work pattern - Savepoints (
begin_nested) for partial rollback - Connection pool tuning:
pool_size,max_overflow,pool_pre_ping
Prerequisites: a FastAPI app connected to PostgreSQL (how-to-connect-fastapi-to-postgres) and basic SQLModel. We'll use a money-transfer example because it makes atomicity impossible to ignore.
fastapi-transactions/
āāā requirements.txt
āāā docker-compose.yml # local Postgres
āāā app/
āāā __init__.py
āāā settings.py # DATABASE_URL via pydantic-settings
āāā database.py # engine (with pool tuning) + get_session
āāā models.py # Account table
āāā main.py # transfer endpointsThe Problem: Partial Writes Corrupt Data (See It First)
A transfer is two writes: debit one account, credit another. Here's the version almost everyone writes first ā commit as you go. It works in the happy path and destroys money in the sad one.
@app.post("/transfer-naive")
def transfer_naive(data: TransferIn, session: SessionDep):
src = session.get(Account, data.from_id)
src.balance -= data.amount
session.add(src)
session.commit() # š„ debit is now PERMANENT
dst = session.get(Account, data.to_id)
if dst is None: # ...or any error: crash, timeout, bug
raise HTTPException(status_code=404, detail="Destination not found")
dst.balance += data.amount
session.add(dst)
session.commit()
return {"ok": True}Send money to an account that doesn't exist:
$ curl -s -X POST http://127.0.0.1:8000/transfer-naive \
-H "Content-Type: application/json" \
-d '{"from_id": 1, "to_id": 999, "amount": "100.00"}'
{"detail":"Destination not found"}
# Alice's balance AFTER the failed transfer:
$ curl -s http://127.0.0.1:8000/accounts/1
{"id":1,"owner":"Alice","balance":"900.00"} <-- 100 pesos VANISHEDcommit() made the debit permanent. When the credit failed, there was nothing to undo it ā the money is simply gone. Two writes that must happen together were committed separately. That's a broken transaction boundary.Session vs Transaction vs Connection
These three get muddled constantly. Keep them straight and transactions stop being scary:
| Term | What it is | In FastAPI |
|---|---|---|
| Connection | A physical link to the database. Expensive to open. | Borrowed from a pool, not created per request. |
| Transaction | A unit of work that fully commits or fully rolls back (atomic). | Bounded by commit() / rollback(). |
| Session | SQLAlchemy's work area: tracks your objects and runs the transaction on a connection. | One Session per request via a dependency. |
commit() lands together; if you rollback() (or an exception does it for you), none of them land. Your job is to draw that boundary around writes that belong together.The Session Dependency (One Per Request)
Never share a session across requests or threads ā a Session is not thread-safe. Create one per request with a dependency and let the with block close it (returning the connection to the pool) when the request ends.
from sqlmodel import Session, SQLModel, create_engine
from app.settings import settings
engine = create_engine(settings.database_url, echo=False)
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
def get_session():
# one session per request; closed automatically at the end
with Session(engine) as session:
yield sessionfrom typing import Annotated
from fastapi import Depends
from sqlmodel import Session
from app.database import get_session
SessionDep = Annotated[Session, Depends(get_session)]with Session(engine) as session: block guarantees the session is closed even if the endpoint raises. Closing a session releases its connection back to the pool ā that's what keeps you from running out of connections under load.The Fix: One Commit, Rollback on Error
Do both writes, then commit once. If anything goes wrong before that single commit, roll back so neither write lands. Here's the explicit try/except form so you can see exactly what happens:
from decimal import Decimal
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from app.models import Account
app = FastAPI(title="FastAPI Transactions", version="0.1.0")
class TransferIn(BaseModel):
from_id: int
to_id: int
amount: Decimal
@app.post("/transfer")
def transfer(data: TransferIn, session: SessionDep):
src = session.get(Account, data.from_id)
dst = session.get(Account, data.to_id)
if src is None or dst is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if src.balance < data.amount:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Insufficient funds")
try:
src.balance -= data.amount
dst.balance += data.amount
session.add(src)
session.add(dst)
session.commit() # both writes land together, or...
except Exception:
session.rollback() # ...neither does
raise
session.refresh(src)
session.refresh(dst)
return {"from_balance": src.balance, "to_balance": dst.balance}commit(). A failed lookup or a crash before that commit leaves the database exactly as it was ā no vanished money.The Clean Way: with session.begin()
The try/except/rollback dance is easy to get wrong. SQLAlchemy's session.begin() context manager does it for you: it commits on success and rolls back on any exception. This is the "unit of work" pattern, and it's what I reach for in real code.
@app.post("/transfer")
def transfer(data: TransferIn, session: SessionDep):
# commit if the block finishes; rollback if it raises
with session.begin():
src = session.get(Account, data.from_id)
dst = session.get(Account, data.to_id)
if src is None or dst is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if src.balance < data.amount:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Insufficient funds")
src.balance -= data.amount
dst.balance += data.amount
# no explicit commit ā the context manager commits on exit
return {"from_balance": src.balance, "to_balance": dst.balance}session.begin() before any query on that session. A plainSession auto-starts a transaction on first use, and calling begin() when one is already active raises InvalidRequestError. In the dependency above the session is fresh, so we begin first ā that's why the queries live inside the with block.HTTPException inside the block still triggers the rollback (it's an exception), then FastAPI turns it into the proper 4xx response on the way out. You get atomicity and a clean error response for free.Savepoints: Partial Rollback with begin_nested()
Sometimes you want "try this sub-step; if it fails, undo just that part but keep the rest." That's a savepoint, created with begin_nested(). The outer transaction survives; only the nested block rolls back.
@app.post("/transfer-with-bonus")
def transfer_with_bonus(data: TransferIn, session: SessionDep):
with session.begin(): # outer transaction
src = session.get(Account, data.from_id)
dst = session.get(Account, data.to_id)
if src is None or dst is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
src.balance -= data.amount
dst.balance += data.amount
# a "nice to have" bonus that must NOT break the core transfer
try:
with session.begin_nested(): # SAVEPOINT
apply_referral_bonus(session, dst)
except Exception:
# bonus failed -> only the savepoint rolls back;
# the transfer above still commits with the outer block
pass
return {"from_balance": src.balance, "to_balance": dst.balance}Connection Pooling (The Other Half)
Opening a database connection is expensive, so SQLAlchemy keeps a pool of them and hands one to each session, then takes it back when the session closes. Under real traffic, the pool settings decide whether your API stays up or throws QueuePool limit ... connection timed out.
from sqlmodel import create_engine
from app.settings import settings
engine = create_engine(
settings.database_url,
pool_size=10, # steady connections kept open
max_overflow=5, # extra temporary connections under spikes (max = 15)
pool_timeout=30, # seconds to wait for a free connection before erroring
pool_pre_ping=True, # check a connection is alive before using it
pool_recycle=1800, # recycle connections older than 30 min
echo=False,
)| Setting | What it does | Why it matters |
|---|---|---|
pool_size | Connections kept open at rest | Too low = requests queue; too high = you exhaust Postgres' max_connections |
max_overflow | Extra connections allowed during spikes | Absorbs bursts without keeping them open forever |
pool_timeout | Wait time for a free connection | Fail fast instead of hanging when the pool is drained |
pool_pre_ping | Tests a connection before handing it over | Kills the dreaded 'server closed the connection unexpectedly' error |
pool_recycle | Max age before a connection is replaced | Beats idle-timeout kills from Postgres/proxies (e.g. PgBouncer) |
(pool_size + max_overflow) Ć number of worker processes. Four uvicorn workers with the config above = up to 60 connections. Keep that comfortably under Postgres' max_connections (default 100), or use a pooler like PgBouncer in front.fastapi-async-sqlalchemy) the same knobs exist on create_async_engine. The pooling concepts here carry over unchanged.Try It (Postgres + curl)
fastapi==0.116.1
uvicorn[standard]==0.30.6
sqlmodel==0.0.22
psycopg[binary]==3.2.1
pydantic-settings==2.5.2services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: bank
ports:
- "5432:5432"from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# never hardcode secrets ā read from env / .env
database_url: str = "postgresql+psycopg://app:app@localhost:5432/bank"
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()from decimal import Decimal
from sqlalchemy import Numeric
from sqlmodel import Column, Field, SQLModel
class Account(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
owner: str
balance: Decimal = Field(
default=Decimal("0.00"),
sa_column=Column(Numeric(12, 2), nullable=False),
)docker compose up -d
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
# (seed Alice=1000, Bob=500 on startup, then:)# happy path: 100 moves from Alice -> Bob
$ curl -s -X POST http://127.0.0.1:8000/transfer \
-H "Content-Type: application/json" \
-d '{"from_id":1,"to_id":2,"amount":"100.00"}'
{"from_balance":"900.00","to_balance":"600.00"}
# sad path: insufficient funds -> whole transaction rolls back
$ curl -s -X POST http://127.0.0.1:8000/transfer \
-H "Content-Type: application/json" \
-d '{"from_id":1,"to_id":2,"amount":"99999.00"}'
{"detail":"Insufficient funds"}
# Alice is UNCHANGED ā no partial write
$ curl -s http://127.0.0.1:8000/accounts/1
{"id":1,"owner":"Alice","balance":"900.00"}Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
| Committing after each write | Partial writes on failure (vanished money) | One commit per unit of work, or with session.begin() |
| Not rolling back on error | Session stuck in a failed transaction; later queries error | rollback() in except, or let begin() do it |
| Committing inside a loop | N round-trips, slow, non-atomic | Do the loop, then a single commit at the end |
| Sharing one session across requests/threads | Random errors, corrupted state | One session per request via a dependency |
No pool_pre_ping | 'server closed the connection unexpectedly' after idle | Enable pool_pre_ping=True (and pool_recycle) |
| Pool too large for Postgres | 'too many connections' under load | (pool_size + overflow) Ć workers < max_connections; add PgBouncer |
Production Notes
- Keep transactions short. Don't call external APIs or do heavy CPU work inside a
with session.begin()block ā you're holding a connection and locks the whole time. Fetch first, transact fast. - Use Decimal for money. Never
float. ANumeric(12, 2)column plus PythonDecimalavoids rounding surprises. - Guard against race conditions. Two concurrent transfers can both read the old balance. For real money, add row locking (
SELECT ... FOR UPDATEviawith_for_update()) or a DB constraint (CHECK (balance >= 0)). - Drive the URL and pool sizes from config. Different environments need different pool sizes ā keep them in
pydantic-settings, never hardcoded.
What's Next
how-to-connect-fastapi-to-postgresā the connection setup this post builds onfastapi-async-sqlalchemyā the same transactions, async stylefastapi-crud-patternsā where these transaction boundaries live in real CRUD codefastapi-error-handlingā turn rollback-triggering errors into clean responses
Recap: a transaction is all-or-nothing ā draw the boundary around writes that belong together, commit once, and let with session.begin() handle commit/rollback. Use savepoints only for genuinely optional sub-steps, give each request its own session, and tune the connection pool so it stays healthy under load.
fastapi-transactions/ # ā
finished
āāā requirements.txt
āāā docker-compose.yml
āāā app/
āāā __init__.py
āāā settings.py # DATABASE_URL from env
āāā database.py # tuned engine (pool_size/pre_ping) + get_session
āāā models.py # Account (Numeric money column)
āāā main.py # atomic /transfer via with session.begin()