Dependency stack — versions and why¶
The framework's runtime stack is chosen by measurement, not by "latest".
Each pin or cap below records the number that justified it, so raising it is a
deliberate re-measurement rather than a drive-by uv lock --upgrade.
The original performance review remains in the private project history. The reproducible measurement commands and fixtures are in the benchmark guide. Numbers below describe their recorded environments; they do not replace measurements of a new release or deployment.
Security floor after the emSign CA incident (2026-10-20)¶
The framework requires certifi>=2026.10.19 for outbound HTTPS trust bundles.
Repository tooling, generated CI and Docker builds use uv 0.13.4 or newer;
the production base is python:3.12.16-slim-trixie.
These are security updates, independent of the performance measurements below.
Existing environments also need pip>=26.2.2: upgrading the system trust store
alone does not replace pip's bundled certificates. Rebuild container images
with --pull --no-cache. The Python security team provides the bootstrap
command and the minimum interpreter versions in the
official incident notice.
How it was measured. CPU instructions per operation under cachegrind
(Linux container, Python 3.11, PYTHONHASHSEED=0): (Ir(N) - Ir(0)) / N, so
app start-up cancels out. HTTP numbers drive the full platform app (all
middlewares, ~80 routes, the route index) as raw ASGI for N = 1000 requests;
Redis and ORM numbers run against the real test Redis / PostgreSQL. Wall-clock
was not used (shared, heavily loaded machine). Run-to-run noise is about ±1 %
at N = 1000 and ±3 % at N = 100.
Web stack: FastAPI 0.136.x, Starlette 1.6.x, pydantic 2.14¶
| instr / request (N = 1000) | before (FastAPI 0.116.1, Starlette 0.47.2, pydantic 2.11.7, SQLAlchemy 2.0.42) | after (0.136.3, 1.6.0, 2.14.0, 2.1.4) | after, GC frozen (before → after) |
|---|---|---|---|
GET /health/simple |
718 k | 774 k (+8 %) | 715 k → 725 k (+1 %) |
| 404 (full route scan) | 589 k | 649 k (+10 %) | 586 k → 602 k (+3 %) |
claims-only authz route (require(Role & Perm)) |
1 262 k | 1 144 k (−9 %) | 1 260 k → 1 099 k (−13 %) |
list[Item] response, 50 items |
3 382 k | 2 270 k (−33 %) | 3 384 k → 2 223 k (−34 %) |
The "GC frozen" column calls gc.collect(); gc.freeze() after warm-up: the
trivial-route increase is almost entirely SQLAlchemy 2.1's larger import graph
making every gen-2 collection dearer (swapping only SQLAlchemy back to 2.0.54
removes it, and it disappears with the GC frozen), not per-request code.
- FastAPI is capped
<0.137. 0.137 began including routers lazily (_IncludedRouter): per-request routing cost grows ~2.4× on Sandloom's app (/health/simple0.80 M → 1.94 M; a 404 0.64 M → 3.06 M; 0.140 / 0.143 are worse still) andapp.router.routesno longer lists the flattened routes the route index and module introspection read. Among 0.130–0.136 the differences are ≤ 2 % (0.133.1 ≈ 1 % cheaper than 0.136.3, inside noise), so the newest pre-lazy release is taken. 0.130+ serialises responses through pydantic-core's Rust JSON path and no longer dumps-and-revalidates a returned model instance — the list response is −32 % on its own. - Starlette ≥ 1.3.1 is a security floor (GHSA-82w8-qh3p-5jfq: form limits
ignored; GHSA-86qp-5c8j-p5mr: Host header; GHSA-wqp7-x3pw-xc5r, …), which
rules out FastAPI 0.130–0.132 (they require Starlette < 1.0). The lock
holds Starlette < 1.7 (
[tool.uv] constraint-dependencies, lock-only — the published metadata keeps just the floor): 1.7.0 costs +6 % on a trivial route (0.724 M → 0.770 M with the GC frozen; 1.3.1, 1.5.1 and 1.6.0 are flat) for features Sandloom does not use (experimental OpenTelemetry middleware,scope["route"]). 1.6.0 includes the 1.5.1Rangehardening. - pydantic 2.14.1 / pydantic-core 2.50.1, pydantic-settings 2.16.1 (2.16
needs python-dotenv ≥ 1.2.2; its alias / union / set-decoding changes do not
touch
Settings, which uses no aliases, set fields or type aliases).
Re-measurement 2026-10-21: caps kept¶
The original measurement used an internal dependency-pinning stand (Python 3.11, cachegrind, N = 1000,
same platform app plus a bench module with a claims-only require(Role & Perm)
route and a 50-item list[Item] route; base = this lock: FastAPI 0.136.3,
Starlette 1.6.0, pydantic 2.14.1, SQLAlchemy 2.1.5). Absolute numbers are this
harness's and are not comparable with the table above; compare within a row.
| instr / request (N = 1000) | base | Starlette 1.7.0 | Starlette 1.9.0 | FastAPI 0.156.8 + Starlette 1.9.0 |
|---|---|---|---|---|
GET /health/simple |
510 k | 545 k (+7 %) | 544 k (+7 %) | 2 450 k (4.8×) |
| 404 (full route scan) | 405 k | 433 k (+7 %) | 436 k (+8 %) | 4 797 k (11.9×) |
| claims-only authz route | 2 043 k | 2 087 k (+2 %) | 2 089 k (+2 %) | 4 114 k (2.0×) |
list[Item], 50 items |
1 296 k | 1 334 k (+3 %) | 1 337 k (+3 %) | 3 219 k (2.5×) |
With the GC frozen after warm-up every cell moves by < 1 %.
- FastAPI stays
<0.137. 0.156 still includes routers lazily:app.router.routeslists 34 entries instead of the 115 flattened routes (module routes such as/bench/itemsare absent; upstream points toiter_route_contexts()), and Sandloom's route-marker pruning turns itself off outside the verified range (sandloom.core.route_markerswarns), so every request pays 2–12× the instructions. - Starlette stays
<1.7in the lock. 1.7–1.9 cost +7 % on a trivial route and +2–3 % on routes that do work; no advisory affects 1.6.0. 1.8 and 1.9 carry robustness fixes (FileResponsestops on client disconnect, multipart range loop, path delimiters escaped inrequest.url, case-insensitiveTrustedHostMiddleware) for paths Sandloom does not serve. Re-evaluate when an advisory affects 1.6.x.
Behaviour change that came with FastAPI ≥ 0.118: UoW scope¶
FastAPI now runs a yield dependency's exit code after the response has been
sent unless the dependency is declared with scope="function". For the request
Unit of Work that meant a client could act on a 200 before the write — and
its on-commit hooks such as token revocation — had committed (the split
conformance suite caught it: a promoted member's next request still saw the
old role; a password change did not yet revoke old tokens). Every framework UoW
dependency (DbSession, Database(name), the authz / identity / tenancy
guards) is declared with scope="function", pinned by
test_uow_commits_before_response.py. A hand-written Depends(get_db) must do
the same — FastAPI caches dependencies per scope, so a mixed declaration opens
a second UoW.
"Double validation" of returned models¶
On FastAPI ≤ 0.129 a returned pydantic instance was dumped to a dict and then
re-validated against response_model. From 0.130 the instance passes through
(revalidate_instances="never"), so the pattern
return [ItemOut.model_validate(row) for row in rows] is validated once. The
remaining difference to returning the ORM rows directly (FastAPI validating
from_attributes itself) is ≈ 5.9 k instructions per item (50 items: 2.31 M vs
2.02 M) — Python call overhead, not a second validation. Endpoints keep the
explicit model_validate: returning ORM objects would put ORM types into the
API layer's signatures, which the ORM-leakage architecture rule and mypy
strict forbid.
SQLAlchemy 2.1, asyncpg 0.32, psycopg2 for migrations¶
| instr / ORM operation (fresh session + transaction, N = 200) | 2.0.42 + asyncpg 0.30 | 2.0.54 + asyncpg 0.32 | 2.1.4 + asyncpg 0.32 |
|---|---|---|---|
select … where id = :x → scalar_one() |
1 691 k | 1 716 k | 1 674 k |
20-row list → scalars().all() |
2 783 k | 2 826 k | 2 714 k |
| insert + flush | 1 882 k | 1 884 k | 1 927 k |
| load + modify + flush | 2 596 k | 2 624 k | 2 592 k |
text("select 1") |
1 154 k | 1 186 k | 1 202 k |
2.1 is within ±4 % of 2.0 per query (reads slightly cheaper, inserts / text()
slightly dearer — 2.1 autoflushes before text() too) and the full suite is
green, so it is adopted. Consequences:
requires-pythonis>=3.11(2.1's floor).- greenlet is no longer installed implicitly: the dependency is
sqlalchemy[asyncio]. - Every PostgreSQL URL names its driver. 2.1 maps a bare
postgresql://to psycopg 3. The app usespostgresql+asyncpg://; migrations usepostgresql+psycopg2://, derived bysandloom.core.migrations.urls.to_sync_url(which also rewrites asyncpg'sssl=query option to libpq'ssslmode=, since psycopg2 rejectsssl=). psycopg2-binary (2.9.13, with 3.14 wheels) stays the single sync driver — the test harness (src/sandloom/core/testing/workers.py) and the test helpers already use it. - The legacy
sqlalchemy.ext.mypyplugin is gone in 2.1 (models are typed withMapped[]); DMLrowcountis read from aCursorResultcast.
redis-py held on 5.2.1 (hiredis 3.5.0)¶
| instr / command (asyncio client, hiredis) | 5.2.1 | 5.3.1 | 7.4.1 | 8.1.0 |
|---|---|---|---|---|
| GET | 239 k | 259 k (+9 %) | 306 k (+28 %) | 387 k (+62 %) |
| SET … EX | 274 k | 295 k (+8 %) | 370 k (+35 %) | 451 k (+65 %) |
| INCR | 241 k | 263 k (+9 %) | 310 k (+29 %) | 390 k (+62 %) |
| pipeline of 8 GETs | 643 k | 665 k (+4 %) | 717 k (+11 %) | 1 236 k (+92 %) |
| XRANGE 32 entries | 699 k | 731 k (+4 %) | 817 k (+17 %) | 903 k (+29 %) |
Re-measured on 2026-10-21 (internal Redis dependency-pinning stand, hiredis 3.5.0, same method; absolute numbers are that harness's):
| instr / command (asyncio client, hiredis 3.5.0) | 5.2.1 | 7.4.1 | 8.1.0 |
|---|---|---|---|
| GET | 223 k | 285 k (+28 %) | 367 k (+65 %) |
| SET … EX | 256 k | 346 k (+35 %) | 430 k (+68 %) |
| INCR | 225 k | 289 k (+28 %) | 371 k (+65 %) |
| pipeline of 8 GETs | 593 k | 663 k (+12 %) | 1 156 k (+95 %) |
| XRANGE 32 entries | 490 k | 560 k (+14 %) | 642 k (+31 %) |
The regressions are unchanged, and the hold stays.
The rule was "move to 7.4 only if needed for security/maintenance and the regression is ≤ 10 %". No advisory affects 5.2.1 and 7.4 regresses 11–35 %, so redis stays on 5.2.1 (RESP2). 5.3.x only adds token-based auth (plus a hard PyJWT dependency) for +4–9 % per command. The trade-off: the 5.x line no longer receives upstream fixes — re-evaluate when a 5.x-affecting advisory appears or 7.x/8.x performance improves. hiredis 3.5.0 is current.
Internal-RPC client: aiohttp 3.14 (httpx kept as fallback)¶
RPC_TRANSPORT=aiohttp is the default outbound client of internal RPC
(sandloom.core.rpc_transport). One real RPC (membership read, conc 64) costs
~0.36 ms total CPU on aiohttp vs ~1.25 ms on httpx 0.28 (caller 0.115 vs
~0.83 ms): httpcore's pool polls every idle connection per request and h11
parses in Python (benchmarks/ladder/RESULTS.md, "RPC transport A/B").
aiohttp is capped >=3.14.5,<3.15: Sandloom subclasses its TCPConnector /
ResponseHandler (pool metrics, and closing a connection that receives
unsolicited bytes while idle) and reads connector internals; widen the range
only with sandloom/core/tests/test_rpc_transports.py green on the new version.
httpx stays a dependency (RPC_TRANSPORT=httpx, test clients).
Everything else¶
Taken at the latest release (security fixes noted): alembic 1.20.0, celery
5.6.3 / kombu 5.6.2, uvicorn 0.54.0 + uvicorn-worker 0.4.0, httpx 0.28.1,
orjson 3.13.0, msgpack 1.2.3 (GHSA-6v7p-g79w-8964), cryptography 50.0.2
(GHSA-g6cj-pr64-35w5, GHSA-jwv3-5hgf-82ww, …), python-multipart 0.0.32 (five
2026 advisories), prometheus-client 0.26.0, PyJWT 2.15.1 (GHSA-hxm8-2xgr-2p9m
and the 2.14/2.15 batch; decode is slower — taken for the fixes), argon2-cffi
25.1.0, authlib 1.9.1 (JOSE/OAuth validation hardening; Sandloom declares it but
imports nothing from it) with joserfc 1.7.7 (JWE AAD / compact-size limits,
non-string jti rejected), pillow 13.0.0 (image-decoder and encoder security fixes),
requests 2.34.2, python-dotenv 1.2.4,
anyio 4.15.1, urllib3 2.8.0, idna 3.20.
uvicorn ≥ 0.36 no longer installs an event-loop policy (it passes a loop
factory to asyncio.run); sandloom.core.cli.gunicorn_conf.server_implementation
reads the running loop instead.
Python 3.14¶
uv sync --frozen --python 3.14 installs the whole locked set from wheels
(no source builds) and the full suite passes on Python 3.14.8 (-n 4).
requires-python stays >=3.11.