Advanced Examples
Examples 57-80 push the task service from a single trusted caller into two real production concerns: authentication (session cookies versus bearer tokens, an issuing endpoint, a middleware-based check and three dependency-based checks, and a read/write access split) and list-endpoint scale (pagination with limit/offset and total/next metadata, filtering by one and by multiple fields, SQL-injection-safe parameterization, and sorting), closed out by an idempotency proof, HTTP method-not-allowed semantics, a liveness-versus-readiness health check, a consistent error envelope across five different failure kinds, a documented curl CRUD-plus-auth script, a full-integration pytest suite spanning CRUD, auth, and pagination together, and a genuine two-process statelessness proof. Every server in this tier runs on port 8003 (Example 80 needs two: 8003 and 8004, since its whole point is two independent OS processes), every example directory is fully self-contained with its own app.py and test_app.py, and every response shown below is real, captured curl and pytest -v output.
Example 57: Sessions vs Tokens
ex-57 · exercises co-17
A session and a bearer token both answer the same question -- who is this caller? -- but they place the state in different places. /login-session mints a server-side dictionary entry and hands the client only an opaque cookie; /login-token hands the client a self-contained credential and keeps no server-side record at all.
%% Color Palette: Blue #0173B2, Orange #DE8F05
graph LR
A["POST /login-session"]:::blue --> B["SESSIONS dict
gets a new entry"]:::blue
B --> C["client holds only
an opaque cookie"]:::blue
D["POST /login-token"]:::orange --> E["client holds the
credential itself"]:::orange
E --> F["server keeps
NO record at all"]:::orange
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-57-sessions-vs-tokens/app.py
"""Example 57: Sessions vs Tokens -- two ways to identify the same caller."""
# => co-17: both mechanisms below answer the SAME question ("who is this caller?") with
# => two different tradeoffs -- session state lives on the server, token state lives on the wire
import uuid # => generates unpredictable session ids -- a real server never uses a guessable id
from fastapi import Cookie, FastAPI, Header, HTTPException, Response # => co-04: header/cookie params
app = FastAPI() # => a fresh app; this example needs no persistence at all
# -- Session mechanism: server holds the state (co-17) -----------------------------------
SESSIONS: dict[str, str] = {} # => session_id -> username; lives ONLY in this process's memory
# => a real deployment would put this in Redis/DB precisely because in-process memory
# => does not survive a restart or scale past one worker (co-05 tension, revisited ex-80)
# -- Token mechanism: the token itself IS the credential (co-17) -------------------------
VALID_TOKEN = "s3cr3t-token-abc123" # => a hardcoded stand-in for a real signed/opaque token
# => (real tokens are cryptographically signed or opaque-and-looked-up -- never a literal, guessable
# => string -- this constant exists purely so curl can present a value the server will accept)
TOKEN_USER = "alice" # => the token's fixed "owner" for this pedagogical example
@app.post("/login-session") # => co-17: issues a SESSION -- server-side state plus a cookie
def login_session(response: Response) -> dict[str, str]:
session_id = str(uuid.uuid4()) # => a fresh, unguessable identifier for this session
SESSIONS[session_id] = "alice" # => the SERVER remembers who this session belongs to
response.set_cookie("session_id", session_id) # => co-04: the CLIENT only gets an opaque id
return {"mechanism": "session", "session_id": session_id} # => confirms issuance to the caller
@app.get("/profile-session") # => co-17: identifies the caller via the session cookie
def profile_session(session_id: str | None = Cookie(default=None)) -> dict[str, str]:
if session_id is None or session_id not in SESSIONS: # => no cookie, or server forgot it
raise HTTPException(status_code=401, detail="no valid session") # => co-03: 401 unauthenticated
username = SESSIONS[session_id] # => O(1) lookup -- but ONLY works on the process that has it
return {"mechanism": "session", "username": username} # => the caller's identity, resolved
@app.post("/login-token") # => co-17, co-18: issues a TOKEN -- no server-side state created
def login_token() -> dict[str, str]:
return {"mechanism": "token", "token": VALID_TOKEN} # => the token itself carries everything needed
@app.get("/profile-token") # => co-17, co-18: identifies the caller via the bearer token alone
def profile_token(authorization: str | None = Header(default=None)) -> dict[str, str]:
if authorization != f"Bearer {VALID_TOKEN}": # => co-04: reads the raw Authorization header
raise HTTPException(status_code=401, detail="missing or invalid token") # => 401, same as session
return {"mechanism": "token", "username": TOKEN_USER} # => resolved WITHOUT any server-side lookupRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== POST /login-session ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:54:32 GMT
server: uvicorn
content-length: 75
content-type: application/json
set-cookie: session_id=cb7cdbbd-0544-4ed7-9364-89c5de3f101b; Path=/; SameSite=lax
{"mechanism":"session","session_id":"cb7cdbbd-0544-4ed7-9364-89c5de3f101b"}
=== GET /profile-session (with cookie) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:54:32 GMT
server: uvicorn
content-length: 42
content-type: application/json
{"mechanism":"session","username":"alice"}
=== GET /profile-session (no cookie) ===
HTTP/1.1 401 Unauthorized
date: Tue, 14 Jul 2026 10:54:32 GMT
server: uvicorn
content-length: 29
content-type: application/json
{"detail":"no valid session"}
=== POST /login-token ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:54:32 GMT
server: uvicorn
content-length: 51
content-type: application/json
{"mechanism":"token","token":"s3cr3t-token-abc123"}
=== GET /profile-token (valid) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:54:32 GMT
server: uvicorn
content-length: 40
content-type: application/json
{"mechanism":"token","username":"alice"}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 4 items
test_app.py::test_session_login_sets_cookie_and_profile_reads_it PASSED [ 25%]
test_app.py::test_profile_session_without_cookie_is_401 PASSED [ 50%]
test_app.py::test_token_login_and_profile_identify_caller PASSED [ 75%]
test_app.py::test_profile_token_without_header_is_401 PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 4 passed, 1 warning in 0.16s =========================Key takeaway: A session needs server-side storage that must be shared across every worker process; a token needs nothing shared at all, because the token itself carries everything a handler needs to verify it.
Why it matters: This is the tradeoff every later example in this cluster builds on: Examples 58-63 develop the token side (issuing, checking via middleware or a dependency, protecting only writes), and Example 64 develops the session side into its own app. Neither mechanism is universally "better" -- sessions make revocation trivial but need shared storage across workers; tokens need no shared storage but are hard to revoke before they expire. Example 80 later proves a session dict would NOT survive across two real worker processes, while a token (or shared database) would.
Example 58: Issue a Token
ex-58 · exercises co-17, co-18
POST /login is the other half of Example 57's token mechanism: given a username and password, it returns a token string the client will present on every later request. This example fixes the credential check and the token to hardcoded constants deliberately, so the focus stays on the request/response shape rather than on cryptography.
%% Color Palette: Blue #0173B2, Teal #029E73
graph LR
A["POST /login<br/>{username, password}"]:::blue --> B{"credentials<br/>match?"}:::blue
B -->|yes| C["200 + a bearer<br/>token string"]:::teal
B -->|no| D["401 --<br/>no token issued"]:::blue
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-58-issue-token/app.py
"""Example 58: Issue a Token -- POST /login returns a token string."""
# => co-17: this is the OTHER half of Example 57's token mechanism -- where does the token
# => a client presents on every later call actually come FROM? This is that issuing endpoint.
from fastapi import FastAPI, HTTPException # => co-17, co-18: raises 401 on bad credentials
from pydantic import BaseModel # => co-10: a typed model validates the login body
app = FastAPI() # => a fresh app -- no persistence, no session store, nothing survives a restart
VALID_TOKEN = "s3cr3t-token-abc123" # => hardcoded stand-in for a real signed/opaque token
# => (a real login endpoint would look up the user, verify a hashed password, then mint a
# => fresh signed JWT or opaque token -- this example fixes ALL of that to one literal string
# => so the FOCUS stays on the request/response SHAPE, not on cryptography or password hashing)
USERNAME = "alice" # => the ONE registered user this pedagogical example recognizes
PASSWORD = "wonderland" # => a hardcoded password -- never do this in real code (co-17 caveat)
class Credentials(BaseModel): # => co-10: the shape POST /login expects
username: str # => a plain required string field
password: str # => a plain required string field, never returned in any response
# => (a real API would also never LOG this field -- pydantic gives no free redaction,
# => so that discipline has to be a deliberate choice in whatever logs the request)
class TokenResponse(BaseModel): # => co-09: declares the exact shape of a successful login
token: str # => the ONLY thing the client needs to authenticate future requests
@app.post("/login", response_model=TokenResponse) # => co-17, co-18: issues a token, no session created
def login(credentials: Credentials) -> TokenResponse:
# => co-10: FastAPI already validated `credentials` matches the Credentials shape above --
# => by the time this line runs, both fields are guaranteed to be present strings
if credentials.username != USERNAME or credentials.password != PASSWORD: # => co-10: reject any mismatch immediately -- deliberately the SAME message for both
# => a wrong username and a wrong password, so a caller can't enumerate valid usernames
raise HTTPException(status_code=401, detail="invalid credentials") # => co-03: 401, not 404
return TokenResponse(token=VALID_TOKEN) # => co-09: the response body IS just the token --
# => no session id, no server-side record created -- co-17's defining difference from ex-57Run: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== POST /login (correct credentials) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:54:58 GMT
server: uvicorn
content-length: 31
content-type: application/json
{"token":"s3cr3t-token-abc123"}
=== POST /login (wrong password) ===
HTTP/1.1 401 Unauthorized
date: Tue, 14 Jul 2026 10:54:58 GMT
server: uvicorn
content-length: 32
content-type: application/json
{"detail":"invalid credentials"}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_login_with_correct_credentials_returns_token PASSED [ 33%]
test_app.py::test_login_with_wrong_password_is_401 PASSED [ 66%]
test_app.py::test_login_missing_field_is_422 PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.17s =========================Key takeaway: A login endpoint returns ONLY the credential a client needs -- never a session id, never any other server-side bookkeeping -- and rejects a wrong username exactly the same way it rejects a wrong password, so a caller can never enumerate valid usernames from the error message alone.
Why it matters: Every one of Examples 59-63's protected routes assumes a token already exists in the caller's hands; this example is where that token would have come from in a real deployment. The identical-error-message discipline (same 401 body for "no such user" and "wrong password") is a genuine security practice, not a stylistic choice -- an API that distinguishes the two responses hands an attacker a free username-enumeration oracle, one guess at a time.
Example 59: Token-Check Middleware
ex-59 · exercises co-18, co-16
A middleware function wraps every single request before routing ever decides which handler runs, which makes it the right place for a cross-cutting concern like "is this caller allowed in?" The entire authorization policy for this app lives in one if statement, instead of being copy-pasted into every handler that needs it.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["request arrives"]:::blue --> B{"path starts with
/protected?"}:::blue
B -->|no| C["call_next() --
handler runs unchanged"]:::teal
B -->|yes| D{"Authorization header
matches token?"}:::orange
D -->|no| E["401 JSONResponse --
handler NEVER runs"]:::orange
D -->|yes| F["request.state.user set,
call_next() runs"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-59-token-check-middleware/app.py
"""Example 59: Token-Check Middleware -- validates a Bearer token on protected routes."""
# => co-16: a MIDDLEWARE runs on every request BEFORE routing decides which handler runs --
# => that makes it the right place for a cross-cutting concern like "is this caller allowed in?"
# => rather than repeating the same check inside every individual handler function
from collections.abc import Awaitable, Callable # => precise typing for the middleware's signature
from fastapi import FastAPI, Request, Response # => co-16: middleware operates on raw Request/Response
from fastapi.responses import JSONResponse # => co-11: builds the structured 401 body by hand
app = FastAPI() # => a fresh app -- this example needs no database, only routing + middleware
VALID_TOKEN = "s3cr3t-token-abc123" # => hardcoded stand-in for a real signed/opaque token
@app.middleware("http") # => co-16: registers a function that wraps EVERY request/response --
# => this decorator form is Starlette's original middleware API; FastAPI also accepts
# => `app.add_middleware(...)` for class-based middleware, but a function is simplest here
async def token_check_middleware(
request: Request, # => co-16: the INCOMING request -- path, method, and headers are readable here
call_next: Callable[[Request], Awaitable[Response]], # => co-16: calling this continues the chain
) -> Response:
# => co-16: this `if` is the ENTIRE authorization policy for the whole app in one place --
# => change what "needs a token" means by editing this one condition, not N handler functions
if request.url.path.startswith("/protected"): # => co-16: only THIS path prefix is guarded --
# => Example 63 explores a read/write split instead of a path-prefix split like this one
auth_header = request.headers.get("authorization") # => co-04: reads the raw header --
# => note the lowercase key: HTTP header names are case-insensitive, and Starlette's
# => Headers mapping normalizes lookups accordingly regardless of the wire casing
if auth_header != f"Bearer {VALID_TOKEN}": # => co-18: exact string match, no partial credit --
# => this branch fires for BOTH a missing header (None != "Bearer ...") and a garbage one
return JSONResponse( # => co-11: a structured envelope, never a stack trace or bare text
status_code=401, # => co-03: 401 Unauthorized -- "who you claim to be" was rejected
content={"error": {"code": "unauthorized", "message": "missing or invalid token"}},
) # => co-16: returning HERE short-circuits the chain -- call_next() never runs,
# => so the guarded handler function below never even starts executing
request.state.user = "alice" # => co-16: a valid token attaches identity for the handler to use --
# => request.state is a plain per-request namespace; middleware writes to it, handlers read it
return await call_next(request) # => co-16: hands off to routing -- unmodified for open paths,
# => and for /protected paths that passed the check above
@app.get("/public") # => never touches the middleware's token branch at all -- doesn't start with /protected
def public() -> dict[str, str]:
return {"access": "public"} # => reachable with zero headers, zero token, always
@app.get("/protected/data") # => co-18: only reachable with a valid token, per the middleware above --
# => this handler body itself contains NO auth logic whatsoever -- that separation is the whole point
def protected_data(request: Request) -> dict[str, str]:
return {"access": "protected", "user": request.state.user} # => identity set by the middleware,
# => not re-derived here -- the handler simply trusts what already ran before itRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /public (no token needed) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:55:00 GMT
server: uvicorn
content-length: 19
content-type: application/json
{"access":"public"}
=== GET /protected/data (valid token) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:55:00 GMT
server: uvicorn
content-length: 37
content-type: application/json
{"access":"protected","user":"alice"}
=== GET /protected/data (no token) ===
HTTP/1.1 401 Unauthorized
date: Tue, 14 Jul 2026 10:55:00 GMT
server: uvicorn
content-length: 70
content-type: application/json
{"error":{"code":"unauthorized","message":"missing or invalid token"}}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_public_route_needs_no_token PASSED [ 33%]
test_app.py::test_protected_route_with_valid_token_reaches_handler PASSED [ 66%]
test_app.py::test_protected_route_without_token_is_401 PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.15s =========================Key takeaway: Returning a response directly from inside middleware -- instead of calling call_next(request) -- short-circuits the whole chain: the guarded handler's own body never even starts executing for a rejected request.
Why it matters: Middleware and a per-route dependency (the style Examples 60-63 use instead) solve the same problem at different scopes: middleware guards by URL PATH PREFIX with one rule for a whole section of the app, while a dependency guards per-ROUTE with explicit opt-in on each handler. Choosing between them is a real architectural decision -- middleware is less code when an entire path family shares one policy, but a dependency is more precise when only some routes under a shared prefix need protection, which is exactly the situation Example 63 explores next.
Example 60: Missing Token
ex-60 · exercises co-18, co-11
This is the first of three 401 scenarios (Examples 60, 61, 62) that all reuse the exact same require_token dependency -- only what curl sends differs between them. Here, curl sends NO Authorization header at all, which HTTPBearer(auto_error=False) reports as credentials is None.
%% Color Palette: Blue #0173B2, Orange #DE8F05
graph LR
A["request with NO<br/>Authorization header"]:::blue --> B["require_token:<br/>credentials is None"]:::orange
B --> C["401 --<br/>handler never runs"]:::orange
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-60-missing-token-401/app.py
"""Example 60: Missing Token -- a protected route with no Authorization header at all."""
# => co-18: this is the FIRST of three 401 scenarios (60, 61, 62) that all reuse the SAME
# => dependency below -- only the request curl sends differs, which is the whole teaching point
from fastapi import Depends, FastAPI, HTTPException, Request # => co-23: Depends injects the auth check
from fastapi.responses import JSONResponse # => co-11: builds the exception handler's structured body
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer # => co-18: standard bearer scheme
app = FastAPI() # => a fresh app -- this example needs no database, only auth plumbing
@app.exception_handler(HTTPException) # => co-11: UNWRAPS FastAPI's default {"detail": ...} nesting --
# => without this handler, raising HTTPException(detail={"error": {...}}) would ship as
# => {"detail": {"error": {...}}}, doubly-nested and inconsistent with every OTHER error path
async def structured_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
body = (
exc.detail
if isinstance(exc.detail, dict) # => the handlers below always raise dict-shaped detail
else {"error": {"code": "error", "message": str(exc.detail)}} # => fallback for plain-string detail
) # => reuses the raised dict AS-IS -- one consistent {"error": {...}} shape, no extra nesting
return JSONResponse(status_code=exc.status_code, content=body) # => co-11: same envelope, every error
VALID_TOKEN = "s3cr3t-token-abc123" # => hardcoded stand-in for a real signed/opaque token
security = HTTPBearer(auto_error=False) # => auto_error=False: WE decide the error shape, not
# => FastAPI's own default (which would raise a bare 403 with a generic "Not authenticated" body)
def require_token( # => co-23: a reusable DEPENDENCY -- any route can opt in via Depends(require_token)
credentials: HTTPAuthorizationCredentials | None = Depends(security), # => co-23: FastAPI calls
# => `security` FIRST, parses any Authorization header it finds, and hands the RESULT here
) -> str:
if credentials is None: # => co-18: no Authorization header was sent AT ALL -- this is THIS
# => example's scenario specifically: curl with no -H "Authorization: ..." flag whatsoever
raise HTTPException( # => co-11: structured envelope, not a bare string
status_code=401, # => co-03: 401 -- the caller never even attempted to identify itself
detail={"error": {"code": "unauthorized", "message": "Authorization header missing"}},
)
if credentials.credentials != VALID_TOKEN: # => header present, but the token itself is wrong --
# => Example 61 is the one that actually exercises THIS branch; unreachable from THIS example's curl
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "invalid token"}},
)
return "alice" # => the resolved identity, available to any handler that depends on this --
# => Example 62 is the one that actually reaches this successful return
@app.get("/protected") # => co-18: guarded entirely by the require_token dependency above --
# => this handler's body contains ZERO auth logic; FastAPI runs the dependency before this even starts
def protected(user: str = Depends(require_token)) -> dict[str, str]:
# => co-23: by the time execution reaches THIS line, `user` is guaranteed to be "alice" --
# => every path that could have failed already returned a 401 response one call frame up
return {"user": user} # => only reached once require_token returns without raisingRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /protected (no Authorization header at all) ===
HTTP/1.1 401 Unauthorized
date: Tue, 14 Jul 2026 10:55:03 GMT
server: uvicorn
content-length: 74
content-type: application/json
{"error":{"code":"unauthorized","message":"Authorization header missing"}}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 2 items
test_app.py::test_no_authorization_header_is_401_with_missing_message PASSED [ 50%]
test_app.py::test_valid_token_still_reaches_handler_for_contrast PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 2 passed, 1 warning in 0.17s =========================Key takeaway: HTTPBearer(auto_error=False) hands a route's dependency the DECISION instead of making it automatically -- auto_error=True (the default) would raise its own generic 403 before this code ever ran, which is why every advanced-tier auth example in this topic sets auto_error=False explicitly.
Why it matters: A custom @app.exception_handler(HTTPException) is what turns a raised HTTPException(detail={"error": {...}}) into a flat, single-shape JSON body instead of FastAPI's default double-nested {"detail": {"error": {...}}}. Every auth example from here through Example 79 registers this exact same handler, because a client parsing error responses should never need to know or care which specific route or dependency produced the failure -- one envelope shape, everywhere, is the whole point of Example 77 later in this batch.
Example 61: Invalid Token
ex-61 · exercises co-18
A token can be wrong in two different ways: entirely absent (Example 60) or present-but-incorrect (this example). A caller sees the identical 401 status and message shape either way, which is deliberate -- an API should never leak which of the two failure modes actually occurred.
learning/code/ex-61-invalid-token-401/app.py
"""Example 61: Invalid Token -- a protected route with a malformed/wrong token."""
# => co-18: a token can be WRONG in two different ways -- entirely absent (Example 60) or
# => present-but-incorrect (this example) -- and a caller sees the SAME 401 status either way,
# => which is deliberate: never leak which half of "no token" vs "bad token" a caller hit
from fastapi import Depends, FastAPI, HTTPException, Request # => co-18: same dependency style as ex-60
from fastapi.responses import JSONResponse # => co-11: builds the exception handler's structured body
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer # => co-18: parses "Bearer <token>"
app = FastAPI() # => a fresh app -- this example needs no database, only auth plumbing
# => (fully self-contained: nothing here is imported from any other example directory)
VALID_TOKEN = "s3cr3t-token-abc123" # => hardcoded stand-in for a real signed/opaque token
security = HTTPBearer(auto_error=False) # => auto_error=False: WE own the 401 body's shape below,
# => not FastAPI's own built-in "Not authenticated" 403 default
# => (co-18: 401 means "I don't know who you are"; 403 means "I know, and you're not allowed")
@app.exception_handler(HTTPException) # => co-11: one consistent {"error": {...}} envelope, unwrapped --
# => without this, every raise HTTPException(detail={"error": {...}}) below would ship doubly
# => nested as {"detail": {"error": {...}}} instead of the flat {"error": {...}} shape shown here
async def structured_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
body = (
exc.detail
if isinstance(exc.detail, dict) # => every raise below already supplies a dict -- this
# => isinstance check exists so the handler ALSO degrades gracefully for a plain string detail
else {"error": {"code": "error", "message": str(exc.detail)}}
)
return JSONResponse(status_code=exc.status_code, content=body) # => co-11: same shape, every error
def require_token( # => co-23: identical dependency SHAPE to Example 60 -- the difference this
# => example actually exercises lives entirely in what curl sends, not in this function's code
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str:
if credentials is None: # => genuinely absent -- Example 60's case, still handled here for completeness
# => so this dependency stays a fully correct, standalone, self-contained auth check on its own
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "Authorization header missing"}},
)
if credentials.credentials != VALID_TOKEN: # => co-18: THIS example's focus -- present but WRONG --
raise HTTPException( # => a well-formed "Bearer <garbage>" header still fails the equality check;
# => HTTPBearer already stripped the "Bearer " prefix, so `.credentials` is just the raw string
status_code=401,
detail={"error": {"code": "unauthorized", "message": "token is invalid"}},
)
return "alice" # => the resolved identity -- unreachable from THIS example's own curl scenario,
# => since the token sent below is deliberately wrong, but still needed for require_token to type-check
# => (the type checker verifies EVERY code path returns str, reachable or not at runtime)
@app.get("/protected") # => co-18: guarded entirely by the require_token dependency above --
# => the handler itself stays a single line, exactly like every open route -- FastAPI resolves
# => `require_token` BEFORE this function body runs, and never calls it at all if that dependency raises
def protected(user: str = Depends(require_token)) -> dict[str, str]:
return {"user": user} # => never actually reached when the curl below sends a garbage tokenRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /protected (garbage token) ===
HTTP/1.1 401 Unauthorized
date: Tue, 14 Jul 2026 10:55:05 GMT
server: uvicorn
content-length: 62
content-type: application/json
{"error":{"code":"unauthorized","message":"token is invalid"}}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 2 items
test_app.py::test_malformed_token_is_401_with_invalid_message PASSED [ 50%]
test_app.py::test_valid_token_still_reaches_handler_for_contrast PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 2 passed, 1 warning in 0.15s =========================Key takeaway: HTTPBearer already strips the literal "Bearer " prefix before this code runs, so credentials.credentials is just the raw token string -- comparing it to VALID_TOKEN is a single, simple equality check, not string parsing.
Why it matters: 401 means "I don't know who you are"; 403 means "I know who you are, and you're still not allowed." This example only ever returns 401, because nothing in this app distinguishes identity from permission -- a real deployment with roles or scopes would need a SEPARATE 403 path once identity is established but the specific action is still disallowed, a distinction Examples 60-62 deliberately keep out of scope so the auth mechanism itself stays the clear focus.
Example 62: Valid Token
ex-62 · exercises co-18
The third scenario in the 401 trio: the exact same guarded route and the exact same require_token dependency as Examples 60 and 61, but this time curl sends the genuinely correct token, so the request clears the check and reaches the handler body.
learning/code/ex-62-valid-token-200/app.py
"""Example 62: Valid Token -- a protected route with a good token succeeds."""
# => co-18: this is the THIRD scenario in the 401 trio (Examples 60, 61, 62) -- same dependency,
# => same guarded route, only the curl's Authorization header changes across all three examples
from fastapi import Depends, FastAPI, HTTPException, Request # => co-23: Depends wires the auth check in
from fastapi.responses import JSONResponse # => co-11: builds the exception handler's structured body
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer # => co-18: parses "Bearer <token>"
app = FastAPI() # => a fresh app -- this example needs no database, only auth plumbing
VALID_TOKEN = "s3cr3t-token-abc123" # => hardcoded stand-in for a real signed/opaque token
security = HTTPBearer(auto_error=False) # => auto_error=False: WE own the 401 body's shape below
@app.exception_handler(HTTPException) # => co-11: unwraps FastAPI's default {"detail": ...} nesting
# => so a dict-shaped detail ships flat as {"error": {...}} -- same reusable pattern as ex-60/61
async def structured_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
body = (
exc.detail
if isinstance(exc.detail, dict) # => the raise below always supplies a dict already
else {"error": {"code": "error", "message": str(exc.detail)}} # => fallback for a plain string
)
# => co-11: this handler is the ONLY place status codes get turned into response bodies --
# => every route in this file stays completely free of response-formatting concerns
return JSONResponse(status_code=exc.status_code, content=body) # => co-11: same shape, every error
def require_token( # => co-23: identical shape to ex-60/61 -- collapses the "missing" and "wrong"
# => cases into ONE branch here, since this example's curl only ever needs to prove the OPPOSITE:
# => that a genuinely correct token clears this check and reaches the handler body below
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str:
if credentials is None or credentials.credentials != VALID_TOKEN: # => co-18: either failure mode
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "missing or invalid token"}},
) # => unreachable from THIS example's own curl -- kept only so the function is fully correct
# => and equally usable if a reader copies this file and DOES send a bad token through it
return "alice" # => co-18: THIS example's focus -- the success path, reached only with a good token
# => no session, no server-side lookup -- the token equality check above IS the entire proof
@app.get("/protected") # => co-18: guarded entirely by the require_token dependency above
# => co-18: identical route SHAPE to ex-60/61 -- the only thing changing across the trio is the token
def protected(user: str = Depends(require_token)) -> dict[str, str | bool]:
# => co-23: `user` is guaranteed to be "alice" here -- every failure path already returned 401
return {"user": user, "granted": True} # => a slightly richer body than ex-60/61 to mark success clearlyRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /protected (valid token) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:55:08 GMT
server: uvicorn
content-length: 31
content-type: application/json
{"user":"alice","granted":true}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 2 items
test_app.py::test_valid_token_returns_200_and_grants_access PASSED [ 50%]
test_app.py::test_missing_and_invalid_still_fail_for_contrast PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 2 passed, 1 warning in 0.15s =========================Key takeaway: By the time protected()'s own body runs, user is guaranteed to already equal "alice" -- every path that could have failed already returned a 401 one call frame earlier, so the handler itself needs zero defensive checks of its own.
Why it matters: This three-example progression (60, 61, 62) is a complete equivalence-class test of one dependency: absent input, wrong input, correct input. That is precisely the shape a real test suite should cover for any validation logic, and it is no accident that Example 79's pytest suite later organizes its own token-auth tests the same way -- missing, wrong, and valid, as three separate, explicit test functions.
Example 63: Protect Writes Only
ex-63 · exercises co-18, co-02
A very common real-world policy: anyone can read the catalog, but only an authenticated caller can change it. The HTTP method itself decides which rule applies -- GET /items has no Depends(require_token) at all, while POST and DELETE both opt into it via dependencies=[Depends(require_token)].
%% Color Palette: Blue #0173B2, Orange #DE8F05
graph LR
A["GET /items"]:::blue --> B["no auth check --
open to everyone"]:::blue
C["POST /items"]:::orange --> D{"valid token?"}:::orange
E["DELETE /items/id"]:::orange --> D
D -->|no| F["401 -- write rejected"]:::orange
D -->|yes| G["write proceeds"]:::orange
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-63-protect-writes-only/app.py
"""Example 63: Protect Writes Only -- GET is open, POST/PUT/DELETE require a token."""
# => co-02, co-18: a very common real-world policy -- ANYONE can read the catalog, but only an
# => authenticated caller can change it. The HTTP method itself decides which rule applies below.
from fastapi import Depends, FastAPI, HTTPException, Request # => co-02: method-sensitive protection
from fastapi.responses import JSONResponse # => co-11: builds the exception handler's structured body
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer # => co-18: parses "Bearer <token>"
app = FastAPI() # => a fresh app -- this example needs no database, only an in-memory dict below
VALID_TOKEN = "s3cr3t-token-abc123" # => hardcoded stand-in for a real signed/opaque token
security = HTTPBearer(auto_error=False) # => auto_error=False: WE own the 401 body's shape below
ITEMS: dict[int, str] = {1: "milk", 2: "bread"} # => in-memory store -- fine for this pedagogical example
# => (two seed rows so list_items() below has something visibly non-empty to return before any write)
NEXT_ID = 3 # => module-level counter for the next inserted id (co-05 caveat: not multi-worker safe --
# => Example 80 revisits this exact tension with a REAL, two-process-shared SQLite file instead)
@app.exception_handler(HTTPException) # => co-11: one consistent {"error": {...}} envelope, unwrapped
async def structured_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
body = (
exc.detail
if isinstance(exc.detail, dict) # => the raise below always supplies a dict already
else {"error": {"code": "error", "message": str(exc.detail)}} # => fallback for a plain string
)
return JSONResponse(status_code=exc.status_code, content=body) # => co-11: same shape, every error
def require_token( # => co-18: this dependency is opted into ONLY by write routes below --
# => note the return type is None, not str -- this variant doesn't need the resolved identity,
# => only the yes/no decision of whether the request is allowed to proceed at all
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> None:
if credentials is None or credentials.credentials != VALID_TOKEN: # => co-18: either failure mode
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "missing or invalid token"}},
)
# => co-18: no `return` needed -- reaching the end of this function IS the "allowed" outcome;
# => FastAPI just runs the dependency for its side effect (raise-or-don't) and discards None
@app.get("/items") # => co-02: GET has NO Depends(require_token) at all -- reads are open to everyone
# => co-02: read methods (GET, HEAD) are conventionally safe/idempotent -- open access here mirrors that
def list_items() -> dict[int, str]:
return ITEMS # => co-02: no auth check anywhere on this path -- curl with zero headers succeeds
@app.post("/items", dependencies=[Depends(require_token)]) # => co-02, co-18: WRITE guarded --
# => `dependencies=[...]` runs require_token for its SIDE EFFECT ONLY, without injecting a
# => parameter into create_item's own signature below -- the route itself never sees the token value
def create_item(name: str) -> dict[str, int | str]:
global NEXT_ID # => mutates the module-level counter -- co-05: this is process-local state,
# => exactly the kind of in-memory mutation that breaks the moment a second worker exists
item_id = NEXT_ID
ITEMS[item_id] = name # => the actual write this token protects
NEXT_ID += 1 # => co-05: NOT atomic across concurrent requests in a real multi-threaded deployment
return {"id": item_id, "name": name} # => co-02: 200 by default here, unlike ex-19's explicit 201
@app.delete("/items/{item_id}", dependencies=[Depends(require_token)]) # => co-02, co-18: WRITE guarded --
# => same dependencies=[...] pattern as the POST route above -- consistent policy, one line each
# => co-02: DELETE, POST, and (in a real app) PUT/PATCH all share this exact same guard style
def delete_item(item_id: int) -> dict[str, bool]:
ITEMS.pop(item_id, None) # => the actual write this token protects -- second-arg None means
# => "don't raise KeyError if item_id doesn't exist," making delete naturally idempotent (co-02)
return {"deleted": True} # => co-02: reports success even if the id was already gone -- by designRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /items (open, no token) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:55:10 GMT
server: uvicorn
content-length: 24
content-type: application/json
{"1":"milk","2":"bread"}
=== POST /items?name=eggs (no token, 401) ===
HTTP/1.1 401 Unauthorized
date: Tue, 14 Jul 2026 10:55:10 GMT
server: uvicorn
content-length: 70
content-type: application/json
{"error":{"code":"unauthorized","message":"missing or invalid token"}}
=== POST /items?name=eggs (valid token) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:55:10 GMT
server: uvicorn
content-length: 22
content-type: application/json
{"id":3,"name":"eggs"}
=== GET /items (after write) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:55:10 GMT
server: uvicorn
content-length: 35
content-type: application/json
{"1":"milk","2":"bread","3":"eggs"}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 4 items
test_app.py::test_get_is_open_without_any_token PASSED [ 25%]
test_app.py::test_post_without_token_is_401 PASSED [ 50%]
test_app.py::test_post_with_token_succeeds PASSED [ 75%]
test_app.py::test_delete_without_token_is_401_but_get_still_open PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 4 passed, 1 warning in 0.19s =========================Key takeaway: dependencies=[Depends(require_token)] runs a dependency for its SIDE EFFECT ONLY -- raise or don't -- without injecting any value into the handler's own parameter list, which is the right shape when a route never needs the resolved identity, only the yes/no gate.
Why it matters: This split -- read methods open, write methods guarded -- mirrors HTTP's own safe/idempotent method classification from RFC 9110: GET and HEAD are conventionally safe (no side effects), so leaving them unguarded matches how the web already expects APIs to behave. The in-memory ITEMS dict and NEXT_ID counter this example uses are explicitly flagged as NOT safe under concurrent writes across multiple worker processes -- Example 80 revisits this exact same tension later with a real, file-backed, two-process-safe alternative.
Example 64: Session-Cookie Auth
ex-64 · exercises co-17
This is Example 57's session mechanism made into its own complete, runnable app: POST /login mints server-side state in a SESSIONS dict and sets an httponly cookie; GET /me reads that cookie back and looks up the identity it points to, never re-checking a password.
learning/code/ex-64-session-cookie-auth/app.py
"""Example 64: Session-Cookie Auth -- set a session cookie on login, read it next request."""
# => co-17: this is Example 57's session mechanism made into its OWN complete, runnable app --
# => login mints server-side state, the browser only ever holds an opaque cookie pointing at it
import uuid # => generates the unguessable session id -- never a sequential/predictable value
from fastapi import Cookie, FastAPI, HTTPException, Request, Response # => co-17: cookie-based session
from fastapi.responses import JSONResponse # => co-11: builds the exception handler's structured body
app = FastAPI() # => a fresh app -- this example needs no database, only the in-memory dict below
# => (fully self-contained: nothing here is imported from any other example directory)
SESSIONS: dict[str, str] = {} # => session_id -> username; server-side state (co-17) -- this dict
# => IS the session store; a real deployment would put this in Redis/a DB, precisely because
# => in-process memory vanishes on restart and isn't shared across multiple worker processes
USERNAME = "alice" # => the ONE registered user this pedagogical example recognizes
PASSWORD = "wonderland" # => a hardcoded password -- never do this in real code (co-17 caveat)
# => (a real login endpoint hashes and salts stored passwords -- this constant exists purely
# => so curl has something deterministic to submit; nothing here is meant as production auth advice)
@app.exception_handler(HTTPException) # => co-11: consistent envelope, same pattern as ex-60..63
async def structured_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
body = (
exc.detail
if isinstance(exc.detail, dict) # => every raise below supplies a dict already
else {"error": {"code": "error", "message": str(exc.detail)}} # => fallback for a plain string
)
return JSONResponse(status_code=exc.status_code, content=body) # => co-11: same shape, every error
@app.post("/login") # => co-17: authenticates, then ESTABLISHES a session -- unlike Example 58's
# => token endpoint, this one has a SIDE EFFECT: it writes a new entry into SESSIONS below
def login(username: str, password: str, response: Response) -> dict[str, str]:
if username != USERNAME or password != PASSWORD: # => co-17: reject any mismatch immediately --
# => deliberately the SAME message for both a wrong username and a wrong password --
# => never let a caller distinguish "no such user" from "wrong password" via the response
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "invalid credentials"}},
)
session_id = str(uuid.uuid4()) # => a fresh, unguessable session identifier -- a real deployment
# => also rotates this on every login, so an old leaked cookie can't be replayed indefinitely
SESSIONS[session_id] = username # => co-17: the SERVER remembers this session belongs to username
response.set_cookie( # => co-04: writes a Set-Cookie response header -- curl's -c flag captures it
"session_id", session_id, httponly=True
) # => co-04: httponly prevents client-side JS from reading it -- mitigates cookie-theft via XSS
return {"logged_in_as": username} # => co-17: confirms WHO logged in -- never the session id itself
# => (leaking the session id in the JSON body too would defeat httponly's whole protection above)
@app.get("/me") # => co-17: identifies the caller purely from the session cookie on THIS request --
# => curl's -b flag is what actually SENDS the cookie captured by -c above back to this endpoint
def me(session_id: str | None = Cookie(default=None)) -> dict[str, str]:
if session_id is None or session_id not in SESSIONS: # => no cookie, or the server never issued it
# => (also covers a server restart, since SESSIONS is in-memory and empties on every reload)
raise HTTPException(
status_code=401,
detail={"error": {"code": "unauthorized", "message": "no active session"}},
)
return {"username": SESSIONS[session_id]} # => the SAME identity established at login, now confirmed
# => co-17: notice this handler never re-checks a password -- the cookie's PRESENCE in
# => SESSIONS is the entire proof of identity for every request after the original loginRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another (using -c/-b to manage a cookie jar), plus pytest -v against the same directory.
Output (curl):
=== POST /login (writes cookie jar) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:55:13 GMT
server: uvicorn
content-length: 24
content-type: application/json
set-cookie: session_id=b781451b-6fb9-492d-b828-a25f2d7c3ea1; HttpOnly; Path=/; SameSite=lax
{"logged_in_as":"alice"}
=== cookie jar contents ===
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 session_id b781451b-6fb9-492d-b828-a25f2d7c3ea1
=== GET /me (reads cookie jar) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:55:13 GMT
server: uvicorn
content-length: 20
content-type: application/json
{"username":"alice"}
=== GET /me (no cookie jar) ===
HTTP/1.1 401 Unauthorized
date: Tue, 14 Jul 2026 10:55:13 GMT
server: uvicorn
content-length: 63
content-type: application/json
{"error":{"code":"unauthorized","message":"no active session"}}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_login_sets_cookie_and_me_reads_it_back PASSED [ 33%]
test_app.py::test_me_without_prior_login_is_401 PASSED [ 66%]
test_app.py::test_login_with_wrong_password_never_sets_a_cookie PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.15s =========================Key takeaway: response.set_cookie("session_id", session_id, httponly=True) prevents client-side JavaScript from ever reading the cookie's value, which is exactly what makes a session cookie meaningfully harder to steal via a cross-site-scripting attack than a token stored in, say, localStorage.
Why it matters: curl's -c flag writes every Set-Cookie response header into a local cookie-jar file, and -b reads that same file back on a later request -- this is precisely what a real browser does automatically and invisibly, which is why session-cookie auth "just works" for browser-based clients without any extra client-side code, unlike a bearer token, which a browser-based client has to manage and attach explicitly on every request.
Example 65: Pagination limit/offset
ex-65 · exercises co-19
GET /tasks?limit=&offset= slices a 25-row seeded dataset into a bounded page: limit caps how many rows come back, offset skips ahead past earlier rows. Both are typed, constrained Query() parameters -- ge=1 and ge=0 respectively -- so an invalid value is rejected before the handler body ever runs.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["GET /tasks?<br/>limit=5&offset=10"]:::blue --> B["SQL: LIMIT 5<br/>OFFSET 10"]:::orange
B --> C["rows 11-15,<br/>in id order"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-65-pagination-limit-offset/app.py
"""Example 65: Pagination limit/offset -- GET /tasks?limit=&offset= slices the list."""
import os
import sqlite3
from typing import TypedDict
from fastapi import FastAPI, Query # => co-12: limit/offset are typed QUERY parameters
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: a real on-disk SQLite file,
# => one PER EXAMPLE directory -- never shared with any other example, keeping each self-contained
# => co-15: the schema below backs every list query in this example. Column-by-column:
# => id -- an auto-incrementing primary key, never chosen by the caller
# => title -- the task's display text, no constraint on it beyond NOT NULL
# => status -- co-20: a FILTERABLE field, exercised starting at Example 69
# => priority -- co-20: a SECOND filterable field, exercised starting at Example 70
# => created_at -- co-20: a SORTABLE field, exercised starting at Example 72
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column
priority: str # => matches the schema's priority column
created_at: str # => matches the schema's created_at column
def _seed(conn: sqlite3.Connection) -> None:
statuses = ["todo", "in_progress", "done"] # => rotates every 3 rows -- index is i % 3
priorities = ["low", "normal", "high"] # => rotates every 2 rows via (i // 2) % 3 -- DECORRELATED
# => from status ON PURPOSE, so that filtering by BOTH fields together (Example 70) genuinely
# => narrows the result further than either single filter alone, instead of the two columns
# => always moving in lockstep (which would make a combined filter demonstrate nothing new)
rows = [
(f"task {i}", statuses[i % 3], priorities[(i // 2) % 3], f"2026-07-{i:02d}T00:00:00")
for i in range(1, 26) # => 25 rows -- enough to make a small page window visibly DIFFERENT
# => from the full list, unlike a 1-2 row toy dataset where pagination wouldn't be provable
]
conn.executemany( # => co-14: a single batched INSERT for all 25 rows, one transaction
"INSERT INTO tasks (title, status, priority, created_at) VALUES (?, ?, ?, ?)", # => co-14: ?
# => placeholders -- the SAME parameterization principle Example 71 stress-tests directly
rows,
)
conn.commit() # => co-14: commits the whole seed batch at once
def init_db() -> None: # => co-15: fresh schema + seed data every time this module is imported
if os.path.exists(DB_PATH):
os.remove(DB_PATH) # => start from a known, deterministic state -- this example's expected
# => row counts and ids only hold true starting from a CLEAN file, never an accumulated one
conn = sqlite3.connect(DB_PATH)
conn.execute(SCHEMA) # => co-15: creates the table every query below depends on existing
_seed(conn) # => populates it with the 25-row deterministic dataset described above
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to the DB --
# => every query function below calls THIS instead of sqlite3.connect() directly
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name, which is
# => what makes `dict(row)` below produce sensible {"id":..., "title":...} keys, not just a tuple
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
def list_tasks(limit: int, offset: int) -> list[TaskRow]: # => co-14, co-19: the repository function itself
# => owns 100% of the SQL for this example -- co-24: the ONLY place LIMIT/OFFSET semantics live
conn = get_connection()
cursor = conn.execute( # => co-14: LIMIT/OFFSET are parameterized, never string-interpolated --
# => an f-string here would reopen the exact SQL-injection risk Example 71 stress-tests
"SELECT id, title, status, priority, created_at FROM tasks ORDER BY id LIMIT ? OFFSET ?",
(limit, offset), # => co-19: THIS example's core mechanism -- limit caps the page size,
# => offset skips ahead -- the exact two numbers a caller controls via query params below
) # => co-15: ORDER BY id keeps page boundaries STABLE across calls -- without an explicit
# => order, SQLite offers no guarantee that repeated queries return rows in the same sequence
result = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> plain dict
conn.close() # => co-14: closed immediately after this one query -- no connection pooling needed here
return result # type: ignore[return-value] # => shape matches TaskRow at runtime
init_db() # => co-15: runs once at import time, before the app starts serving -- every curl call
# => below sees the SAME 25-row dataset, since nothing in this example ever re-seeds mid-run
app = FastAPI() # => a fresh app -- this example needs no auth, only the pagination mechanism
@app.get("/tasks") # => co-19: the paginated list endpoint this whole example is about --
# => FastAPI parses `?limit=&offset=` from the URL and hands them to this function as plain ints
def get_tasks(
limit: int = Query(default=10, ge=1), # => co-12: a typed, constrained query parameter --
# => ge=1 rejects zero/negative limits before the handler body ever runs (co-10) --
# => a caller who omits `?limit=` entirely gets the default of 10, proven by Example 66
offset: int = Query(default=0, ge=0), # => co-12: how many rows to skip before the page starts --
# => ge=0 rejects a negative offset the same way limit rejects a non-positive value above
) -> list[TaskRow]:
return list_tasks(limit, offset) # => co-19: the handler holds NO SQL -- co-24 layering --
# => every subsequent pagination/filter/sort example in this topic reuses this exact split:
# => a thin route function delegating to a repository function that owns the actual queryRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /tasks (default limit=10, offset=0) ===
[{"id":1,"title":"task 1","status":"in_progress","priority":"low","created_at":"2026-07-01T00:00:00"},{"id":2,"title":"task 2","status":"done","priority":"normal","created_at":"2026-07-02T00:00:00"},{"id":3,"title":"task 3","status":"todo","priority":"normal","created_at":"2026-07-03T00:00:00"},{"id":4,"title":"task 4","status":"in_progress","priority":"high","created_at":"2026-07-04T00:00:00"},{"id":5,"title":"task 5","status":"done","priority":"high","created_at":"2026-07-05T00:00:00"},{"id":6,"title":"task 6","status":"todo","priority":"low","created_at":"2026-07-06T00:00:00"},{"id":7,"title":"task 7","status":"in_progress","priority":"low","created_at":"2026-07-07T00:00:00"},{"id":8,"title":"task 8","status":"done","priority":"normal","created_at":"2026-07-08T00:00:00"},{"id":9,"title":"task 9","status":"todo","priority":"normal","created_at":"2026-07-09T00:00:00"},{"id":10,"title":"task 10","status":"in_progress","priority":"high","created_at":"2026-07-10T00:00:00"}]
=== GET /tasks?limit=5&offset=10 ===
[{"id":11,"title":"task 11","status":"done","priority":"high","created_at":"2026-07-11T00:00:00"},{"id":12,"title":"task 12","status":"todo","priority":"low","created_at":"2026-07-12T00:00:00"},{"id":13,"title":"task 13","status":"in_progress","priority":"low","created_at":"2026-07-13T00:00:00"},{"id":14,"title":"task 14","status":"done","priority":"normal","created_at":"2026-07-14T00:00:00"},{"id":15,"title":"task 15","status":"todo","priority":"normal","created_at":"2026-07-15T00:00:00"}]Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_default_page_returns_first_ten PASSED [ 33%]
test_app.py::test_limit_5_offset_10_returns_a_different_window PASSED [ 66%]
test_app.py::test_offset_past_the_end_returns_empty PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.15s =========================Key takeaway: ORDER BY id is not decorative -- without an explicit order, SQLite offers no guarantee that repeated identical queries return rows in the same sequence, which would make page boundaries silently unstable across calls.
Why it matters: The 25-row seed (for i in range(1, 26)) is deliberately larger than any single page, so a page's contents are PROVABLY different from the full list -- a 1-2 row toy dataset could never demonstrate that pagination genuinely narrows anything. Every example from here through Example 73 reuses this identical seed shape, which is also why worked-out facts like "status=done matches ids {2,5,8,11,14,17,20,23}" stay correct and checkable across the whole cluster.
Example 66: Pagination Default
ex-66 · exercises co-19
Builds on Example 65's exact same limit/offset mechanism, proving what happens on the OTHER side: a caller who sends no ?limit= at all still gets a bounded page, not the entire 25-row table. DEFAULT_LIMIT = 10 is a named constant specifically so the bound is documented once and reused everywhere, instead of a bare 10 scattered inline.
learning/code/ex-66-pagination-default/app.py
"""Example 66: Pagination Default -- a default limit applies when the param is absent."""
# => co-12, co-19: builds on the SAME limit/offset mechanism as Example 65, this time proving
# => what happens on the OTHER side -- a caller who sends no limit at all still gets a bounded page
import os
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import TypedDict # => co-09: a typed dict shape for what the repository returns
from fastapi import FastAPI, Query # => co-12: query params are typed and validated here
# => (fully self-contained: nothing here is imported from any other example directory)
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite
# => file PER EXAMPLE DIRECTORY -- never shared with any other example, keeping each self-contained
# => co-15: the schema below backs every list query in this example. Column-by-column:
# => id -- an auto-incrementing primary key, never chosen by the caller
# => title -- the task's display text, no constraint on it beyond NOT NULL
# => status -- co-20: a FILTERABLE field, exercised starting at Example 69
# => priority -- co-20: a SECOND filterable field, exercised starting at Example 70
# => created_at -- co-20: a SORTABLE field, exercised starting at Example 72
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape --
# => every field below matches a SCHEMA column one-to-one, so `dict(row)` (further down)
# => produces a value that satisfies this shape without any manual field-by-field mapping
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column
priority: str # => matches the schema's priority column
created_at: str # => matches the schema's created_at column
def _seed(conn: sqlite3.Connection) -> None: # => co-14: builds the SAME deterministic 25-row
# => dataset used by every pagination/filter/sort example in this topic, so worked-out ids
# => like "done={2,5,8,...}" stay correct however many of these example directories exist
statuses = ["todo", "in_progress", "done"] # => rotates every 3 rows -- index is i % 3
priorities = ["low", "normal", "high"] # => rotates every 2 rows via (i // 2) % 3 --
# => DECORRELATED from status ON PURPOSE, so a combined status+priority filter genuinely
# => narrows further than either field alone, instead of the two columns moving in lockstep
rows = [
(f"task {i}", statuses[i % 3], priorities[(i // 2) % 3], f"2026-07-{i:02d}T00:00:00")
for i in range(1, 26) # => the SAME 25-row seed used across the pagination/filter examples --
# => 25 is more than DEFAULT_LIMIT, so the default page is PROVABLY shorter than the full set
]
conn.executemany( # => co-14: a single batched INSERT for all 25 rows, one transaction
"INSERT INTO tasks (title, status, priority, created_at) VALUES (?, ?, ?, ?)", # => co-14:
# => ? placeholders -- the SAME parameterization principle Example 71 stress-tests directly
rows,
)
conn.commit() # => co-14: commits the whole seed batch at once, not row-by-row
def init_db() -> None: # => co-15: fresh schema + seed data every time this module is imported
if os.path.exists(DB_PATH):
os.remove(DB_PATH) # => start from a known, deterministic state -- this example's expected
# => row counts and ids only hold true starting from a CLEAN file, never an accumulated one
conn = sqlite3.connect(DB_PATH)
conn.execute(SCHEMA) # => co-15: creates the table every query below depends on existing
_seed(conn) # => populates it with the 25-row deterministic dataset described above
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to
# => the DB -- every query function below calls THIS instead of sqlite3.connect() directly
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name, which
# => is what makes `dict(row)` below produce sensible {"id":..., "title":...} keys, not a tuple
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
DEFAULT_LIMIT = 10 # => co-19: a NAMED constant -- the exact bound applied when the caller sends
# => nothing at all for `?limit=` -- naming it (rather than a bare 10 inline) means the value is
# => documented once, reused everywhere below, and trivial to find without reading every route
def list_tasks(limit: int, offset: int) -> list[TaskRow]: # => co-14, co-19: identical query shape
# => to every other example in this cluster -- what's NEW here is what CALLS it, further down
conn = get_connection()
cursor = conn.execute( # => co-14: LIMIT/OFFSET are parameterized, never string-interpolated
"SELECT id, title, status, priority, created_at FROM tasks ORDER BY id LIMIT ? OFFSET ?",
(limit, offset), # => co-19: whatever the caller sent (or the default below) lands here
)
result = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> dict
conn.close() # => co-14: closed immediately after this one query
return result # type: ignore[return-value] # => shape matches TaskRow at runtime
def count_tasks() -> int: # => co-19: total row count, used here only to PROVE the default is bounded --
# => this example's curl calls THIS endpoint to show 25 rows exist, then /tasks to show only 10 return
conn = get_connection()
total = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] # => co-14: a single scalar query
conn.close()
return int(total) # => SQLite returns a plain int already -- the cast documents the intent, not a fix
init_db() # => co-15: runs once at import time, before the app starts serving
app = FastAPI() # => a fresh app -- this example needs no auth, only the default-limit mechanism
@app.get("/tasks") # => co-19: this example's focus -- what happens when limit is OMITTED entirely
def get_tasks(
limit: int = Query(default=DEFAULT_LIMIT, ge=1), # => co-12: the default only kicks in when
# => `?limit=` is absent from the URL altogether -- sending `?limit=0` would instead hit ge=1's
# => validation error, which is a DIFFERENT scenario than "the caller said nothing" (this example's)
offset: int = Query(default=0, ge=0), # => co-12: same default-when-absent behavior, for offset
) -> list[TaskRow]:
return list_tasks(limit, offset) # => co-19: the handler holds NO SQL -- co-24 layering
@app.get("/tasks/count") # => a small helper endpoint proving 25 rows genuinely exist behind the default
def get_count() -> dict[str, int]:
return {"total": count_tasks()} # => co-19: contrast THIS number against /tasks's page lengthRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /tasks/count (proves 25 rows exist) ===
{"total":25}
=== GET /tasks (no limit param at all -- default applies) ===
row count: 10
ids: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 2 items
test_app.py::test_omitting_limit_uses_the_default_not_the_full_dataset PASSED [ 50%]
test_app.py::test_explicit_limit_still_overrides_the_default PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 2 passed, 1 warning in 0.19s =========================Key takeaway: A caller omitting ?limit= entirely is a DIFFERENT scenario from a caller explicitly sending ?limit=0 -- the first hits the Query(default=DEFAULT_LIMIT, ...) default, while the second hits the ge=1 validation error -- and confusing the two would be a real bug in a hand-written pagination implementation.
Why it matters: An unbounded default page size is a classic footgun: a table that starts with 25 rows and grows to 25 million over a product's lifetime would turn an unguarded SELECT * into an outage. Applying a sane default the moment pagination is introduced -- rather than retrofitting one after a production incident -- is the cheap, boring choice this example makes concrete.
Example 67: Pagination Metadata
ex-67 · exercises co-19, co-09
Example 65 proved limit/offset slices the list; this example proves a client can also discover how many total rows exist and where the next page starts, without issuing a separate count request. The Page TypedDict wraps items, total, and next into one typed response envelope instead of returning a bare array.
%% Color Palette: Blue #0173B2, Teal #029E73
graph LR
A["GET /tasks?limit=10&offset=0"]:::blue --> B["COUNT(*) query --
total = 25"]:::blue
A --> C["LIMIT/OFFSET query --
this page's 10 rows"]:::blue
B --> D["Page envelope:
items, total, next"]:::teal
C --> D
D --> E["next = 10
(there IS a next page)"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-67-pagination-metadata/app.py
"""Example 67: Pagination Metadata -- the response includes total and next."""
# => co-19: Example 65 proved limit/offset SLICES the list; this example proves a client can also
# => discover how many pages exist and where the next one starts, without a separate count request
import os
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import TypedDict # => co-09: a typed dict shape for what the repository returns
from fastapi import FastAPI, Query # => co-12: query params are typed and validated here
# => (fully self-contained: nothing here is imported from any other example directory)
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite
# => file PER EXAMPLE DIRECTORY -- never shared with any other example, keeping each self-contained
# => co-15: the schema below backs every list query in this example. Column-by-column:
# => id -- an auto-incrementing primary key, never chosen by the caller
# => title -- the task's display text, no constraint on it beyond NOT NULL
# => status -- co-20: a FILTERABLE field, exercised starting at Example 69
# => priority -- co-20: a SECOND filterable field, exercised starting at Example 70
# => created_at -- co-20: a SORTABLE field, exercised starting at Example 72
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape --
# => every field below matches a SCHEMA column one-to-one, so `dict(row)` (further down)
# => produces a value that satisfies this shape without any manual field-by-field mapping
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column
priority: str # => matches the schema's priority column
created_at: str # => matches the schema's created_at column
class Page(TypedDict): # => co-09, co-19: the ENVELOPE shape -- not just a bare array anymore --
# => every list endpoint from THIS example onward returns this shape instead of a raw list
items: list[TaskRow] # => this page's rows -- exactly what Example 65 returned on its own
total: int # => co-19: the FULL table's row count, independent of this page's size
next: int | None # => None means "no further page" -- a real, typed sentinel, not a magic number
def _seed(conn: sqlite3.Connection) -> None: # => co-14: builds the SAME deterministic 25-row
# => dataset used by every pagination/filter/sort example in this topic, so worked-out ids
# => like "done={2,5,8,...}" stay correct however many of these example directories exist
statuses = ["todo", "in_progress", "done"] # => rotates every 3 rows -- index is i % 3
priorities = ["low", "normal", "high"] # => co-19: irrelevant to pagination itself, but kept
# => identical to Example 65's schema/seed so this file stays a complete, self-contained app --
# => rotates every 2 rows via (i // 2) % 3, DECORRELATED from status ON PURPOSE (a combined
# => status+priority filter, exercised starting at Example 70, needs the two fields independent)
rows = [
(f"task {i}", statuses[i % 3], priorities[(i // 2) % 3], f"2026-07-{i:02d}T00:00:00")
for i in range(1, 26) # => 25 total rows -- big enough that a 10-row page genuinely has a next
]
conn.executemany( # => co-14: a single batched INSERT for all 25 rows, one transaction
"INSERT INTO tasks (title, status, priority, created_at) VALUES (?, ?, ?, ?)", # => co-14:
# => ? placeholders -- the SAME parameterization principle Example 71 stress-tests directly
rows,
)
conn.commit() # => co-14: commits the whole seed batch at once, not row-by-row
def init_db() -> None: # => co-15: fresh schema + seed data every time this module is imported
if os.path.exists(DB_PATH):
os.remove(DB_PATH) # => start from a known, deterministic state -- this example's expected
# => row counts and ids only hold true starting from a CLEAN file, never an accumulated one
conn = sqlite3.connect(DB_PATH)
conn.execute(SCHEMA) # => co-15: creates the table every query below depends on existing
_seed(conn) # => populates it with the 25-row deterministic dataset described above
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to
# => the DB -- every query function below calls THIS instead of sqlite3.connect() directly
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name, which
# => is what makes `dict(row)` below produce sensible {"id":..., "title":...} keys, not a tuple
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
def list_page(limit: int, offset: int) -> Page: # => co-14, co-19: repository returns the FULL
# => envelope this time -- not just the rows, but ENOUGH metadata for a client to keep paging
conn = get_connection()
total = int(conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]) # => co-19: the WHOLE
# => table's size, computed with a SEPARATE query -- the LIMIT below never touches this number
cursor = conn.execute( # => co-14: the exact same paginated SELECT as Example 65
"SELECT id, title, status, priority, created_at FROM tasks ORDER BY id LIMIT ? OFFSET ?",
(limit, offset),
)
items = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> dict
conn.close() # => co-14: closed immediately after both queries above finish
next_offset = offset + limit # => co-19: the offset a client would use to fetch the FOLLOWING page
has_more = next_offset < total # => whether that following page would return anything at all --
# => an offset landing exactly ON or past `total` means there is nothing left to return
return {
"items": items, # type: ignore[typeddict-item] # => this page's slice of rows
"total": total, # => co-19: the FULL count, letting a client compute "page 3 of N" itself
"next": next_offset if has_more else None, # => None signals "you have reached the end" --
# => a typed sentinel a client can check with `if page["next"] is not None`, no magic number
}
init_db() # => co-15: runs once at import time, before the app starts serving
app = FastAPI() # => a fresh app -- this example needs no auth, only the pagination envelope
@app.get("/tasks") # => co-19, co-09: returns the metadata envelope, not a bare list -- Example 65's
# => `list[TaskRow]` return type becomes `Page` here, wrapping the same rows with total/next alongside
def get_tasks(
limit: int = Query(default=10, ge=1), # => co-12: identical constraint to Example 65
offset: int = Query(default=0, ge=0), # => co-12: identical constraint to Example 65
) -> Page:
return list_page(limit, offset) # => co-19: the handler holds NO SQL -- co-24 layeringRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /tasks?limit=10&offset=0 (first page) ===
{"items":[{"id":1,"title":"task 1","status":"in_progress","priority":"low","created_at":"2026-07-01T00:00:00"},{"id":2,"title":"task 2","status":"done","priority":"normal","created_at":"2026-07-02T00:00:00"},{"id":3,"title":"task 3","status":"todo","priority":"normal","created_at":"2026-07-03T00:00:00"},{"id":4,"title":"task 4","status":"in_progress","priority":"high","created_at":"2026-07-04T00:00:00"},{"id":5,"title":"task 5","status":"done","priority":"high","created_at":"2026-07-05T00:00:00"},{"id":6,"title":"task 6","status":"todo","priority":"low","created_at":"2026-07-06T00:00:00"},{"id":7,"title":"task 7","status":"in_progress","priority":"low","created_at":"2026-07-07T00:00:00"},{"id":8,"title":"task 8","status":"done","priority":"normal","created_at":"2026-07-08T00:00:00"},{"id":9,"title":"task 9","status":"todo","priority":"normal","created_at":"2026-07-09T00:00:00"},{"id":10,"title":"task 10","status":"in_progress","priority":"high","created_at":"2026-07-10T00:00:00"}],"total":25,"next":10}
=== GET /tasks?limit=10&offset=20 (last page) ===
{"items":[{"id":21,"title":"task 21","status":"todo","priority":"normal","created_at":"2026-07-21T00:00:00"},{"id":22,"title":"task 22","status":"in_progress","priority":"high","created_at":"2026-07-22T00:00:00"},{"id":23,"title":"task 23","status":"done","priority":"high","created_at":"2026-07-23T00:00:00"},{"id":24,"title":"task 24","status":"todo","priority":"low","created_at":"2026-07-24T00:00:00"},{"id":25,"title":"task 25","status":"in_progress","priority":"low","created_at":"2026-07-25T00:00:00"}],"total":25,"next":null}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 2 items
test_app.py::test_first_page_reports_total_and_a_next_offset PASSED [ 50%]
test_app.py::test_last_page_reports_next_as_null PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 2 passed, 1 warning in 0.15s =========================Key takeaway: next: int | None is a real, typed sentinel a client can check with if page["next"] is not None: -- far safer than a magic number like -1 that a client might forget to special-case.
Why it matters: total is computed with a completely SEPARATE SELECT COUNT(*) query from the one that fetches this page's rows -- the LIMIT/OFFSET clause on the second query never touches the first query's result at all. This separation matters: Example 73 later reuses this exact envelope shape but computes total against a FILTERED count, proving the same pattern composes correctly once a WHERE clause enters the picture, rather than silently reporting the whole table's size when a filter is active.
Example 68: Pagination Bounds
ex-68 · exercises co-19, co-10
Example 65's ge=1 stopped a limit too SMALL; this example adds le=MAX_LIMIT to stop one too LARGE. Both bounds live declaratively in the Query() constraint itself -- there is no if limit > 50: raise ... anywhere in this file.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["limit=1000<br/>(client requests too many)"]:::blue --> B{"Query(...,<br/>le=MAX_LIMIT)?"}:::orange
B -->|"exceeds ge/le"| C["422 --<br/>bounds enforced before the handler"]:::orange
B -->|"within bounds"| D["handler runs<br/>normally"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-68-pagination-bounds/app.py
"""Example 68: Pagination Bounds -- a limit over the maximum is rejected with 422."""
# => co-10, co-19: Example 65's `ge=1` stopped a limit too SMALL; this example adds `le=MAX_LIMIT`
# => to stop one too LARGE -- together they turn an unbounded page size into a safely bounded one
import os
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import TypedDict # => co-09: a typed dict shape for what the repository returns
from fastapi import FastAPI, Query # => co-12: query params are typed and validated here
# => (fully self-contained: nothing here is imported from any other example directory)
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite
# => file PER EXAMPLE DIRECTORY -- never shared with any other example, keeping each self-contained
# => co-15: the schema below backs every list query in this example. Column-by-column:
# => id -- an auto-incrementing primary key, never chosen by the caller
# => title -- the task's display text, no constraint on it beyond NOT NULL
# => status -- co-20: a FILTERABLE field, exercised starting at Example 69
# => priority -- co-20: a SECOND filterable field, exercised starting at Example 70
# => created_at -- co-20: a SORTABLE field, exercised starting at Example 72
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape --
# => every field below matches a SCHEMA column one-to-one, so `dict(row)` (further down)
# => produces a value that satisfies this shape without any manual field-by-field mapping
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column
priority: str # => matches the schema's priority column
created_at: str # => matches the schema's created_at column
def _seed(conn: sqlite3.Connection) -> None: # => co-14: builds the SAME deterministic 25-row
# => dataset used by every pagination/filter/sort example in this topic, so worked-out ids
# => like "done={2,5,8,...}" stay correct however many of these example directories exist
statuses = ["todo", "in_progress", "done"] # => rotates every 3 rows -- index is i % 3
priorities = ["low", "normal", "high"] # => rotates every 2 rows via (i // 2) % 3 --
# => DECORRELATED from status ON PURPOSE, so a combined status+priority filter genuinely
# => narrows further than either field alone, instead of the two columns moving in lockstep
rows = [
(f"task {i}", statuses[i % 3], priorities[(i // 2) % 3], f"2026-07-{i:02d}T00:00:00")
for i in range(1, 26) # => 25 rows -- fewer than MAX_LIMIT, so a request AT the max still succeeds
]
conn.executemany( # => co-14: a single batched INSERT for all 25 rows, one transaction
"INSERT INTO tasks (title, status, priority, created_at) VALUES (?, ?, ?, ?)", # => co-14:
# => ? placeholders -- the SAME parameterization principle Example 71 stress-tests directly
rows,
)
conn.commit() # => co-14: commits the whole seed batch at once, not row-by-row
def init_db() -> None: # => co-15: fresh schema + seed data every time this module is imported
if os.path.exists(DB_PATH):
os.remove(DB_PATH) # => start from a known, deterministic state -- this example's expected
# => row counts and ids only hold true starting from a CLEAN file, never an accumulated one
conn = sqlite3.connect(DB_PATH)
conn.execute(SCHEMA) # => co-15: creates the table every query below depends on existing
_seed(conn) # => populates it with the 25-row deterministic dataset described above
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to
# => the DB -- every query function below calls THIS instead of sqlite3.connect() directly
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name, which
# => is what makes `dict(row)` below produce sensible {"id":..., "title":...} keys, not a tuple
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
MAX_LIMIT = 50 # => co-19: the ceiling this example enforces -- prevents an accidental "select
# => everything" call from a caller who passes an enormous limit like ?limit=1000000 on a huge table
def list_tasks(limit: int, offset: int) -> list[TaskRow]: # => co-14, co-19: identical query shape
# => to Example 65 -- this function TRUSTS `limit` is already within bounds by the time it runs
conn = get_connection()
cursor = conn.execute( # => co-14: LIMIT/OFFSET are parameterized, never string-interpolated
"SELECT id, title, status, priority, created_at FROM tasks ORDER BY id LIMIT ? OFFSET ?",
(limit, offset),
)
result = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> dict
conn.close() # => co-14: closed immediately after this one query
return result # type: ignore[return-value] # => shape matches TaskRow at runtime
init_db() # => co-15: runs once at import time, before the app starts serving
app = FastAPI() # => a fresh app -- this example needs no auth, only the bounds-checking mechanism
@app.get("/tasks") # => co-19, co-10: the guard is declared right in the query parameter's constraints --
# => no `if limit > 50: raise ...` anywhere in this file; FastAPI enforces the bound declaratively
def get_tasks(
limit: int = Query(default=10, ge=1, le=MAX_LIMIT), # => co-10: le=50 -- FastAPI REJECTS anything
# => higher with a 422 before this function is even entered, same enforcement point as ge=1 below
offset: int = Query(default=0, ge=0), # => co-12: unrelated to THIS example's bound, kept for parity
) -> list[TaskRow]:
return list_tasks(limit, offset) # => this line never runs at all for an out-of-bounds limit -- co-10
# => validation happens BEFORE the handler body, exactly like any other Pydantic/Query constraintRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /tasks?limit=50 (at the max -- succeeds) ===
row count: 25
=== GET /tasks?limit=1000 (over the max -- 422) ===
HTTP/1.1 422 Unprocessable Content
date: Tue, 14 Jul 2026 10:55:47 GMT
server: uvicorn
content-length: 143
content-type: application/json
{"detail":[{"type":"less_than_equal","loc":["query","limit"],"msg":"Input should be less than or equal to 50","input":"1000","ctx":{"le":50}}]}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_limit_over_maximum_is_422 PASSED [ 33%]
test_app.py::test_limit_at_maximum_is_allowed PASSED [ 66%]
test_app.py::test_limit_zero_is_422 PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.15s =========================Key takeaway: FastAPI rejects an out-of-bounds limit with a 422 BEFORE the handler function is ever entered -- the constraint is enforced at the same validation layer as any other Pydantic/Query rule, not as custom logic inside the route.
Why it matters: MAX_LIMIT = 50 exists specifically to stop a caller from accidentally (or maliciously) requesting ?limit=1000000 against a table that has grown far past this example's 25-row seed -- an unbounded upper limit is exactly the kind of gap that turns a working pagination feature into a resource-exhaustion vector the moment the underlying table gets large in production.
Example 69: Filter by Field
ex-69 · exercises co-20
Builds on Example 65's list endpoint by adding a narrowing dimension alongside pagination: ?status=done returns only the 8 seeded rows whose status column equals "done", out of the full 25-row table.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["GET /tasks?<br/>status=done"]:::blue --> B["WHERE status = ?<br/>appended to the query"]:::orange
B --> C["only matching rows<br/>returned, total reflects filter"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-69-filter-by-field/app.py
"""Example 69: Filter by Field -- ?status=done narrows the list."""
# => co-20: builds on Example 65's list endpoint by adding a NARROWING dimension alongside
# => pagination -- a caller now controls WHICH rows qualify, not just how many come back per page
import os
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import TypedDict # => co-09: a typed dict shape for what the repository returns
from fastapi import FastAPI, Query # => co-12: query params are typed and validated here
# => (fully self-contained: nothing here is imported from any other example directory)
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite
# => file PER EXAMPLE DIRECTORY -- never shared with any other example, keeping each self-contained
# => co-15: the schema below backs every list query in this example. Column-by-column:
# => id -- an auto-incrementing primary key, never chosen by the caller
# => title -- the task's display text, no constraint on it beyond NOT NULL
# => status -- co-20: a FILTERABLE field, exercised starting at Example 69
# => priority -- co-20: a SECOND filterable field, exercised starting at Example 70
# => created_at -- co-20: a SORTABLE field, exercised starting at Example 72
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape --
# => every field below matches a SCHEMA column one-to-one, so `dict(row)` (further down)
# => produces a value that satisfies this shape without any manual field-by-field mapping
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column
priority: str # => matches the schema's priority column
created_at: str # => matches the schema's created_at column
def _seed(conn: sqlite3.Connection) -> None: # => co-14: builds the SAME deterministic 25-row
# => dataset used by every pagination/filter/sort example in this topic, so worked-out ids
# => like "done={2,5,8,...}" stay correct however many of these example directories exist
statuses = ["todo", "in_progress", "done"] # => rotates every 3 rows -- index is i % 3
# => this example's filter is on THIS field, so its exact rotation is what makes counts checkable
priorities = ["low", "normal", "high"] # => rotates every 2 rows via (i // 2) % 3 --
# => DECORRELATED from status ON PURPOSE, so a combined status+priority filter genuinely
# => narrows further than either field alone, instead of the two columns moving in lockstep
rows = [
(f"task {i}", statuses[i % 3], priorities[(i // 2) % 3], f"2026-07-{i:02d}T00:00:00")
for i in range(1, 26) # => same 25-row seed -- status=done matches ids {2,5,8,11,14,17,20,23}
# => (8 of the 25 rows -- verified by counting i % 3 == 2 for i in 1..25, since "done" is index 2)
]
conn.executemany( # => co-14: a single batched INSERT for all 25 rows, one transaction
"INSERT INTO tasks (title, status, priority, created_at) VALUES (?, ?, ?, ?)", # => co-14:
# => ? placeholders -- the SAME parameterization principle Example 71 stress-tests directly
rows,
)
conn.commit() # => co-14: commits the whole seed batch at once, not row-by-row
def init_db() -> None: # => co-15: fresh schema + seed data every time this module is imported
if os.path.exists(DB_PATH):
os.remove(DB_PATH) # => start from a known, deterministic state -- this example's expected
# => row counts and ids only hold true starting from a CLEAN file, never an accumulated one
conn = sqlite3.connect(DB_PATH)
conn.execute(SCHEMA) # => co-15: creates the table every query below depends on existing
_seed(conn) # => populates it with the 25-row deterministic dataset described above
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to
# => the DB -- every query function below calls THIS instead of sqlite3.connect() directly
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name, which
# => is what makes `dict(row)` below produce sensible {"id":..., "title":...} keys, not a tuple
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
def list_tasks(status: str | None) -> list[TaskRow]: # => co-14, co-20: the repository's filter
# => parameter -- None means "no filter at all," a real value means "narrow to exactly this"
conn = get_connection()
if status is None: # => co-20: no filter -- the FULL 25-row table, same query as Example 65
cursor = conn.execute("SELECT id, title, status, priority, created_at FROM tasks ORDER BY id")
else: # => co-14, co-20: a parameterized WHERE clause -- never string-formatted into the SQL,
# => which is exactly what keeps a value like `done' OR '1'='1` (Example 71) inert here too
cursor = conn.execute(
"SELECT id, title, status, priority, created_at FROM tasks WHERE status = ? ORDER BY id",
(status,), # => co-14: the single ? placeholder -- SQLite treats this as DATA, never SQL
)
result = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> dict
conn.close() # => co-14: closed immediately after whichever branch above ran
return result # type: ignore[return-value] # => shape matches TaskRow at runtime
init_db() # => co-15: runs once at import time, before the app starts serving
app = FastAPI() # => a fresh app -- this example needs no auth, only the filter mechanism
@app.get("/tasks") # => co-20: this example's focus -- a single optional filter query param --
# => co-20: `status=done` matches exactly the 8 seeded rows at ids {2,5,8,11,14,17,20,23}
def get_tasks(status: str | None = Query(default=None)) -> list[TaskRow]:
return list_tasks(status) # => co-24: the handler holds no SQL -- it delegates to the repositoryRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /tasks (unfiltered) ===
row count: 25
=== GET /tasks?status=done ===
[{"id":2,"title":"task 2","status":"done","priority":"normal","created_at":"2026-07-02T00:00:00"},{"id":5,"title":"task 5","status":"done","priority":"high","created_at":"2026-07-05T00:00:00"},{"id":8,"title":"task 8","status":"done","priority":"normal","created_at":"2026-07-08T00:00:00"},{"id":11,"title":"task 11","status":"done","priority":"high","created_at":"2026-07-11T00:00:00"},{"id":14,"title":"task 14","status":"done","priority":"normal","created_at":"2026-07-14T00:00:00"},{"id":17,"title":"task 17","status":"done","priority":"high","created_at":"2026-07-17T00:00:00"},{"id":20,"title":"task 20","status":"done","priority":"normal","created_at":"2026-07-20T00:00:00"},{"id":23,"title":"task 23","status":"done","priority":"high","created_at":"2026-07-23T00:00:00"}]Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_no_filter_returns_all_twenty_five PASSED [ 33%]
test_app.py::test_status_done_returns_only_eight_matching_rows PASSED [ 66%]
test_app.py::test_status_with_no_matches_returns_empty PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.18s =========================Key takeaway: The filter value flows through a ? placeholder exactly like every other parameterized value in this topic -- WHERE status = ? with (status,) as the bound parameter -- never string-formatted directly into the SQL text, which is precisely the discipline Example 71 stress-tests against an actual injection attempt.
Why it matters: status is None (omit the query param entirely) and status is not None (a real filter value) are two branches that share almost identical SQL, differing only in whether a WHERE clause is present at all -- this is the simplest possible version of the pattern Example 70 extends to TWO independent filters and Example 73 extends further to filter-plus-sort-plus-paginate all composing in one query.
Example 70: Filter Multiple
ex-70 · exercises co-20
?status=done&priority=high combines two independently optional filters with AND semantics. This is exactly why Example 65's seed deliberately decorrelates status from priority (priority rotates via (i // 2) % 3, not in lockstep with status's own i % 3 rotation) -- the two filters only prove something new if their intersection is genuinely smaller than either alone.
learning/code/ex-70-filter-multiple/app.py
"""Example 70: Filter Multiple -- ?status=done&priority=high combine with AND semantics."""
# => co-20: this is exactly WHY Example 65's seed decorrelates status from priority -- the two
# => filters below only prove something new if their intersection is smaller than either alone
import os # => builds DB_PATH below in an OS-independent way
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import TypedDict # => co-09: a typed dict shape for what the repository returns
from fastapi import FastAPI, Query # => co-12: query params are typed and validated here
# => (fully self-contained: nothing here is imported from any other example directory)
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite
# => file PER EXAMPLE DIRECTORY -- never shared with any other example, keeping each self-contained
# => co-15: the schema below backs every list query in this example. Column-by-column:
# => id -- an auto-incrementing primary key, never chosen by the caller
# => title -- the task's display text, no constraint on it beyond NOT NULL
# => status -- co-20: a FILTERABLE field, exercised starting at Example 69
# => priority -- co-20: a SECOND filterable field, exercised starting at Example 70
# => created_at -- co-20: a SORTABLE field, exercised starting at Example 72
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape --
# => every field below matches a SCHEMA column one-to-one, so `dict(row)` (further down)
# => produces a value that satisfies this shape without any manual field-by-field mapping
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column
priority: str # => matches the schema's priority column
created_at: str # => matches the schema's created_at column
def _seed(conn: sqlite3.Connection) -> None: # => co-14: builds the SAME deterministic 25-row
# => dataset used by every pagination/filter/sort example in this topic, so worked-out ids
# => like "done={2,5,8,...}" stay correct however many of these example directories exist
statuses = ["todo", "in_progress", "done"] # => rotates every 3 rows -- index is i % 3
priorities = ["low", "normal", "high"] # => rotates every 2 rows via (i // 2) % 3 --
# => DECORRELATED from status ON PURPOSE, so a combined status+priority filter genuinely
# => narrows further than either field alone, instead of the two columns moving in lockstep
rows = [
(f"task {i}", statuses[i % 3], priorities[(i // 2) % 3], f"2026-07-{i:02d}T00:00:00")
for i in range(1, 26) # => done={2,5,8,11,14,17,20,23}, high={4,5,10,11,16,17,22,23}
]
conn.executemany( # => co-14: a single batched INSERT for all 25 rows, one transaction
"INSERT INTO tasks (title, status, priority, created_at) VALUES (?, ?, ?, ?)", # => co-14:
# => ? placeholders -- the SAME parameterization principle Example 71 stress-tests directly
rows,
)
conn.commit() # => co-14: commits the whole seed batch at once, not row-by-row
def init_db() -> None: # => co-15: fresh schema + seed data every time this module is imported
if os.path.exists(DB_PATH):
os.remove(DB_PATH) # => start from a known, deterministic state -- this example's expected
# => row counts and ids only hold true starting from a CLEAN file, never an accumulated one
conn = sqlite3.connect(DB_PATH)
conn.execute(SCHEMA) # => co-15: creates the table every query below depends on existing
_seed(conn) # => populates it with the 25-row deterministic dataset described above
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to
# => the DB -- every query function below calls THIS instead of sqlite3.connect() directly
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name, which
# => is what makes `dict(row)` below produce sensible {"id":..., "title":...} keys, not a tuple
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
def list_tasks(status: str | None, priority: str | None) -> list[TaskRow]: # => co-20: TWO
# => independent filters -- each optional on its own, but AND-combined when both are present
conn = get_connection()
clauses: list[str] = [] # => co-14, co-20: builds the WHERE clause incrementally, still
# => fully parameterized -- ONLY the placeholder count/order is dynamic, never the SQL text itself
params: list[str] = [] # => co-14: kept in lockstep with `clauses` -- one entry per ? placeholder
if status is not None: # => co-20: opt-in -- omitting ?status= entirely skips this clause completely
clauses.append("status = ?") # => placeholder ONLY -- never f"status = '{status}'"
params.append(status) # => co-14: the actual value, bound positionally to the ? above
if priority is not None: # => co-20: independently opt-in -- can combine with status or stand alone
clauses.append("priority = ?") # => a SECOND placeholder, combined with AND below
params.append(priority) # => co-14: appended AFTER status's param, matching clause order
where = f" WHERE {' AND '.join(clauses)}" if clauses else "" # => co-20: AND joins every active
# => filter -- zero filters means an empty string here, reproducing Example 65's unfiltered query
cursor = conn.execute( # => co-14: only the CLAUSE STRUCTURE is built dynamically; every value
# => still flows through params as a placeholder, so injection is impossible regardless of input
f"SELECT id, title, status, priority, created_at FROM tasks{where} ORDER BY id", # => co-20
params, # => co-14: 0, 1, or 2 bound values, matching however many clauses were built above
)
result = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> dict
conn.close() # => co-14: closed immediately after this one query
return result # type: ignore[return-value] # => shape matches TaskRow at runtime
init_db() # => co-15: runs once at import time, before the app starts serving
app = FastAPI() # => a fresh app -- this example needs no auth, only the combined-filter mechanism
@app.get("/tasks") # => co-20: this example's focus -- TWO filters combined with AND semantics
def get_tasks(
status: str | None = Query(default=None), # => co-12: independently optional, same as Example 69
priority: str | None = Query(default=None), # => co-12: the SECOND independently optional filter
) -> list[TaskRow]:
return list_tasks(status, priority) # => co-24: the handler holds no SQL, only orchestrationRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /tasks?status=done (single filter) ===
row count: 8
ids: [2, 5, 8, 11, 14, 17, 20, 23]
=== GET /tasks?status=done&priority=high (combined AND filter) ===
[{"id":5,"title":"task 5","status":"done","priority":"high","created_at":"2026-07-05T00:00:00"},{"id":11,"title":"task 11","status":"done","priority":"high","created_at":"2026-07-11T00:00:00"},{"id":17,"title":"task 17","status":"done","priority":"high","created_at":"2026-07-17T00:00:00"},{"id":23,"title":"task 23","status":"done","priority":"high","created_at":"2026-07-23T00:00:00"}]Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 2 items
test_app.py::test_status_alone_returns_eight PASSED [ 50%]
test_app.py::test_status_and_priority_together_narrow_further PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 2 passed, 1 warning in 0.15s =========================Key takeaway: clauses and params are built up incrementally as parallel lists, so the SQL TEXT'S structure is the only dynamic part -- every actual VALUE still flows through a ? placeholder, positionally matched to params, which keeps injection impossible regardless of how many filters end up active.
Why it matters: Zero, one, or two active filters all fall out of the exact same code path: an empty clauses list produces an empty WHERE string, reproducing Example 69's unfiltered case with no special-casing required. This incremental-clause-building technique is the foundation Example 73 builds on directly when it adds pagination and sorting into the same composed query.
Example 71: Filter Parameterized SQL
ex-71 · exercises co-20, co-14
Every other pagination/filter example in this topic already parameterizes its SQL; this example is the one that proves WHY, by including a deliberately vulnerable twin function alongside the safe one actually wired to the route. Sending status=done' OR '1'='1 at the real, parameterized /tasks endpoint returns an empty list -- SQLite treats the entire string as one literal value, not as SQL syntax.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["status=done' OR '1'='1<br/>(malicious input)"]:::blue --> B{"WHERE status = ?<br/>parameterized"}:::orange
B --> C["treated as ONE literal<br/>string value -- no injection"]:::teal
D["string-formatted query<br/>(the vulnerable twin)"]:::orange -.->|"never wired to the route"| E["would let this input<br/>alter the SQL itself"]:::orange
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-71-filter-parameterized-sql/app.py
"""Example 71: Filter Parameterized SQL -- an injection attempt is neutralized."""
# => co-14, co-20: every other pagination/filter example in this topic ALREADY parameterizes
# => its SQL -- this example is the one that proves WHY, by showing the vulnerable alternative
import os
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import TypedDict # => co-09: a typed dict shape for what the repository returns
from fastapi import FastAPI, Query # => co-12: query params are typed and validated here
# => (fully self-contained: nothing here is imported from any other example directory)
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite
# => file PER EXAMPLE DIRECTORY -- never shared with any other example, keeping each self-contained
# => co-15: the schema below backs every list query in this example. Column-by-column:
# => id -- an auto-incrementing primary key, never chosen by the caller
# => title -- the task's display text, no constraint on it beyond NOT NULL
# => status -- co-20: a FILTERABLE field, exercised starting at Example 69
# => priority -- co-20: a SECOND filterable field, exercised starting at Example 70
# => created_at -- co-20: a SORTABLE field, exercised starting at Example 72
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape --
# => every field below matches a SCHEMA column one-to-one, so `dict(row)` (further down)
# => produces a value that satisfies this shape without any manual field-by-field mapping
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column
priority: str # => matches the schema's priority column
created_at: str # => matches the schema's created_at column
def _seed(conn: sqlite3.Connection) -> None: # => co-14: builds the SAME deterministic 25-row
# => dataset used by every pagination/filter/sort example in this topic, so worked-out ids
# => like "done={2,5,8,...}" stay correct however many of these example directories exist
statuses = ["todo", "in_progress", "done"] # => rotates every 3 rows -- index is i % 3
priorities = ["low", "normal", "high"] # => rotates every 2 rows via (i // 2) % 3 --
# => DECORRELATED from status ON PURPOSE, so a combined status+priority filter genuinely
# => narrows further than either field alone, instead of the two columns moving in lockstep
rows = [(f"task {i}", statuses[i % 3], priorities[(i // 2) % 3], f"2026-07-{i:02d}T00:00:00") for i in range(1, 26)]
conn.executemany( # => co-14: a single batched INSERT for all 25 rows, one transaction
"INSERT INTO tasks (title, status, priority, created_at) VALUES (?, ?, ?, ?)", # => co-14:
# => ? placeholders -- the SAME parameterization principle Example 71 stress-tests directly
rows,
)
conn.commit() # => co-14: commits the whole seed batch at once, not row-by-row
def init_db() -> None: # => co-15: fresh schema + seed data every time this module is imported
if os.path.exists(DB_PATH):
os.remove(DB_PATH) # => start from a known, deterministic state -- this example's expected
# => row counts and ids only hold true starting from a CLEAN file, never an accumulated one
conn = sqlite3.connect(DB_PATH)
conn.execute(SCHEMA) # => co-15: creates the table every query below depends on existing
_seed(conn) # => populates it with the 25-row deterministic dataset described above
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to
# => the DB -- every query function below calls THIS instead of sqlite3.connect() directly
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name, which
# => is what makes `dict(row)` below produce sensible {"id":..., "title":...} keys, not a tuple
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
def list_tasks_safe(status: str) -> list[TaskRow]: # => co-14, co-20: the SAFE, parameterized version --
# => this is the ONLY function this example's route actually calls, further down
conn = get_connection()
cursor = conn.execute( # => co-14: the value is bound as a PARAMETER, never concatenated into the SQL
"SELECT id, title, status, priority, created_at FROM tasks WHERE status = ? ORDER BY id",
(status,), # => sqlite3 sends this as DATA, not as part of the SQL grammar at all -- an
# => injection payload like `done' OR '1'='1` is just a literal STRING being compared here
)
result = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> dict
conn.close() # => co-14: closed immediately after this one query
return result # type: ignore[return-value] # => empty list when no row's status equals the payload
def list_tasks_unsafe_for_demo_only( # => co-14: the VULNERABLE version -- exists ONLY as a
status: str, # => teaching contrast; never called by any route, only by this example's own tests
) -> list[TaskRow]:
conn = get_connection()
query = ( # => co-14: string-BUILDING the SQL text itself, not just its parameters
f"SELECT id, title, status, priority, created_at FROM tasks WHERE status = '{status}' ORDER BY id"
)
# => DELIBERATELY f-string-interpolated -- shown ONLY to prove what parameterization prevents,
# => never something a real handler should do; not wired to any route in this app
cursor = conn.execute(query) # => co-14: SQLite parses whatever ends up embedded in `query` as SQL
result = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> dict
conn.close() # => co-14: closed immediately after this one query
return result # type: ignore[return-value] # => ALL 25 rows when the payload closes the quote early
init_db() # => co-15: runs once at import time, before the app starts serving
app = FastAPI() # => a fresh app -- this example needs no auth, only the safe/unsafe query contrast
@app.get("/tasks") # => co-20: the ONLY route this app exposes uses the SAFE repository function --
# => `list_tasks_unsafe_for_demo_only` above is reachable only from Python code (like the tests
# => and curl-equivalent script below), never from any HTTP route a real caller could hit
def get_tasks(status: str = Query(...)) -> list[TaskRow]: # => co-12: required, no default -- omitting
# => `?status=` entirely is a 422, distinct from THIS example's own scenario of a crafted value
return list_tasks_safe(status) # => co-24: the handler always goes through the SAFE pathRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /tasks?status=done' OR '1'='1 (SQL injection attempt, parameterized route) ===
[]
=== GET /tasks?status=done (legitimate filter, for contrast) ===
row count: 8Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 4 items
test_app.py::test_route_neutralizes_the_injection_attempt PASSED [ 25%]
test_app.py::test_safe_repository_function_matches_route_behavior PASSED [ 50%]
test_app.py::test_unsafe_function_demonstrates_the_vulnerability_it_would_have_had PASSED [ 75%]
test_app.py::test_safe_status_lookup_still_works_normally PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 4 passed, 1 warning in 0.16s =========================Key takeaway: list_tasks_unsafe_for_demo_only() exists purely as a teaching contrast -- it is never called by any HTTP route in this app, only directly from Python code (this example's own tests) to prove what parameterization prevents, without ever exposing the vulnerable path to a real caller.
Why it matters: The contrast is the whole point: the SAME injection payload that returns an empty list against the safe, parameterized query returns ALL 25 rows against the unsafe, f-string-interpolated twin -- a single-quote in the payload closes the SQL string literal early, turning status = 'done' OR '1'='1' into a condition that matches every row. Seeing both outcomes side by side, from the identical input, makes parameterization's protection concrete rather than an abstract rule to memorize.
Example 72: Sort Param
ex-72 · exercises co-20
?sort=created_at (ascending) or ?sort=-created_at (descending, via a leading -) reorders the SAME 25 rows -- sorting never changes which rows come back, only the sequence they arrive in. SortValue = Literal["created_at", "-created_at"] is a closed set, so FastAPI 422s any other value automatically.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["GET /tasks?<br/>sort=-created_at"]:::blue --> B{"leading '-'<br/>present?"}:::orange
B -->|yes| C["ORDER BY created_at<br/>DESC"]:::teal
B -->|no| D["ORDER BY created_at<br/>ASC"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-72-sort-param/app.py
"""Example 72: Sort Param -- ?sort=created_at (or -created_at) orders the list."""
import os
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import Literal, TypedDict # => co-12: Literal constrains the sort param to two values
from fastapi import FastAPI, Query # => co-12: query params are typed and validated here
# => (fully self-contained: nothing here is imported from any other example directory)
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite
# => file PER EXAMPLE DIRECTORY -- never shared with any other example, keeping each self-contained
# => co-15: the schema below backs every list query in this example. Column-by-column:
# => id -- an auto-incrementing primary key, never chosen by the caller
# => title -- the task's display text, no constraint on it beyond NOT NULL
# => status -- co-20: a FILTERABLE field, exercised starting at Example 69
# => priority -- co-20: a SECOND filterable field, exercised starting at Example 70
# => created_at -- co-20: a SORTABLE field, exercised starting at Example 72
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape --
# => every field below matches a SCHEMA column one-to-one, so `dict(row)` (further down)
# => produces a value that satisfies this shape without any manual field-by-field mapping
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column
priority: str # => matches the schema's priority column
created_at: str # => matches the schema's created_at column
def _seed(conn: sqlite3.Connection) -> None: # => co-14: builds the SAME deterministic 25-row
# => dataset used by every pagination/filter/sort example in this topic, so worked-out ids
# => like "done={2,5,8,...}" stay correct however many of these example directories exist
statuses = ["todo", "in_progress", "done"] # => rotates every 3 rows -- index is i % 3
priorities = ["low", "normal", "high"] # => rotates every 2 rows via (i // 2) % 3 --
# => DECORRELATED from status ON PURPOSE, so a combined status+priority filter genuinely
# => narrows further than either field alone, instead of the two columns moving in lockstep
rows = [
(f"task {i}", statuses[i % 3], priorities[(i // 2) % 3], f"2026-07-{i:02d}T00:00:00")
for i in range(1, 26) # => created_at already increases WITH id -- ascending sort looks
# => like a no-op, so THIS example's curl deliberately checks descending order too, below
]
conn.executemany( # => co-14: a single batched INSERT for all 25 rows, one transaction
"INSERT INTO tasks (title, status, priority, created_at) VALUES (?, ?, ?, ?)", # => co-14:
# => ? placeholders -- the SAME parameterization principle Example 71 stress-tests directly
rows,
)
conn.commit() # => co-14: commits the whole seed batch at once, not row-by-row
def init_db() -> None: # => co-15: fresh schema + seed data every time this module is imported
if os.path.exists(DB_PATH):
os.remove(DB_PATH) # => start from a known, deterministic state -- this example's expected
# => row counts and ids only hold true starting from a CLEAN file, never an accumulated one
conn = sqlite3.connect(DB_PATH)
conn.execute(SCHEMA) # => co-15: creates the table every query below depends on existing
_seed(conn) # => populates it with the 25-row deterministic dataset described above
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to
# => the DB -- every query function below calls THIS instead of sqlite3.connect() directly
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name, which
# => is what makes `dict(row)` below produce sensible {"id":..., "title":...} keys, not a tuple
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
SortValue = Literal["created_at", "-created_at"] # => co-20: a closed set -- FastAPI 422s anything
# => else, unlike `status`/`priority` in the filter examples, which accept any string at all
_COLUMN_BY_SORT: dict[SortValue, str] = { # => co-14: maps the PUBLIC sort value to a real column +
# => direction -- this dict is the ONLY thing standing between user input and an ORDER BY clause
"created_at": "created_at ASC", # => oldest-first, matching the seed's natural insertion order
"-created_at": "created_at DESC", # => a leading "-" is a common REST convention for "descending"
}
def list_tasks(sort: SortValue) -> list[TaskRow]: # => co-14, co-20: sort is a Literal, so `sort`
# => can ONLY ever be one of the two dict keys above -- pyright itself enforces total coverage
conn = get_connection()
order_clause = _COLUMN_BY_SORT[sort] # => co-14: looked up from a FIXED mapping -- never
# => interpolates raw user input directly into ORDER BY, which parameterization cannot protect
# => against (SQLite has no `?` placeholder syntax for column names or ASC/DESC keywords at all)
cursor = conn.execute(f"SELECT id, title, status, priority, created_at FROM tasks ORDER BY {order_clause}")
result = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> dict
conn.close() # => co-14: closed immediately after this one query
return result # type: ignore[return-value] # => shape matches TaskRow at runtime
init_db() # => co-15: runs once at import time, before the app starts serving
app = FastAPI() # => a fresh app -- this example needs no auth, only the sort mechanism
@app.get("/tasks") # => co-20: this example's focus -- reordering the SAME underlying rows,
# => never changing WHICH rows come back, only the SEQUENCE they arrive in
def get_tasks(sort: SortValue = Query(default="created_at")) -> list[TaskRow]:
return list_tasks(sort) # => co-24: the handler holds no SQL -- it delegates to the repositoryRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /tasks?sort=created_at (ascending) ===
first 3 ids: [1, 2, 3]
last 3 ids: [23, 24, 25]
=== GET /tasks?sort=-created_at (descending) ===
first 3 ids: [25, 24, 23]
last 3 ids: [3, 2, 1]Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_ascending_sort_starts_at_id_1 PASSED [ 33%]
test_app.py::test_descending_sort_reverses_the_order PASSED [ 66%]
test_app.py::test_invalid_sort_value_is_422 PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.19s =========================Key takeaway: User input is never interpolated directly into ORDER BY -- _COLUMN_BY_SORT maps the two allowed PUBLIC values to a fixed SQL fragment, because SQLite has no ? placeholder syntax for column names or ASC/DESC keywords, which parameterization alone cannot protect against.
Why it matters: A Literal type is a genuinely different validation strategy from status/priority's plain str in Examples 69-70: a closed set is appropriate when only a FIXED number of values ever make sense (sort direction), while an open str is appropriate when the valid values come from actual DATA (task statuses, which a real app might add to over time). Choosing the wrong one in either direction is a real design mistake -- a Literal for something that should grow, or a plain str for something that never should.
Example 73: Combined List Query
ex-73 · exercises co-19, co-20
Examples 65-72 built pagination, filtering, and sorting SEPARATELY; this is the capstone-style proof that all three compose correctly in a single endpoint, without one feature silently breaking another. total reflects the FILTERED count, computed with the same WHERE clause and params as the main query -- never the whole unfiltered table.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["?status=done&limit=3
&offset=0&sort=-created_at"]:::blue
A --> B["WHERE status = ?
(filter)"]:::orange
B --> C["ORDER BY created_at DESC
(sort)"]:::orange
C --> D["LIMIT 3 OFFSET 0
(paginate)"]:::teal
D --> E["Page: items, total=8,
next=3"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-73-combined-list-query/app.py
"""Example 73: Combined List Query -- pagination + filter + sort together."""
# => co-19, co-20: Examples 65-72 built pagination, filtering, and sorting SEPARATELY -- this
# => example is the capstone-style proof that all three compose correctly in a single endpoint,
# => without one feature silently breaking another (e.g. total must reflect the FILTER, not ignore it)
import os # => co-14: used for the DB_PATH lookup and the "start fresh" file check below
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import Literal, TypedDict # => co-12: Literal constrains the sort param to two values
from fastapi import FastAPI, Query # => co-12: query params are typed and validated here
# => (fully self-contained: nothing here is imported from any other example directory)
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite
# => file PER EXAMPLE DIRECTORY -- never shared with any other example, keeping each self-contained
# => co-15: the schema below backs every list query in this example. Column-by-column:
# => id -- an auto-incrementing primary key, never chosen by the caller
# => title -- the task's display text, no constraint on it beyond NOT NULL
# => status -- co-20: a FILTERABLE field, exercised starting at Example 69
# => priority -- co-20: a SECOND filterable field, exercised starting at Example 70
# => created_at -- co-20: a SORTABLE field, exercised starting at Example 72
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape --
# => every field below matches a SCHEMA column one-to-one, so `dict(row)` (further down)
# => produces a value that satisfies this shape without any manual field-by-field mapping
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column
priority: str # => matches the schema's priority column
created_at: str # => matches the schema's created_at column
class Page(TypedDict): # => co-09, co-19: the SAME envelope shape as Example 67, reused here
items: list[TaskRow] # => this page's rows, after filtering AND sorting AND slicing
total: int # => co-19, co-20: the FILTERED count -- not the whole 25-row table
next: int | None # => co-19: computed against the filtered total, not the unfiltered one
def _seed(conn: sqlite3.Connection) -> None: # => co-14: builds the SAME deterministic 25-row
# => dataset used by every pagination/filter/sort example in this topic, so worked-out ids
# => like "done={2,5,8,...}" stay correct however many of these example directories exist
statuses = ["todo", "in_progress", "done"] # => rotates every 3 rows -- index is i % 3
priorities = ["low", "normal", "high"] # => rotates every 2 rows via (i // 2) % 3 --
# => DECORRELATED from status ON PURPOSE, so a combined status+priority filter genuinely
# => narrows further than either field alone, instead of the two columns moving in lockstep
rows = [
(f"task {i}", statuses[i % 3], priorities[(i // 2) % 3], f"2026-07-{i:02d}T00:00:00")
for i in range(1, 26) # => the SAME 25-row seed as every other pagination/filter/sort example
]
conn.executemany( # => co-14: a single batched INSERT for all 25 rows, one transaction
"INSERT INTO tasks (title, status, priority, created_at) VALUES (?, ?, ?, ?)", # => co-14:
# => ? placeholders -- the SAME parameterization principle Example 71 stress-tests directly
rows,
)
conn.commit() # => co-14: commits the whole seed batch at once, not row-by-row
def init_db() -> None: # => co-15: fresh schema + seed data every time this module is imported
if os.path.exists(DB_PATH):
os.remove(DB_PATH) # => start from a known, deterministic state -- this example's expected
# => row counts and ids only hold true starting from a CLEAN file, never an accumulated one
conn = sqlite3.connect(DB_PATH)
conn.execute(SCHEMA) # => co-15: creates the table every query below depends on existing
_seed(conn) # => populates it with the 25-row deterministic dataset described above
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to
# => the DB -- every query function below calls THIS instead of sqlite3.connect() directly
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name, which
# => is what makes `dict(row)` below produce sensible {"id":..., "title":...} keys, not a tuple
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
SortValue = Literal["created_at", "-created_at"] # => co-20: same closed set as Example 72
_COLUMN_BY_SORT: dict[SortValue, str] = { # => co-14: the SAME lookup-not-interpolate pattern
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
}
def list_page( # => co-19, co-20: ALL THREE features compose in one repository function --
# => the order below matters: filter narrows the ROWS, sort orders them, THEN limit/offset slices
status: str | None, # => co-20: optional -- None means every status qualifies
limit: int, # => co-19: how many rows this page holds, at most
offset: int, # => co-19: how many filtered-and-sorted rows to skip before this page starts
sort: SortValue, # => co-20: which column/direction orders the result, before slicing happens
) -> Page:
conn = get_connection() # => co-14: one connection, reused for both queries this function issues
clauses: list[str] = [] # => co-14, co-20: the filter half -- still fully parameterized,
# => identical technique to Example 69, just reused inside a function that also sorts and paginates
params: list[str] = [] # => co-14: kept in lockstep with `clauses`, exactly like Example 70
if status is not None: # => co-20: opt-in, exactly like the standalone filter example
clauses.append("status = ?") # => the one filterable column this composed example supports
params.append(status) # => co-14: the bound value, positionally matched to the ? above
where = f" WHERE {' AND '.join(clauses)}" if clauses else "" # => empty string when unfiltered
total = int(conn.execute(f"SELECT COUNT(*) FROM tasks{where}", params).fetchone()[0]) # => co-19: total reflects the FILTERED count, not the whole table -- computed with the
# => SAME where clause and params as the main query below, so the two numbers stay consistent
order_clause = _COLUMN_BY_SORT[sort] # => co-20: the sort half -- looked up, never interpolated raw
cursor = conn.execute( # => co-14, co-19, co-20: filter, sort, AND paginate in one statement
f"SELECT id, title, status, priority, created_at FROM tasks{where} ORDER BY {order_clause} LIMIT ? OFFSET ?", # => co-19: the pagination half, composed last --
# => SQL applies WHERE, then ORDER BY, then LIMIT/OFFSET, in that fixed evaluation order
[*params, limit, offset], # => co-14: filter params FIRST, then limit/offset -- position
# => must match the ? placeholders' left-to-right order in the SQL string exactly above
)
items = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> dict
conn.close() # => co-14: closed immediately after both queries above finish
next_offset = offset + limit # => co-19: identical formula to Example 67, applied to the FILTERED total
has_more = next_offset < total # => whether another filtered-and-sorted page exists past this one
return { # => co-19, co-20: the SAME Page envelope shape as Example 67, now built from a
# => query that filtered, sorted, AND paginated in a single round trip to SQLite
"items": items, # type: ignore[typeddict-item] # => this page's slice, after all three steps
"total": total, # => co-19, co-20: the FILTERED total -- proves filter+pagination interact correctly
"next": next_offset if has_more else None, # => None once the filtered result set is exhausted
}
init_db() # => co-15: runs once at import time, before the app starts serving
app = FastAPI() # => a fresh app -- this example needs no auth, only the composed list mechanism
@app.get("/tasks") # => co-19, co-20: this example's focus -- all three composed in one call --
# => every query param below is INDEPENDENTLY optional, exactly matching its own standalone example
def get_tasks(
status: str | None = Query(default=None), # => co-20: same as Example 69
limit: int = Query(default=10, ge=1, le=50), # => co-19: same bounds as Example 68
offset: int = Query(default=0, ge=0), # => co-19: same as Example 65
sort: SortValue = Query(default="created_at"), # => co-20: same as Example 72
) -> Page:
# => co-24: four independently-optional parameters, one delegated call -- zero SQL in this handler
return list_page(status, limit, offset, sort) # => co-24: the handler holds no SQL at allRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /tasks?status=done&limit=3&offset=0&sort=-created_at (all three composed) ===
{"items":[{"id":23,"title":"task 23","status":"done","priority":"high","created_at":"2026-07-23T00:00:00"},{"id":20,"title":"task 20","status":"done","priority":"normal","created_at":"2026-07-20T00:00:00"},{"id":17,"title":"task 17","status":"done","priority":"high","created_at":"2026-07-17T00:00:00"}],"total":8,"next":3}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_filter_alone_reports_the_filtered_total PASSED [ 33%]
test_app.py::test_filter_plus_pagination_plus_sort_all_compose PASSED [ 66%]
test_app.py::test_second_page_of_the_same_filtered_sorted_query PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.15s =========================Key takeaway: SQL applies WHERE, then ORDER BY, then LIMIT/OFFSET, in that fixed evaluation order -- and the parameter LIST built in this function must match that same left-to-right order (filter params first, then limit, then offset), or the wrong values land in the wrong placeholders.
Why it matters: Every query parameter here -- status, limit, offset, sort -- is INDEPENDENTLY optional, with its own default, exactly matching its own standalone example earlier in this cluster. That independence is what makes composing them safe: a caller supplying only ?status=done gets the exact same filtered-but-unpaginated-beyond-the-default result as Example 69 alone would produce, proving the four features were built to combine from the start rather than needing special-case glue code once combined.
Example 74: Idempotent PUT, Verified
ex-74 · exercises co-02
RFC 9110 classifies PUT as idempotent, but a classification is only a promise. This example actually PROVES it: the same PUT body is sent twice against the same task id, and the row count is checked to confirm it stays at exactly 1 -- no duplicate row, no different final state.
%% Color Palette: Blue #0173B2, Teal #029E73
graph LR
A["PUT /tasks/1<br/>call #1"]:::blue --> C["row state:<br/>{title, status}"]:::teal
B["PUT /tasks/1<br/>call #2, SAME body"]:::blue --> C
C --> D["row count stays at 1 --<br/>verified, not just assumed"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-74-idempotent-put-verified/app.py
"""Example 74: Idempotent PUT, Verified -- the same PUT applied twice leaves the same state."""
# => co-02: RFC 9110 CLASSIFIES PUT as idempotent, but a classification is only a promise --
# => this example actually PROVES it, by calling PUT twice and checking the row count stays 1
import os # => co-14: used for the DB_PATH lookup and the "start fresh" file check below
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import TypedDict # => co-09: a typed dict shape for what the repository returns
from fastapi import FastAPI, HTTPException # => co-03: HTTPException raises the 404 branch below
from pydantic import BaseModel # => co-10: validates the PUT request body's shape
# => (fully self-contained: nothing here is imported from any other example directory)
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite file,
# => PER EXAMPLE DIRECTORY -- never shared with any other example, keeping this one self-contained
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL
);
""" # => co-15: a DELIBERATELY smaller schema than the pagination examples -- this one is about
# => idempotency, not filtering/sorting, so priority/created_at columns would only be noise here
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column
class TaskUpdate(BaseModel): # => co-02, co-10: PUT REPLACES the full resource with this exact shape --
# => unlike PATCH, there is no "only send the fields you're changing" option with PUT
title: str # => required -- omitting it entirely is a 422, not a partial update
status: str # => required -- same rule as title above
def init_db() -> None: # => co-15: fresh schema + one seed row every time this module is imported
if os.path.exists(DB_PATH):
os.remove(DB_PATH) # => start from a known, deterministic state every run
conn = sqlite3.connect(DB_PATH) # => co-14: a plain connection -- no row_factory needed for INSERT
conn.execute(SCHEMA) # => co-15: creates the table every query below depends on existing
conn.execute( # => co-14: a single-row INSERT, parameterized like every write in this topic
"INSERT INTO tasks (title, status) VALUES (?, ?)", ("write the report", "todo")
) # => a single seed row -- id 1, the ONLY row this example's PUT calls will ever target
conn.commit() # => co-14: commits the seed insert
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to the DB
conn = sqlite3.connect(DB_PATH) # => co-14: every read/write function below calls THIS helper
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
def replace_task(task_id: int, update: TaskUpdate) -> TaskRow: # => co-02, co-14: PUT's repository
# => function -- calling this TWICE with the SAME arguments must leave the SAME final state
conn = get_connection() # => co-14: one connection, used for the UPDATE and the confirming SELECT
cursor = conn.execute( # => co-14: cursor.rowcount tells us WHETHER a row actually matched below
"UPDATE tasks SET title = ?, status = ? WHERE id = ?", # => co-14: parameterized, idempotent
# => by design -- an UPDATE that sets a column to the value it ALREADY holds changes nothing
(update.title, update.status, task_id), # => co-14: title, status, THEN the WHERE id last
)
# => co-02: nothing above distinguishes "first call" from "second identical call" -- the SQL
# => itself has no memory of prior invocations, which is precisely what makes it idempotent
if cursor.rowcount == 0: # => co-02: RFC 9110 -- PUT may also CREATE; this example only replaces,
# => so a missing id is treated as a 404 rather than silently inserting a NEW row at that id
conn.close() # => co-14: close before raising -- never leak a connection on the error path
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "no such task"}})
conn.commit() # => co-14: commits the UPDATE -- both the first AND second identical PUT commit here
row = conn.execute( # => co-14: re-reads the row to return its CURRENT state to the caller
"SELECT id, title, status FROM tasks WHERE id = ?", (task_id,)
).fetchone()
# => co-02: the SECOND PUT's response body is BYTE-IDENTICAL to the first one's -- proof enough
conn.close() # => co-14: closed after the confirming SELECT above returns the fresh row
return dict(row) # type: ignore[return-value] # => sqlite3.Row -> TaskRow-shaped dict
def count_tasks() -> int: # => proves PUT never INSERTS a duplicate row -- used only to verify
# => idempotency, never called by any route a real client would hit
conn = get_connection() # => co-14: a fresh, short-lived connection just for this one scalar query
total = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] # => co-14: a single scalar query
conn.close() # => co-14: closed immediately after reading the single count value
return int(total) # => stays 1 no matter how many times /tasks/1 is PUT to below
# => a NON-idempotent bug here would look like: POST-style INSERT instead of UPDATE, growing this
init_db() # => co-15: runs once at import time, before the app starts serving
app = FastAPI() # => a fresh app -- this example needs no auth, only the idempotency proof
# => (co-24: routes, repository, and models all live in this ONE file, deliberately not split up)
@app.put("/tasks/{task_id}") # => co-02: PUT -- RFC 9110 classifies this method as IDEMPOTENT --
# => this route's curl below calls it TWICE with an identical body to make that promise concrete
def put_task( # => co-02: task_id comes from the URL PATH, the rest of the state from the BODY
task_id: int, update: TaskUpdate
) -> TaskRow:
return replace_task(task_id, update) # => co-24: the handler holds no SQL -- co-24 layering
@app.get("/tasks/count") # => a helper endpoint used ONLY to prove no duplicate row was ever created
def get_count() -> dict[str, int]: # => co-02: called before AND after the two PUTs to compare
return {"total": count_tasks()} # => co-02: expected to read 1, both before AND after two PUTsRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== GET /tasks/count (before) ===
{"total":1}
=== PUT /tasks/1 (first call) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:56:30 GMT
server: uvicorn
content-length: 58
content-type: application/json
{"id":1,"title":"write the report","status":"in_progress"}
=== PUT /tasks/1 (second, identical call) ===
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:56:30 GMT
server: uvicorn
content-length: 58
content-type: application/json
{"id":1,"title":"write the report","status":"in_progress"}
=== GET /tasks/count (after both PUTs) ===
{"total":1}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_first_put_replaces_the_resource PASSED [ 33%]
test_app.py::test_second_identical_put_produces_the_same_state_and_no_duplicate PASSED [ 66%]
test_app.py::test_put_missing_id_is_404 PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.19s =========================Key takeaway: The UPDATE statement itself has no memory of prior invocations -- nothing distinguishes "first call" from "second identical call" at the SQL level, which is precisely what makes it idempotent by construction rather than by any special-case idempotency-detection logic.
Why it matters: Idempotency is a genuinely load-bearing property in distributed systems: a client that times out waiting for a PUT response and retries can safely resend the exact same request without fear of corrupting state, which is NOT true of POST (Example 63's create_item would insert a second row on a naive retry). Proving idempotency with an actual double-call-and-compare test, rather than trusting the method's classification on faith, is the discipline this example models.
Example 75: Method Not Allowed
ex-75 · exercises co-02, co-03
FastAPI and Starlette handle a 405 response entirely automatically -- there is no raise HTTPException(405) anywhere in this file. Calling DELETE /tasks (a path with only a GET route registered) or GET /reports (a path with only a POST route registered) both return 405 purely from route registration, each carrying an Allow header naming the method that path actually supports.
learning/code/ex-75-method-not-allowed-405/app.py
"""Example 75: Method Not Allowed -- an unsupported method returns 405 with an Allow header."""
# => co-03: FastAPI/Starlette handle THIS status code entirely automatically -- there is no
# => `raise HTTPException(405)` anywhere in this file; the 405 comes purely from route registration
from fastapi import FastAPI # => co-16: no other import needed -- routing alone drives this example
app = FastAPI() # => a fresh app -- this example needs no database, only route registration
@app.get("/tasks") # => co-02: ONLY GET is registered for this exact path -- no PUT/POST/DELETE at all
def list_tasks() -> list[str]:
return ["write the report"] # => a fixed, single-item list -- this example never mutates state
@app.post("/reports") # => a DIFFERENT path, registered with ONLY POST -- proves Allow is per-PATH
def create_report() -> dict[str, str]:
return {"created": "true"} # => reachable only via POST -- GET /reports itself would 405 too
# => co-03: RFC 9110 SS15.5.6 requires a 405 response to carry an Allow header listing the method(s)
# => that path genuinely supports -- Starlette (FastAPI's ASGI toolkit) generates this automatically.
# => VERIFIED NUANCE: when a single path has multiple decorators (e.g. both @app.get("/x") and
# => @app.post("/x")), Starlette's 405 Allow header reflects only the FIRST-matched route object,
# => not the union of every decorator for that path -- which is exactly why this example keeps
# => /tasks and /reports as two separate, single-method paths instead.Run: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== DELETE /tasks (only GET registered) ===
HTTP/1.1 405 Method Not Allowed
date: Tue, 14 Jul 2026 10:56:33 GMT
server: uvicorn
allow: GET
content-length: 31
content-type: application/json
{"detail":"Method Not Allowed"}
=== GET /reports (only POST registered) ===
HTTP/1.1 405 Method Not Allowed
date: Tue, 14 Jul 2026 10:56:33 GMT
server: uvicorn
allow: POST
content-length: 31
content-type: application/json
{"detail":"Method Not Allowed"}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_delete_on_get_only_path_is_405_with_get_allow PASSED [ 33%]
test_app.py::test_delete_on_post_only_path_is_405_with_post_allow PASSED [ 66%]
test_app.py::test_registered_methods_still_work_normally PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.15s =========================Key takeaway: RFC 9110 SS15.5.6 requires a 405 response to carry an Allow header listing the method(s) that path genuinely supports -- Starlette generates this correctly on its own, with no code in this file dedicated to it at all.
Why it matters: A verified nuance worth knowing: when a single path has multiple decorators (both @app.get("/x") and @app.post("/x")), Starlette's 405 Allow header reflects only the FIRST-matched route object for that path, not the union of every method registered on it -- which is exactly why this example keeps /tasks and /reports as two separate, single-method paths instead of demonstrating a multi-method path's Allow header, avoiding a claim this framework version does not actually keep.
Example 76: Health vs Readiness
ex-76 · exercises co-08, co-14
Two different questions that sound similar: /health asks "is the process itself alive and responsive at all?" (liveness), while /ready asks "can this instance actually serve a real request right now?" (readiness, proven by pinging the database). Orchestrators treat a failed liveness check very differently from a failed readiness check -- one triggers a restart, the other just pulls the instance out of rotation.
%% Color Palette: Blue #0173B2, Orange #DE8F05
graph LR
A["GET /health"]:::blue --> B["always 200 --
no DB call at all"]:::blue
C["GET /ready"]:::orange --> D{"SELECT 1
against the DB?"}:::orange
D -->|succeeds| E["200 ready"]:::orange
D -->|fails| F["503 not_ready --
pulled from rotation"]:::orange
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-76-health-vs-readiness/app.py
"""Example 76: Health vs Readiness -- /health never touches the DB, /ready pings it."""
# => co-08: two DIFFERENT questions that sound similar -- "is the process alive" (health) versus
# => "can this instance actually do its job right now" (readiness) -- orchestrators treat them
# => differently: a failed health check usually triggers a RESTART, a failed readiness check
# => just pulls the instance out of the load-balancing rotation until it recovers on its own
import os # => co-14: reads the TASKS_DB_PATH env var this example's readiness check depends on
import sqlite3 # => co-14: the stdlib DB driver -- readiness pings a REAL connection, never a mock
from fastapi import FastAPI, Response # => co-08: Response lets a handler override the status code
# => DB_PATH is read from an env var so the SAME code can point at a real file (co-14) or a
# => deliberately unreachable path -- used ONLY to genuinely simulate "the database is down"
# => without faking any response by hand
DB_PATH = os.environ.get("TASKS_DB_PATH", os.path.join(os.path.dirname(__file__), "tasks.db")) # => co-14: no hardcoded path here at all -- this example's curl run overrides it directly
app = FastAPI() # => a fresh app -- this example needs no auth, only the health/readiness contrast
def _init_db_if_reachable() -> None: # => best-effort setup -- readiness below is what actually
# => PROVES reachability; this function only exists so the "healthy" scenario has a real table
try:
conn = sqlite3.connect(DB_PATH) # => co-14: fails immediately if DB_PATH's directory is gone
conn.execute("CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL)")
conn.commit() # => co-14: commits the CREATE TABLE, if the connection above succeeded at all
conn.close() # => co-14: closed immediately -- this function runs once, at import time
except sqlite3.OperationalError: # => e.g. DB_PATH points at a directory that doesn't exist
pass # => intentionally swallowed here -- /ready is the endpoint that surfaces this, not startup
_init_db_if_reachable() # => co-15: runs once at import time -- may silently no-op, by design above
@app.get("/health") # => co-08: LIVENESS -- "is the process itself running and responsive at all?"
def health() -> dict[str, str]:
return {"status": "ok"} # => co-03: no DB call, no dependency on anything external -- always 200
@app.get("/ready") # => co-08, co-14: READINESS -- "can this instance actually SERVE a real request?"
def ready(response: Response) -> dict[str, str]:
try:
conn = sqlite3.connect(DB_PATH, timeout=1) # => co-14: a real connection attempt, not a mock --
# => timeout=1 keeps this check FAST, since a slow readiness probe is nearly as bad as a broken one
conn.execute("SELECT 1") # => the cheapest possible real query -- proves the DB genuinely answers
conn.close() # => co-14: closed immediately after the probe succeeds
return {"status": "ready"} # => co-03: 200 -- the default status FastAPI applies here
except sqlite3.OperationalError as exc: # => e.g. "unable to open database file"
response.status_code = 503 # => co-03: Service Unavailable -- this instance cannot serve
# => traffic right now; a load balancer reading THIS status is what actually acts on it
return {"status": "not_ready", "reason": str(exc)} # => co-11: the real underlying DB error,
# => surfaced for operator debugging -- never shown to an end user in a production deploymentRun: uvicorn app:app --port 8003 in one terminal for scenario 1 (default tasks.db), then again with TASKS_DB_PATH=/nonexistent-dir-for-ex76/tasks.db for scenario 2, plus pytest -v against the same directory.
Output (curl):
=== scenario 1: DB reachable ===
-- GET /health --
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:56:35 GMT
server: uvicorn
content-length: 15
content-type: application/json
{"status":"ok"}
-- GET /ready --
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:56:35 GMT
server: uvicorn
content-length: 18
content-type: application/json
{"status":"ready"}
=== scenario 2: TASKS_DB_PATH points at an unreachable directory ===
-- GET /health (liveness unaffected) --
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:56:37 GMT
server: uvicorn
content-length: 15
content-type: application/json
{"status":"ok"}
-- GET /ready (503, DB unreachable) --
HTTP/1.1 503 Service Unavailable
date: Tue, 14 Jul 2026 10:56:37 GMT
server: uvicorn
content-length: 62
content-type: application/json
{"status":"not_ready","reason":"unable to open database file"}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 3 items
test_app.py::test_health_never_depends_on_the_database PASSED [ 33%]
test_app.py::test_readiness_fails_when_db_is_genuinely_unreachable PASSED [ 66%]
test_app.py::test_readiness_succeeds_when_db_is_reachable PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.17s =========================Key takeaway: /health never touches the database at all -- it always returns 200 as long as the process can respond, while /ready makes a real, timeout-bounded connection attempt and only returns 200 if that attempt genuinely succeeds.
Why it matters: Conflating these two checks into one endpoint is a real production mistake: an orchestrator that restarts an instance every time its DATABASE is briefly unreachable (rather than just routing traffic elsewhere until it recovers) turns a transient dependency blip into an unnecessary process churn, and potentially a cascading restart storm across every instance simultaneously. TASKS_DB_PATH being read from an environment variable is what makes this example's "DB down" scenario genuinely real rather than mocked -- pointing it at a nonexistent directory produces an actual sqlite3.OperationalError, not a faked response.
Example 77: Error Envelope Consistency
ex-77 · exercises co-11
Five different failure kinds -- 400, 401, 404, 422, and 500 -- are all rendered through the SAME envelope() helper and the same two-key {"error": {"code": ..., "message": ...}} shape. A client parses exactly one response shape, regardless of which of the five actually failed.
learning/code/ex-77-error-envelope-consistency/app.py
"""Example 77: Error Envelope Consistency -- every error path shares one shape."""
# => co-11: five DIFFERENT failure kinds (400, 401, 404, 422, 500) below, all rendered through
# => the SAME `envelope()` helper -- a client parses exactly one shape, regardless of WHICH failed
from fastapi import Depends, FastAPI, Header, HTTPException, Request # => co-23: Depends wires 401 in
from fastapi.exceptions import RequestValidationError # => co-10: FastAPI's own validation failure type
from fastapi.responses import JSONResponse # => co-11: builds every exception handler's body below
from pydantic import BaseModel # => co-10: validates the POST body, feeding the 422 path
# => co-11: five handlers below, one shared helper -- this file is the single reference for
# => "what does an error look like in this app," regardless of which route or dependency raised it
def envelope(code: str, message: str) -> dict[str, dict[str, str]]: # => co-11: the ONE shape every
# => error path in this app returns -- a machine-readable `code` plus a human-readable `message`
return {"error": {"code": code, "message": message}}
app = FastAPI() # => a fresh app -- this example needs only enough state to trigger each error kind
# => (fully self-contained: nothing here is imported from any other example directory)
@app.exception_handler(HTTPException) # => co-11: catches every raise HTTPException(...) in this app --
# => this handler alone is what turns FastAPI's default {"detail": ...} nesting into {"error": {...}}
async def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
if isinstance(exc.detail, dict): # => a route already built a structured envelope itself
return JSONResponse(status_code=exc.status_code, content=exc.detail) # => used as-is, unwrapped
return JSONResponse( # => a route raised HTTPException(status_code=X, detail="plain string")
status_code=exc.status_code, content=envelope("error", str(exc.detail))
) # => co-11: normalized into the SAME envelope shape either way
@app.exception_handler(RequestValidationError) # => co-10, co-11: FastAPI's own 422 gets the SAME shape --
# => without THIS handler, a validation failure would ship FastAPI's default {"detail": [...]} array instead
async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
first = exc.errors()[0] # => co-11: names the OFFENDING field, same spirit as an earlier example's
# => detail array -- only the FIRST error is surfaced, keeping the envelope's message a single string
field = ".".join(str(loc) for loc in first["loc"]) # => co-11: e.g. "body.title" for a missing field
return JSONResponse( # => co-11: SAME shape as the HTTPException handler above, code differs
status_code=422, content=envelope("validation_error", f"{field}: {first['msg']}")
)
@app.exception_handler(Exception) # => co-11: the LAST resort -- catches anything else, sanitized --
# => registration ORDER matters here: FastAPI checks the more specific handlers above FIRST
async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse( # => co-11: the SAME two-key {"error": {code, message}} shape as every
# => OTHER handler in this file -- a client never needs to special-case a 500 differently
status_code=500,
content=envelope("internal_error", "an unexpected error occurred"),
) # => co-11: NEVER leaks exc's message or a stack trace to the client -- deliberately generic
TASKS = {1: "write the report"} # => a tiny in-memory store, just enough to trigger each error kind --
# => id 1 exists (drives the 200 path), any other id drives the 404 path further down
VALID_TOKEN = "s3cr3t-token-abc123" # => hardcoded stand-in for a real signed/opaque token
class CreateTask(BaseModel): # => co-10: the POST body's expected shape --
# => this is the model VALIDATED before create_task's own body even runs, per co-10
title: str # => co-10: required -- omitting it triggers the 422 path
def require_token( # => co-04, co-18: 401 trigger -- reused via Depends() on the write route below
authorization: str | None = Header(default=None), # => co-04: MUST be Header(), not a bare
# => default -- FastAPI treats an unannotated `str | None = None` as a QUERY param instead
) -> None:
if authorization != f"Bearer {VALID_TOKEN}": # => co-18: either missing entirely or genuinely wrong
raise HTTPException(status_code=401, detail=envelope("unauthorized", "missing or invalid token"))
@app.get("/tasks/{task_id}") # => co-03, co-11: the 404 trigger -- path params validate their
# => TYPE automatically (a non-integer id is a 422), but existence is business logic, checked below
def get_task(task_id: int) -> dict[str, str]:
if task_id not in TASKS: # => co-03: any id besides the single seeded one below triggers this
raise HTTPException(status_code=404, detail=envelope("not_found", "no such task"))
return {"title": TASKS[task_id]} # => co-11: the ONLY success-path response in this whole file
@app.get("/tasks") # => co-03, co-11: the 400 trigger -- a semantically bad input Pydantic can't catch
def list_tasks(bad: bool = False) -> dict[str, str]:
if bad: # => a deliberate business-rule violation, not a type/shape error -- co-10 validation
# => would happily accept `bad=true` as a well-formed bool; THIS check is business logic, not typing
raise HTTPException(status_code=400, detail=envelope("bad_request", "the bad=true flag is rejected"))
return {"count": str(len(TASKS))} # => co-11: the ordinary, unguarded success path
@app.post("/tasks", dependencies=[Depends(require_token)]) # => co-10, co-11: the 422 trigger, if
# => `title` is omitted from the body -- and separately, the 401 trigger if no valid token is sent
def create_task(body: CreateTask) -> dict[str, str]:
return {"title": body.title} # => co-11: unreachable without BOTH a valid token and a valid body
@app.get("/boom") # => co-11: the 500 trigger -- an unhandled exception, sanitized by the handler above
def boom() -> dict[str, str]:
raise RuntimeError("a genuinely unexpected internal failure") # => never reaches the client verbatim --
# => the generic Exception handler above catches THIS and every other undeclared exception typeRun: uvicorn app:app --port 8003 in one terminal, then the curl commands below in another, plus pytest -v against the same directory.
Output (curl):
=== 400: GET /tasks?bad=true ===
HTTP/1.1 400 Bad Request
date: Tue, 14 Jul 2026 10:56:40 GMT
server: uvicorn
content-length: 74
content-type: application/json
{"error":{"code":"bad_request","message":"the bad=true flag is rejected"}}
=== 401: POST /tasks (no token) ===
HTTP/1.1 401 Unauthorized
date: Tue, 14 Jul 2026 10:56:40 GMT
server: uvicorn
content-length: 70
content-type: application/json
{"error":{"code":"unauthorized","message":"missing or invalid token"}}
=== 404: GET /tasks/999 ===
HTTP/1.1 404 Not Found
date: Tue, 14 Jul 2026 10:56:40 GMT
server: uvicorn
content-length: 55
content-type: application/json
{"error":{"code":"not_found","message":"no such task"}}
=== 422: POST /tasks (valid token, missing title) ===
HTTP/1.1 422 Unprocessable Content
date: Tue, 14 Jul 2026 10:56:40 GMT
server: uvicorn
content-length: 76
content-type: application/json
{"error":{"code":"validation_error","message":"body.title: Field required"}}
=== 500: GET /boom ===
HTTP/1.1 500 Internal Server Error
date: Tue, 14 Jul 2026 10:56:40 GMT
server: uvicorn
content-length: 76
content-type: application/json
{"error":{"code":"internal_error","message":"an unexpected error occurred"}}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 6 items
test_app.py::test_400_bad_request_uses_the_envelope PASSED [ 16%]
test_app.py::test_401_unauthorized_uses_the_envelope PASSED [ 33%]
test_app.py::test_404_not_found_uses_the_envelope PASSED [ 50%]
test_app.py::test_422_validation_error_uses_the_envelope PASSED [ 66%]
test_app.py::test_500_internal_error_uses_the_envelope_and_hides_details PASSED [ 83%]
test_app.py::test_every_error_status_code_shares_the_identical_top_level_keys PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 6 passed, 1 warning in 0.23s =========================Key takeaway: Registration ORDER matters for exception handlers: FastAPI checks more specific handler types (HTTPException, RequestValidationError) before falling through to the generic Exception handler, which is why the 500 handler can safely assume it is catching something genuinely unexpected.
Why it matters: The Exception handler NEVER leaks exc's actual message or a stack trace to the client -- it always returns the same generic "an unexpected error occurred" string, deliberately. Leaking internal exception details in a 500 response is a real information-disclosure risk (stack traces can reveal file paths, library versions, or even fragments of query text), and this example demonstrates the discipline of sanitizing every unexpected failure down to one safe, generic message while still logging the real detail server-side for an operator to actually debug.
Example 78: curl CRUD + Auth Script
ex-78 · exercises co-22, co-18
A documented, executable shell script (crud_auth.sh) exercises full CRUD plus the protected-write auth check end to end against a live server: create without a token (expect 401), create with a token (expect 201), read, update, delete, then confirm a 404 on the now-deleted resource. Every step asserts its own expected status code with test "$code" = "...", so the script itself fails loudly if any step's behavior regresses.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["create without token --<br/>expect 401"]:::blue --> B["create with token --<br/>expect 201"]:::orange
B --> C["read --<br/>GET, no token needed"]:::orange
C --> D["update --<br/>PUT with token"]:::orange
D --> E["delete --<br/>DELETE with token,<br/>verify 404 after"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-78-curl-crud-auth-script/app.py
"""Example 78: curl CRUD + Auth Script -- the target app the companion script exercises end to end."""
# => co-02, co-18: this app itself introduces nothing new -- every route below reuses patterns from
# => earlier examples in this topic. What's NEW is the companion crud_auth.sh script that drives it
import os # => co-14: used for the DB_PATH lookup and the "start fresh" file check below
# => co-02, co-18: every route below maps to exactly one HTTP verb -- POST create, GET read,
# => PUT replace, DELETE remove -- the four verbs a REST-style CRUD resource conventionally exposes
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import TypedDict # => co-09: a typed dict shape for what the repository returns
from fastapi import Depends, FastAPI, HTTPException, Request # => co-23: Depends wires the auth check in
from fastapi.responses import JSONResponse # => co-11: builds the exception handler's structured body
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer # => co-18: parses "Bearer <token>"
from pydantic import BaseModel # => co-10: validates both the create and update request bodies
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite file,
# => PER EXAMPLE DIRECTORY -- never shared with any other example, keeping this one self-contained
# => co-15: the schema below intentionally omits priority/created_at -- this example is about
# => the FULL request lifecycle (create, read, update, delete, auth), not filtering or sorting
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'todo'
);
""" # => co-15: DEFAULT 'todo' means a bare INSERT (title only) still produces a valid row
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape --
# => every route below returns EITHER this shape or None, never a raw sqlite3.Row directly
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column, defaulted server-side on create
class CreateTask(BaseModel): # => co-10: POST's expected body shape -- deliberately narrow --
# => a client cannot set a task's status at creation time; the schema's DEFAULT handles that
title: str # => co-10: required -- status is never client-settable at creation time
class UpdateTask(BaseModel): # => co-02, co-10: PUT REPLACES the full resource with this exact
# => shape -- unlike PATCH, there is no "only send the fields you're changing" option here
title: str # => required, same rule as CreateTask above
status: str # => required -- PUT (unlike POST) DOES let the caller set status explicitly
def init_db() -> None: # => co-15: fresh schema every time this module is imported
if os.path.exists(DB_PATH): # => co-15: true on every run after the first, since nothing else
# => in this example removes tasks.db besides this check itself
os.remove(DB_PATH) # => start from a known, deterministic state -- an empty table every run
conn = sqlite3.connect(DB_PATH) # => co-14: a plain connection -- no row_factory needed for DDL
conn.execute(SCHEMA) # => co-15: creates the table every repository function below depends on
conn.commit() # => co-14: commits the CREATE TABLE
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to the DB
conn = sqlite3.connect(DB_PATH) # => co-14: every repository function below calls THIS helper
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
def create_task(title: str) -> TaskRow: # => co-14: repository create -- the "C" of CRUD
conn = get_connection() # => co-14: one connection for both the INSERT and the confirming SELECT
cursor = conn.execute("INSERT INTO tasks (title) VALUES (?)", (title,)) # => co-14: parameterized
conn.commit() # => co-14: commits the new row before reading it back below
row = conn.execute( # => co-14: re-reads the row to return its server-assigned id + default status
"SELECT id, title, status FROM tasks WHERE id = ?",
(cursor.lastrowid,), # => cursor.lastrowid
).fetchone() # => co-14: the just-inserted row, guaranteed to exist immediately after commit
conn.close() # => co-14: closed after both the INSERT and the confirming SELECT finish
return dict(row) # type: ignore[return-value] # => sqlite3.Row -> TaskRow-shaped dict
def get_task(task_id: int) -> TaskRow | None: # => co-14: repository read -- the "R" of CRUD
conn = get_connection() # => co-14: opened just for this one read
row = conn.execute( # => co-14: a single parameterized lookup by primary key
"SELECT id, title, status FROM tasks WHERE id = ?", (task_id,)
).fetchone() # => co-14: None if the id doesn't exist, a Row if it does
conn.close() # => co-14: closed immediately after this one query
return dict(row) if row else None # type: ignore[return-value] # => None signals "no such id"
def update_task(task_id: int, title: str, status: str) -> TaskRow | None: # => co-14: repository
# => update -- the "U" of CRUD, backing this example's PUT route
conn = get_connection() # => co-14: one connection for the UPDATE and the confirming SELECT
cursor = conn.execute( # => co-14: parameterized UPDATE, matching this topic's convention throughout
"UPDATE tasks SET title = ?, status = ? WHERE id = ?",
(title, status, task_id), # => co-14
) # => co-14: cursor.rowcount below reveals whether this actually matched a row
if cursor.rowcount == 0: # => co-02: no row matched this id -- the route maps this to a 404
conn.close() # => co-14: close before returning -- never leak a connection on this path
return None # => co-02: signals "not found" up to the route, which raises the actual 404
conn.commit() # => co-14: commits the UPDATE now that we know a row genuinely matched
row = conn.execute( # => co-14: re-reads the row to return its post-update state to the caller
"SELECT id, title, status FROM tasks WHERE id = ?",
(task_id,), # => co-14: same id as the UPDATE
).fetchone() # => co-14: guaranteed non-None -- we just confirmed rowcount > 0 above
conn.close() # => co-14: closed after the confirming SELECT returns the fresh row
return dict(row) # type: ignore[return-value] # => sqlite3.Row -> TaskRow-shaped dict
def delete_task(task_id: int) -> bool: # => co-14: repository delete -- the "D" of CRUD
conn = get_connection() # => co-14: opened just for this one DELETE
cursor = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) # => co-14: parameterized DELETE
conn.commit() # => co-14: commits regardless of whether a row actually matched
conn.close() # => co-14: closed immediately after the DELETE commits
return cursor.rowcount > 0 # => co-02: True only if a row genuinely existed and was removed
init_db() # => co-15: runs once at import time, before the app starts serving
# => co-15: every curl step in crud_auth.sh below runs against this SAME freshly-emptied table
app = FastAPI() # => a fresh app -- routes below wire the four repository functions above to HTTP
# => (fully self-contained: nothing here is imported from any other example directory)
VALID_TOKEN = "s3cr3t-token-abc123" # => hardcoded stand-in for a real signed/opaque token
# => (the SAME literal value used by crud_auth.sh's --header "Authorization: Bearer ..." calls)
security = HTTPBearer(auto_error=False) # => auto_error=False: WE own the 401 body's shape below
# => (not FastAPI's own default, generic "Not authenticated" 403 body)
@app.exception_handler(HTTPException) # => co-11: consistent envelope across every error this app
# => returns -- unwraps FastAPI's default {"detail": ...} nesting into the flat {"error": {...}} shape
async def handle_http_exception( # => co-11: registered ONCE, catches every HTTPException app-wide
request: Request, exc: HTTPException
) -> JSONResponse:
body = ( # => co-11: normalizes whatever a route raised into ONE consistent envelope shape
exc.detail # => co-11: already a dict -- every raise in this file supplies one directly
if isinstance(exc.detail, dict) # => every raise below already supplies a dict
else {"error": {"code": "error", "message": str(exc.detail)}} # => fallback for a plain string
)
return JSONResponse(status_code=exc.status_code, content=body) # => co-11: same shape, every error
def require_token( # => co-18, co-23: guards every WRITE below -- reused via Depends() per route
credentials: HTTPAuthorizationCredentials | None = Depends(security), # => co-23: resolved BEFORE
# => the guarded route's own handler body ever runs
) -> None: # => co-23: returns nothing -- FastAPI only cares whether this raises or not
if credentials is None or credentials.credentials != VALID_TOKEN: # => co-18: either failure mode
raise HTTPException( # => co-11: structured envelope, matching every other error in this app
status_code=401, # => co-03: 401 Unauthorized -- "who you claim to be" was rejected
detail={"error": {"code": "unauthorized", "message": "missing or invalid token"}},
)
@app.post("/tasks", status_code=201, dependencies=[Depends(require_token)]) # => co-02, co-03, co-18:
# => CREATE -- a WRITE, so it's guarded; 201 signals a new resource now exists at the returned id
def post_task(body: CreateTask) -> TaskRow: # => co-10: body already validated against CreateTask
return create_task(body.title) # => co-24: the handler holds no SQL -- co-24 layering
@app.get("/tasks/{task_id}") # => co-02: reads stay OPEN, no token required
def read_task(task_id: int) -> TaskRow: # => co-02: no dependencies=[...] list at all -- unguarded
task = get_task(task_id) # => co-24: delegates straight to the repository, no logic in between
if task is None: # => co-02: no row exists at this id
raise HTTPException( # => co-11: structured envelope, matching every other error in this app
status_code=404, # => co-03: Not Found -- the id is well-formed, just nonexistent
detail={"error": {"code": "not_found", "message": "no such task"}},
)
return task # => co-24: the same TaskRow shape create_task returned on the way in
@app.put("/tasks/{task_id}", dependencies=[Depends(require_token)]) # => co-02, co-18: REPLACE --
# => a WRITE, so it's guarded; RFC 9110 classifies PUT as idempotent, unlike POST above
def put_task(task_id: int, body: UpdateTask) -> TaskRow: # => co-02: id from the PATH, state from the BODY
updated = update_task(task_id, body.title, body.status) # => co-24: no SQL in this handler either
if updated is None: # => co-02: no row matched -- this example's PUT only replaces, never creates
raise HTTPException( # => co-11: structured envelope, matching every other error in this app
status_code=404, # => co-03: Not Found -- consistent with read_task's 404 above
detail={"error": {"code": "not_found", "message": "no such task"}},
)
return updated # => co-24: the fresh, post-update row state
@app.delete("/tasks/{task_id}", status_code=204, dependencies=[Depends(require_token)]) # => co-02,
# => co-03, co-18: DELETE -- a WRITE, so it's guarded; 204 carries no response body on success
def remove_task(task_id: int) -> None: # => co-02: None return type -- 204 forbids a response body
if not delete_task(task_id): # => co-02: nothing matched this id -- report 404, not a silent no-op
raise HTTPException( # => co-11: structured envelope, matching every other error in this app
status_code=404, # => co-03: Not Found -- the SAME code every other missing-id error uses
detail={"error": {"code": "not_found", "message": "no such task"}},
)learning/code/ex-78-curl-crud-auth-script/crud_auth.sh
#!/usr/bin/env bash
# Example 78: a documented curl sequence exercising CRUD + auth end-to-end (co-22, co-18).
# Run against a live server: uvicorn app:app --port 8003 (from this directory).
set -euo pipefail # => co-22: abort immediately on any failing command, unset var, or pipe error
BASE_URL="http://localhost:8003" # => the target server this script drives end-to-end
TOKEN="s3cr3t-token-abc123" # => matches app.py's VALID_TOKEN -- the SAME literal both sides expect
echo "== Step 1: create WITHOUT a token -- expect 401 ==" # => co-18: no Authorization header at all
# => captures ONLY the HTTP status code, discarding the response body via -o /dev/null
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Content-Type: application/json" \
-d '{"title":"write the report"}' "$BASE_URL/tasks") # => co-02: POST with no Authorization header
echo "status: $code" # => prints the captured status code for a human reading the script's output
test "$code" = "401" # => co-03: aborts the script (set -e) if this specific assertion fails
echo "== Step 2: create WITH a valid token -- expect 201 ==" # => co-18: the SAME create, now WITH a token
# => this time the FULL response body is captured, not just the status
create_response=$(curl -s -X POST -H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" -d '{"title":"write the report"}' "$BASE_URL/tasks") # => co-18
echo "$create_response" # => prints the raw JSON body so the created task's id is visible
task_id=$(echo "$create_response" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') # => extracts "id"
echo "created task id: $task_id" # => the id every remaining step below operates on
echo "== Step 3: read the created task (no token needed) -- expect 200 ==" # => co-02: reads stay OPEN
curl -s -i "$BASE_URL/tasks/$task_id" # => -i prints response headers too, not just the body
echo # => a blank line separator between this step's output and the next
echo "== Step 4: update WITH a valid token -- expect 200, status becomes done ==" # => co-02: PUT replaces
# => co-18: PUT is guarded the same way POST was in Step 2
curl -s -X PUT -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-d '{"title":"write the report","status":"done"}' "$BASE_URL/tasks/$task_id" # => full-body replace
echo # => separates this step's raw JSON response from the next step's header
echo "== Step 5: delete WITH a valid token -- expect 204 ==" # => co-18: DELETE is guarded too
# => co-02: DELETE carries no body -- only the status code is captured
code=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/tasks/$task_id") # => co-02: same task_id created back in Step 2
echo "status: $code" # => prints the captured DELETE status code
test "$code" = "204" # => co-03: 204 means success with no response body
echo "== Step 6: read after delete -- expect 404 (genuinely gone) ==" # => proves the row is truly gone
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/tasks/$task_id") # => co-02: same unguarded GET
echo "status: $code" # => prints the captured final status code
test "$code" = "404" # => co-03: confirms the row is genuinely gone, not just soft-deleted
echo "== ALL STEPS PASSED ==" # => only reached if every test assertion above succeeded (set -e)Run: uvicorn app:app --port 8003 in one terminal, then bash crud_auth.sh in another (the script targets localhost:8003 directly), plus pytest -v against the same directory.
Output (curl):
== Step 1: create WITHOUT a token -- expect 401 ==
status: 401
== Step 2: create WITH a valid token -- expect 201 ==
{"id":1,"title":"write the report","status":"todo"}
created task id: 1
== Step 3: read the created task (no token needed) -- expect 200 ==
HTTP/1.1 200 OK
date: Tue, 14 Jul 2026 10:56:43 GMT
server: uvicorn
content-length: 51
content-type: application/json
{"id":1,"title":"write the report","status":"todo"}
== Step 4: update WITH a valid token -- expect 200, status becomes done ==
{"id":1,"title":"write the report","status":"done"}
== Step 5: delete WITH a valid token -- expect 204 ==
status: 204
== Step 6: read after delete -- expect 404 (genuinely gone) ==
status: 404
== ALL STEPS PASSED ==
SCRIPT EXIT CODE: 0Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 2 items
test_app.py::test_create_without_token_is_401 PASSED [ 50%]
test_app.py::test_full_crud_round_trip_with_token PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 2 passed, 1 warning in 0.15s =========================Key takeaway: set -euo pipefail at the top of the script means ANY failing command -- including a failed test assertion on an unexpected status code -- aborts the whole script immediately with a nonzero exit code, rather than silently continuing past a broken step.
Why it matters: This script is the shell-scripting equivalent of an integration test, runnable by anyone with curl and no Python test runner at all -- useful for a smoke test in a deploy pipeline, a support engineer verifying a staging environment, or a quick sanity check before a release. Every step's real captured output below (and the script's own $? exit code, 0) proves the SAME app this topic has built throughout genuinely supports a full create-read-update-delete-with-auth lifecycle, not just each operation in isolation.
Example 79: pytest Full Integration
ex-79 · exercises co-14, co-18, co-19
This app combines every major mechanism from the advanced tier -- CRUD, token auth, and pagination -- into a single service, and test_app.py is the fullest pytest suite in this topic precisely because it verifies all three areas genuinely coexist rather than each working only in isolation. Three test classes (TestCrud, TestTokenAuth, TestPagination) plus one final smoke test that touches all three areas in a single function.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["TestCrud --
create/read/update/delete"]:::blue
B["TestTokenAuth --
missing/wrong/valid token"]:::orange
C["TestPagination --
seeded pages + filter"]:::teal
A --> D["full_integration_smoke --
all three, ONE test"]
B --> D
C --> D
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-79-pytest-full-integration/app.py
"""Example 79: pytest Full Integration -- CRUD + token auth + pagination, one target app."""
# => co-14, co-18, co-19: this file combines EVERY major mechanism from this topic's advanced
# => tier into a single app -- the companion test_app.py is the fullest pytest suite in this
# => topic precisely because it verifies all three areas (CRUD, auth, pagination) genuinely coexist
import os # => co-14: used for the DB_PATH lookup and the "start fresh" file check below
# => co-02: every route maps to exactly one HTTP verb -- POST create, GET read, PUT replace,
# => DELETE remove, plus a second GET for the paginated list -- the full set this topic covers
import sqlite3 # => co-14: the stdlib DB driver -- no ORM, no extra dependency needed
from typing import TypedDict # => co-09: typed dict shapes for what the repository returns
from fastapi import Depends, FastAPI, HTTPException, Query, Request # => co-23: Depends wires auth in
from fastapi.responses import JSONResponse # => co-11: builds the exception handler's structured body
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer # => co-18: parses "Bearer <token>"
from pydantic import BaseModel # => co-10: validates both the create and update request bodies
# => co-24: routes, repository, and models all live in this ONE file, deliberately not split up
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db") # => co-14: one on-disk SQLite file,
# => PER EXAMPLE DIRECTORY -- never shared with any other example, keeping this one self-contained
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'todo'
);
""" # => co-15: DEFAULT 'todo' means a bare INSERT (title only) still produces a valid row
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
status: str # => matches the schema's status column, defaulted server-side on create
class Page(TypedDict): # => co-09, co-19: the SAME envelope shape used by the pagination examples
items: list[TaskRow] # => this page's rows, after any status filter and the limit/offset slice
total: int # => co-19, co-20: the FILTERED count -- not necessarily the whole table
next: int | None # => None means "no further page" -- a real, typed sentinel
class CreateTask(BaseModel): # => co-10: POST's expected body shape -- deliberately narrow --
# => a client cannot set a task's status at creation time; the schema's DEFAULT handles that
title: str # => co-10: required -- status is never client-settable at creation time
class UpdateTask(BaseModel): # => co-02, co-10: PUT REPLACES the full resource with this exact shape
title: str # => required, same rule as CreateTask above
status: str # => required -- PUT (unlike POST) DOES let the caller set status explicitly
# => (an invalid status value like "not_a_real_status" is still a plain str at this layer --
# => nothing in this simplified example restricts it to a closed set of allowed values)
def init_db() -> None: # => co-15: fresh schema every time this module is imported
if os.path.exists(DB_PATH): # => co-15: true on every run after the first, since nothing else
# => in this example removes tasks.db besides this check itself
os.remove(DB_PATH) # => start from a known, deterministic state -- an empty table every run
conn = sqlite3.connect(DB_PATH) # => co-14: a plain connection -- no row_factory needed for DDL
conn.execute(SCHEMA) # => co-15: creates the table every repository function below depends on
conn.commit() # => co-14: commits the CREATE TABLE
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to the DB
conn = sqlite3.connect(DB_PATH) # => co-14: every repository function below calls THIS helper
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name
return conn # => a fresh connection per call -- co-14: no pooling, matches this example's scale
def create_task(title: str) -> TaskRow: # => co-14: repository create -- the "C" of CRUD
conn = get_connection() # => co-14: one connection for both the INSERT and the confirming SELECT
cursor = conn.execute("INSERT INTO tasks (title) VALUES (?)", (title,)) # => co-14: parameterized
conn.commit() # => co-14: commits the new row before reading it back below
row = conn.execute( # => co-14: re-reads the row to return its server-assigned id + default status
"SELECT id, title, status FROM tasks WHERE id = ?",
(cursor.lastrowid,), # => cursor.lastrowid
).fetchone() # => co-14: the just-inserted row, guaranteed to exist immediately after commit
conn.close() # => co-14: closed after both the INSERT and the confirming SELECT finish
return dict(row) # type: ignore[return-value] # => sqlite3.Row -> TaskRow-shaped dict
def get_task(task_id: int) -> TaskRow | None: # => co-14: repository read -- the "R" of CRUD
conn = get_connection() # => co-14: opened just for this one read
row = conn.execute( # => co-14: a single parameterized lookup by primary key
"SELECT id, title, status FROM tasks WHERE id = ?", (task_id,)
).fetchone() # => co-14: None if the id doesn't exist, a Row if it does
conn.close() # => co-14: closed immediately after this one query
return dict(row) if row else None # type: ignore[return-value] # => None signals "no such id"
def update_task(task_id: int, title: str, status: str) -> TaskRow | None: # => co-14: repository
# => update -- the "U" of CRUD, backing this example's PUT route
conn = get_connection() # => co-14: one connection for the UPDATE and the confirming SELECT
cursor = conn.execute( # => co-14: parameterized UPDATE, matching this topic's convention throughout
"UPDATE tasks SET title = ?, status = ? WHERE id = ?",
(title, status, task_id), # => co-14
) # => co-14: cursor.rowcount below reveals whether this actually matched a row
if cursor.rowcount == 0: # => co-02: no row matched this id -- the route maps this to a 404
conn.close() # => co-14: close before returning -- never leak a connection on this path
return None # => co-02: signals "not found" up to the route, which raises the actual 404
conn.commit() # => co-14: commits the UPDATE now that we know a row genuinely matched
row = conn.execute( # => co-14: re-reads the row to return its post-update state to the caller
"SELECT id, title, status FROM tasks WHERE id = ?",
(task_id,), # => co-14: same id as the UPDATE
).fetchone() # => co-14: guaranteed non-None -- we just confirmed rowcount > 0 above
conn.close() # => co-14: closed after the confirming SELECT returns the fresh row
return dict(row) # type: ignore[return-value] # => sqlite3.Row -> TaskRow-shaped dict
def delete_task(task_id: int) -> bool: # => co-14: repository delete -- the "D" of CRUD
conn = get_connection() # => co-14: opened just for this one DELETE
cursor = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) # => co-14: parameterized DELETE
conn.commit() # => co-14: commits regardless of whether a row actually matched
conn.close() # => co-14: closed immediately after the DELETE commits
return cursor.rowcount > 0 # => co-02: True only if a row genuinely existed and was removed
def list_page( # => co-19, co-20: full pagination+filter -- the SAME repository shape as the
limit: int, # => co-19: how many rows this page holds, at most
offset: int, # => co-19: how many filtered rows to skip before this page starts
status: str | None, # => co-20: optional -- None means every status qualifies
) -> Page:
conn = get_connection() # => co-14: one connection for both queries this function issues
where = " WHERE status = ?" if status is not None else "" # => co-20: opt-in, exactly like the
# => standalone filter example -- an empty string here reproduces the fully unfiltered query
params: list[str] = [status] if status is not None else [] # => co-14: kept in lockstep with `where`
total = int( # => co-19: the FILTERED count, computed with the SAME where clause and params
conn.execute(f"SELECT COUNT(*) FROM tasks{where}", params).fetchone()[0]
) # => co-19: so the two numbers -- total and items -- stay consistent with each other
cursor = conn.execute( # => co-14, co-19, co-20: filter AND paginate in a single statement
f"SELECT id, title, status FROM tasks{where} ORDER BY id LIMIT ? OFFSET ?", # => co-15:
# => ORDER BY id keeps page boundaries STABLE across repeated calls with the same params
[*params, limit, offset], # => co-14: filter params FIRST, then limit/offset, in that order
)
items = [dict(row) for row in cursor.fetchall()] # type: ignore[misc] # => sqlite3.Row -> dict
conn.close() # => co-14: closed immediately after both queries above finish
next_offset = offset + limit # => co-19: the offset a client would use to fetch the FOLLOWING page
return { # => co-19, co-20: the SAME Page envelope shape as the standalone pagination example
"items": items, # type: ignore[typeddict-item] # => this page's slice, after filter+paginate
"total": total, # => co-19, co-20: the FILTERED total -- proves filter+pagination interact correctly
"next": next_offset if next_offset < total else None, # => None once the filtered set is exhausted
}
init_db() # => co-15: runs once at import time, before the app starts serving
app = FastAPI() # => a fresh app -- routes below wire every repository function above to HTTP
# => (fully self-contained: nothing here is imported from any other example directory)
VALID_TOKEN = "s3cr3t-token-abc123" # => hardcoded stand-in for a real signed/opaque token
# => (the SAME literal constant test_app.py's TestTokenAuth class asserts against below)
security = HTTPBearer(auto_error=False) # => auto_error=False: WE own the 401 body's shape below
@app.exception_handler(HTTPException) # => co-11: consistent envelope across every error this app
# => returns -- unwraps FastAPI's default {"detail": ...} nesting into the flat {"error": {...}} shape
async def handle_http_exception( # => co-11: registered ONCE, catches every HTTPException app-wide
request: Request, exc: HTTPException
) -> JSONResponse:
body = ( # => co-11: normalizes whatever a route raised into ONE consistent envelope shape
exc.detail # => co-11: already a dict -- every raise in this file supplies one directly
if isinstance(exc.detail, dict) # => every raise below already supplies a dict
else {"error": {"code": "error", "message": str(exc.detail)}} # => fallback for a plain string
)
return JSONResponse(status_code=exc.status_code, content=body) # => co-11: same shape, every error
def require_token( # => co-18, co-23: guards every WRITE below -- reused via Depends() per route
credentials: HTTPAuthorizationCredentials | None = Depends(security), # => co-23: resolved BEFORE
# => the guarded route's own handler body ever runs
) -> None: # => co-23: returns nothing -- FastAPI only cares whether this raises or not
if credentials is None or credentials.credentials != VALID_TOKEN: # => co-18: either failure mode
raise HTTPException( # => co-11: structured envelope, matching every other error in this app
status_code=401, # => co-03: 401 Unauthorized -- "who you claim to be" was rejected
detail={"error": {"code": "unauthorized", "message": "missing or invalid token"}},
)
# => co-18: reaching the end of this function without raising IS the "allowed" outcome --
# => FastAPI just runs it for its side effect, and discards the None it returns
@app.post("/tasks", status_code=201, dependencies=[Depends(require_token)]) # => co-02, co-03, co-18:
# => CREATE -- a WRITE, so it's guarded; 201 signals a new resource now exists at the returned id
def post_task(body: CreateTask) -> TaskRow: # => co-10: body already validated against CreateTask
return create_task(body.title) # => co-24: the handler holds no SQL -- co-24 layering
@app.get("/tasks/{task_id}") # => co-02: reads stay OPEN, no token required
def read_task(task_id: int) -> TaskRow: # => co-02: no dependencies=[...] list at all -- unguarded
task = get_task(task_id) # => co-24: delegates straight to the repository, no logic in between
if task is None: # => co-02: no row exists at this id
raise HTTPException( # => co-11: structured envelope, matching every other error in this app
status_code=404, # => co-03: Not Found -- the id is well-formed, just nonexistent
detail={"error": {"code": "not_found", "message": "no such task"}},
)
return task # => co-24: the same TaskRow shape create_task returned on the way in
# => this route is called between the CREATE and UPDATE steps in test_app.py's CRUD test
@app.put("/tasks/{task_id}", dependencies=[Depends(require_token)]) # => co-02, co-18: REPLACE --
# => a WRITE, so it's guarded; RFC 9110 classifies PUT as idempotent, unlike POST above
def put_task(task_id: int, body: UpdateTask) -> TaskRow: # => co-02: id from the PATH, state from the BODY
updated = update_task(task_id, body.title, body.status) # => co-24: no SQL in this handler either
if updated is None: # => co-02: no row matched -- this example's PUT only replaces, never creates
raise HTTPException( # => co-11: structured envelope, matching every other error in this app
status_code=404, # => co-03: Not Found -- consistent with read_task's 404 above
detail={"error": {"code": "not_found", "message": "no such task"}},
)
return updated # => co-24: the fresh, post-update row state
# => co-18: reaching here at all already proves the caller passed the require_token dependency
@app.delete("/tasks/{task_id}", status_code=204, dependencies=[Depends(require_token)]) # => co-02,
# => co-03, co-18: DELETE -- a WRITE, so it's guarded; 204 carries no response body on success
def remove_task(task_id: int) -> None: # => co-02: None return type -- 204 forbids a response body
if not delete_task(task_id): # => co-02: nothing matched this id -- report 404, not a silent no-op
raise HTTPException( # => co-11: structured envelope, matching every other error in this app
status_code=404, # => co-03: Not Found -- the SAME code every other missing-id error uses
detail={"error": {"code": "not_found", "message": "no such task"}},
)
# => co-02: a successful DELETE falls through here with nothing to return -- FastAPI sends 204
@app.get("/tasks") # => co-19, co-20: pagination + filtering, open to everyone -- reads NEVER
# => require a token in this app, exactly matching the earlier standalone pagination/filter examples
def list_tasks(
limit: int = Query(default=10, ge=1, le=50), # => co-19: same bounds as the standalone example
offset: int = Query(default=0, ge=0), # => co-19: same default-when-absent behavior
status: str | None = Query(default=None), # => co-20: independently optional, same as elsewhere
) -> Page:
return list_page(limit, offset, status) # => co-24: the handler holds no SQL at alllearning/code/ex-79-pytest-full-integration/test_app.py
"""Example 79: pytest Full Integration -- CRUD + token + pagination, one green suite (co-14, co-18, co-19)."""
import pytest # => co-22: provides the @pytest.fixture decorator used below
from fastapi.testclient import TestClient # => co-22: drives the app in-process, no real socket needed
from app import app # => co-24: imports the SAME FastAPI instance app.py builds -- one process, no server
AUTH = {"Authorization": "Bearer s3cr3t-token-abc123"} # => co-18: matches app.py's VALID_TOKEN exactly
BAD_AUTH = {"Authorization": "Bearer wrong-token"} # => co-18: any string that is NOT VALID_TOKEN
@pytest.fixture() # => co-22: reruns this function once per test that declares a "client" parameter
def client() -> TestClient: # => co-22: a fresh client per test -- but the SQLite file persists across tests
return TestClient(app) # => co-22: wraps `app` for in-process requests, no uvicorn process needed
# -- CRUD group (co-14) ------------------------------------------------------------------
class TestCrud: # => co-14: groups every create/read/update/delete assertion under one namespace
def test_create_requires_a_token(self, client: TestClient) -> None: # => co-18: guard check first
response = client.post("/tasks", json={"title": "a"}) # => co-18: deliberately NO Authorization header
assert response.status_code == 401 # => co-18: writes are guarded
# => require_token raises BEFORE post_task's own body ever runs -- no row was created
def test_create_read_update_delete_round_trip(self, client: TestClient) -> None: # => co-14: full CRUD cycle
created = client.post("/tasks", json={"title": "write the report"}, headers=AUTH) # => co-18: valid token
assert created.status_code == 201 # => co-03: 201 confirms a new resource now exists
task_id = created.json()["id"] # => co-14: the server-assigned id, read back from the response body
# => every later call in this test reuses THIS id -- one row, tracked through its full lifecycle
read = client.get(f"/tasks/{task_id}") # => co-14: reads are open, no token needed
assert read.status_code == 200 # => co-14: the just-created row is genuinely readable
assert read.json()["status"] == "todo" # => co-15: confirms the schema's DEFAULT applied server-side
updated = client.put( # => co-02: PUT replaces the full resource, guarded by require_token
f"/tasks/{task_id}",
json={"title": "write the report", "status": "done"},
headers=AUTH, # => co-02: full body
) # => co-18: valid token required for this write
assert updated.status_code == 200 # => co-02: a successful replace on an existing id
assert updated.json()["status"] == "done" # => co-14: confirms the UPDATE actually landed
deleted = client.delete(f"/tasks/{task_id}", headers=AUTH) # => co-18: DELETE is guarded too
assert deleted.status_code == 204 # => co-03: no body on a successful delete
gone = client.get(f"/tasks/{task_id}") # => co-14: re-reads the SAME id after deletion
assert gone.status_code == 404 # => co-14: genuinely removed
# => proves delete_task's cursor.rowcount check actually removed the row, not just returned 204
def test_update_missing_id_is_404(self, client: TestClient) -> None: # => co-02: PUT never creates
response = client.put( # => co-02: id 99999 was never created by this test
"/tasks/99999",
json={"title": "x", "status": "todo"},
headers=AUTH, # => co-18: a valid token is supplied
) # => co-18: a valid token alone does not manufacture a matching row
assert response.status_code == 404 # => co-02: confirms PUT only replaces, never upserts
# => distinguishes THIS 404 (valid token, missing row) from the 401 cases in TestTokenAuth below
# -- Token auth group (co-18) ------------------------------------------------------------
class TestTokenAuth: # => co-18: isolates every auth-specific assertion under one namespace
def test_missing_token_is_401(self, client: TestClient) -> None: # => co-18: no header at all
assert client.post("/tasks", json={"title": "a"}).status_code == 401 # => co-18: rejected before create_task runs
# => credentials is None here -- the FIRST branch of require_token's "either failure mode" check
def test_wrong_token_is_401(self, client: TestClient) -> None: # => co-18: a header that fails the comparison
response = client.post("/tasks", json={"title": "a"}, headers=BAD_AUTH) # => co-18: wrong bearer value
assert response.status_code == 401 # => co-18: require_token rejects a non-matching token too
# => credentials.credentials != VALID_TOKEN here -- the SECOND branch of that same check
def test_valid_token_succeeds(self, client: TestClient) -> None: # => co-18: the "allowed" outcome
response = client.post("/tasks", json={"title": "a"}, headers=AUTH) # => co-18: exact VALID_TOKEN match
assert response.status_code == 201 # => co-18: require_token returns None -- the write proceeds
# => this is the ONE outcome where require_token does not raise at all
def test_delete_also_requires_a_token(self, client: TestClient) -> None: # => co-18: guard applies to every WRITE
created = client.post("/tasks", json={"title": "b"}, headers=AUTH).json() # => co-18: create needs a token too
response = client.delete(f"/tasks/{created['id']}") # => no Authorization header
assert response.status_code == 401 # => co-18: DELETE is guarded exactly like POST and PUT
# => confirms require_token is wired to remove_task too, not just post_task and put_task
# -- Pagination group (co-19) -------------------------------------------------------------
class TestPagination: # => co-19: isolates every pagination/filter assertion under one namespace
def test_seeded_pages_have_the_expected_window(self, client: TestClient) -> None: # => co-19: bounded pages
for i in range(20): # => seeds 20 fresh tasks specifically for this pagination check
client.post("/tasks", json={"title": f"paginated task {i}"}, headers=AUTH) # => co-18: each needs a token
first_page = client.get("/tasks", params={"limit": 5, "offset": 0}) # => co-19: no token needed for reads
assert len(first_page.json()["items"]) == 5 # => co-19: a genuinely bounded page
assert first_page.json()["next"] == 5 # => co-19: metadata points at the next window
# => next == limit here because offset started at 0 -- the SAME next_offset formula app.py uses
def test_status_filter_narrows_the_page(self, client: TestClient) -> None: # => co-20: filter + pagination together
created = client.post("/tasks", json={"title": "filter-target"}, headers=AUTH).json() # => co-18: needs a token
client.put( # => co-20: sets status to "done" so the filter below has a real match to find
f"/tasks/{created['id']}", # => co-14: the path identifies WHICH row to update
json={"title": "filter-target", "status": "done"}, # => co-02: PUT's full-replacement body
headers=AUTH, # => co-18: this write also needs a valid token
) # => co-18: PUT is a write, so a valid token is required here too
response = client.get("/tasks", params={"status": "done", "limit": 50}) # => co-20: filtered read, no token
body = response.json() # => co-19, co-20: the SAME Page envelope shape as the standalone example
assert all(t["status"] == "done" for t in body["items"]) # => co-20: every returned row matches
# => a wide limit=50 with no offset ensures the filtered row above is genuinely inside this page
def test_full_integration_smoke_all_three_areas_together() -> None: # => co-14, co-18, co-19: all three at once
# => co-14, co-18, co-19: ONE test touching create (auth), read, list (pagination), and delete --
# => the fullest single-test demonstration this example is named for
client = TestClient(app) # => co-22: a client built directly, outside the shared fixture above
ids = [ # => co-14: creates 3 fresh tasks, each guarded by a valid token
client.post("/tasks", json={"title": f"smoke {i}"}, headers=AUTH).json()["id"] # => co-18: token required
for i in range(3) # => co-14: exactly 3 rows, enough to prove "list" without a large fixture
] # => co-14: the 3 server-assigned ids, collected via a list comprehension
listing = client.get("/tasks", params={"limit": 50}) # => co-19: an unguarded read, wide enough to see all 3
assert all(any(t["id"] == i for t in listing.json()["items"]) for i in ids) # => all 3 are listed
# => co-19: proves list_page's SELECT genuinely returns rows this test itself just created
for task_id in ids: # => co-14: cleans up every id this test created, one DELETE per id
assert client.delete(f"/tasks/{task_id}", headers=AUTH).status_code == 204 # => co-18: needs a token
assert client.get(f"/tasks/{ids[0]}").status_code == 404 # => co-14: genuinely cleaned up
# => spot-checks only the FIRST id -- the loop above already proved every delete call succeededRun: uvicorn app:app --port 8003 in one terminal for the curl sanity check below, plus pytest -v against the same directory (the pytest suite itself never needs a running server -- it drives the app in-process via TestClient).
Output (curl):
=== curl sanity check: POST /tasks (with token) ===
{"id":1,"title":"curl sanity","status":"todo"}
=== curl sanity check: GET /tasks (list) ===
{"items":[{"id":1,"title":"curl sanity","status":"todo"}],"total":1,"next":null}Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 10 items
test_app.py::TestCrud::test_create_requires_a_token PASSED [ 10%]
test_app.py::TestCrud::test_create_read_update_delete_round_trip PASSED [ 20%]
test_app.py::TestCrud::test_update_missing_id_is_404 PASSED [ 30%]
test_app.py::TestTokenAuth::test_missing_token_is_401 PASSED [ 40%]
test_app.py::TestTokenAuth::test_wrong_token_is_401 PASSED [ 50%]
test_app.py::TestTokenAuth::test_valid_token_succeeds PASSED [ 60%]
test_app.py::TestTokenAuth::test_delete_also_requires_a_token PASSED [ 70%]
test_app.py::TestPagination::test_seeded_pages_have_the_expected_window PASSED [ 80%]
test_app.py::TestPagination::test_status_filter_narrows_the_page PASSED [ 90%]
test_app.py::test_full_integration_smoke_all_three_areas_together PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================== 10 passed, 1 warning in 0.39s =========================Key takeaway: test_full_integration_smoke_all_three_areas_together is deliberately ONE test function that creates (with auth), lists (with pagination), and deletes (with auth) -- proving the three mechanisms compose in a single realistic call sequence, not just that each passes its own isolated test class.
Why it matters: Grouping related tests into classes (TestCrud, TestTokenAuth, TestPagination) is a real pytest organizational pattern: it groups tests by CONCERN in the test output (visible in the pytest -v listing below as TestCrud::test_..., TestTokenAuth::test_...), which scales far better than 20+ flat, unrelated-looking test functions once a suite grows past a handful of tests. The reused module-level AUTH/BAD_AUTH header dicts also demonstrate a small but genuinely useful test-authoring habit: define a fixture's shared data once, reuse it across every test that needs it, rather than re-typing the same header literal in a dozen places.
Example 80: Stateless, Two Workers
ex-80 · exercises co-05, co-24
The culmination of every earlier "co-05 caveat" note scattered across this topic's in-memory examples: two SEPARATE OS processes (WORKER_PORT=8003 and WORKER_PORT=8004) serve requests against the SAME on-disk SQLite file, and a caller genuinely cannot tell them apart -- because neither process holds any state the other doesn't also see.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["curl POST :8003/tasks"]:::blue --> B["Worker A process
writes to tasks.db"]:::blue
B --> C[("tasks.db --
the ONLY shared state")]:::teal
C --> D["Worker B process
reads from tasks.db"]:::orange
E["curl GET :8004/tasks/id"]:::orange --> D
D --> F["IDENTICAL row --
same id, title, handled_by"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-80-stateless-two-workers/app.py
"""Example 80: Stateless, Two Workers -- two independent processes sharing only the DB file."""
# => co-05: the culmination of every earlier "co-05 caveat" note scattered across this topic's
# => in-memory examples -- here, TWO SEPARATE OS processes serve requests, and a caller genuinely
# => cannot tell them apart, because neither process holds any state the OTHER doesn't also see
import os # => co-14: reads the WORKER_PORT env var and resolves DB_PATH relative to this file
import sqlite3 # => co-14: the stdlib DB driver -- the ONLY thing shared between the two processes
from typing import TypedDict # => co-09: a typed dict shape for what the repository returns
from fastapi import FastAPI, HTTPException # => co-03: HTTPException raises the 404 branch below
from pydantic import BaseModel # => co-10: validates the POST request body's shape
# => (the two_workers.sh script below starts TWO of these processes, on ports 8003 and 8004)
# => co-05, co-24: DB_PATH resolves relative to THIS file, so both a process started on port 8003 and
# => a SEPARATE process started on port 8004 -- run from the same directory -- point at the EXACT SAME
# => on-disk file. Nothing in this module is shared between the two processes except that file.
DB_PATH = os.path.join(os.path.dirname(__file__), "tasks.db")
# => co-15: the schema below adds ONE column beyond a plain task table -- handled_by exists purely
# => so this demo can PROVE which process wrote each row, by reading it back from the OTHER process
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
handled_by TEXT NOT NULL
);
"""
class TaskRow(TypedDict): # => co-14, co-24: the repository's typed return shape --
# => identical regardless of which of the two processes assembled this particular dict
id: int # => matches the schema's INTEGER PRIMARY KEY column
title: str # => matches the schema's title column
handled_by: str # => matches the schema's handled_by column -- this example's whole proof point
class CreateTask(BaseModel): # => co-10: POST's expected body shape -- identical regardless of
# => which of the two processes happens to receive this specific request
title: str # => co-10: required -- handled_by is NEVER client-settable, only server-assigned
def init_db_if_absent() -> None: # => co-05: only the FIRST process to start creates the schema; the
# => name itself documents the race: whichever process imports this module FIRST wins, harmlessly
conn = sqlite3.connect(DB_PATH) # => second process reuses the SAME file, never re-initializing it
conn.execute(SCHEMA) # => CREATE TABLE IF NOT EXISTS -- safe to call from either process, any order
conn.commit() # => co-14: commits the CREATE TABLE, if this is genuinely the first process to run it
conn.close() # => co-14: connections are short-lived here -- opened, used, closed, never held
def get_connection() -> sqlite3.Connection: # => co-14: the repository's ONLY entry point to the DB
conn = sqlite3.connect(DB_PATH, timeout=5) # => a real timeout -- two processes DO contend for the file
conn.row_factory = sqlite3.Row # => rows behave like dicts -- readable by column name
return conn # => a fresh connection per call -- NEITHER process holds a long-lived connection open
PORT = os.environ.get("WORKER_PORT", "unknown") # => co-05: identifies WHICH process served a given request
# => purely for this demo's own proof -- a real client never needs to know or care which worker answered
def create_task(title: str) -> TaskRow: # => co-14: the ONLY state this write produces lives in the DB file
conn = get_connection() # => co-14: one connection for both the INSERT and the confirming SELECT
cursor = conn.execute( # => co-14: PORT (this PROCESS's own identity) is stamped onto the row itself
"INSERT INTO tasks (title, handled_by) VALUES (?, ?)", (title, PORT)
)
conn.commit() # => co-14: commits the new row -- durably written to the shared file on disk
row = conn.execute( # => co-14: re-reads the row to return its server-assigned id + handled_by
"SELECT id, title, handled_by FROM tasks WHERE id = ?", (cursor.lastrowid,)
).fetchone()
conn.close() # => co-14: closed after both the INSERT and the confirming SELECT finish
return dict(row) # type: ignore[return-value] # => sqlite3.Row -> TaskRow-shaped dict
def get_task(task_id: int) -> TaskRow | None: # => co-05, co-14: reads the SAME file, regardless of
conn = get_connection() # => which process is running THIS particular request
row = conn.execute( # => co-14: a single parameterized lookup by primary key
"SELECT id, title, handled_by FROM tasks WHERE id = ?", (task_id,)
).fetchone() # => co-05: returns the ORIGINAL handled_by, even if a DIFFERENT process reads it now
conn.close() # => co-14: closed immediately after this one query
return dict(row) if row else None # type: ignore[return-value] # => None signals "no such id"
init_db_if_absent() # => co-15: runs at import time in EVERY process -- safe, since CREATE TABLE
# => IF NOT EXISTS is a no-op for whichever process starts second
app = FastAPI() # => a fresh app -- one instance PER process, sharing nothing but the DB file below
# => (fully self-contained: nothing here is imported from any other example directory)
@app.post("/tasks", status_code=201) # => co-05: whichever worker RECEIVES this request stamps its
# => OWN port into handled_by, but the resulting row is immediately visible to the OTHER worker too
# => co-05: this handler holds NO in-process cache or dict at all --
# => everything it produces is written to the shared file, never kept in this process's own memory
def post_task(body: CreateTask) -> TaskRow:
return create_task(body.title) # => co-24: the handler holds no SQL -- co-24 layering
@app.get("/tasks/{task_id}") # => co-05, co-24: statelessness means THIS process needs no memory of
def read_task(task_id: int) -> TaskRow: # => the request that created the row -- the DB file is the
task = get_task(task_id) # => only source of truth, shared identically across every worker
if task is None: # => co-02: no row exists at this id, in EITHER process's view of the shared file
raise HTTPException( # => co-11: structured envelope, matching every other example's shape
status_code=404, # => co-03: Not Found -- identical regardless of which process answers
detail={"error": {"code": "not_found", "message": "no such task"}},
)
return task # => co-05: identical response whether THIS process or the OTHER created the row
@app.get("/whoami") # => reports which worker port answered -- used only to PROVE two processes are involved
def whoami() -> dict[str, str]:
return {"port": PORT} # => co-05: this differs by PROCESS, unlike everything else in a response body
# => a request to worker A and the SAME request replayed to worker B differ ONLY in this one fieldlearning/code/ex-80-stateless-two-workers/two_workers.sh
#!/usr/bin/env bash
# Example 80: two INDEPENDENT uvicorn processes sharing only the SQLite file (co-05, co-24).
# Worker A serves port 8003, Worker B serves port 8004 -- separate OS processes, no shared memory.
set -euo pipefail # => co-22: abort immediately on any failing command, unset var, or pipe error
cd "$(dirname "$0")" # => ensures the relative paths below resolve from this script's own directory
rm -f tasks.db # => co-05: start from a clean, deterministic file both workers will share
VENV_PY="../../../.venv/bin/python" # => the shared venv's interpreter, three levels up from this dir
echo "== starting worker A on :8003 ==" # => co-05: the FIRST of two separate OS processes
WORKER_PORT=8003 "$VENV_PY" -m uvicorn app:app --port 8003 >/tmp/ex80_worker_a.log 2>&1 & # => backgrounds worker A, its own env var, its own log file
WORKER_A_PID=$! # => co-05: captures worker A's OS process id for later confirmation and cleanup
echo "== starting worker B on :8004 ==" # => co-05: the SECOND, entirely separate OS process
WORKER_PORT=8004 "$VENV_PY" -m uvicorn app:app --port 8004 >/tmp/ex80_worker_b.log 2>&1 & # => backgrounds worker B, a DIFFERENT env var, its own log file
WORKER_B_PID=$! # => co-05: captures worker B's OS process id for later confirmation and cleanup
sleep 2 # => gives both uvicorn processes time to finish starting before the curl calls below
echo "== confirm TWO distinct OS processes are running ==" # => co-05: proof this is genuinely two processes
ps -p "$WORKER_A_PID" -o pid,command | tail -1 # => co-05: shows worker A's real OS pid and command line
ps -p "$WORKER_B_PID" -o pid,command | tail -1 # => co-05: shows worker B's real OS pid -- DIFFERENT from A's
echo "== worker A identifies itself ==" # => co-05: /whoami reports which PROCESS answered this request
curl -s http://localhost:8003/whoami # => co-05: hits worker A directly by its own port
echo # => a blank line separator between this step's output and the next
echo "== worker B identifies itself ==" # => co-05: the SAME /whoami route, now against the OTHER process
curl -s http://localhost:8004/whoami # => co-05: hits worker B directly by its own port
echo # => a blank line separator between this step's output and the next
echo "== create a task on WORKER A (:8003) ==" # => co-05, co-24: the WRITE happens through worker A only
# => captures the FULL response body so task_id can be extracted below
create_response=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"title":"created on worker A"}' http://localhost:8003/tasks) # => co-24: routed to worker A's port
echo "$create_response" # => prints the raw JSON body so the created task's id is visible
task_id=$(echo "$create_response" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') # => extracts "id"
echo "== read the SAME task from WORKER B (:8004) -- proves the file, not memory, is the source of truth ==" # => co-05: the READ happens through a DIFFERENT process
curl -s http://localhost:8004/tasks/"$task_id" # => co-05: worker B never saw the write -- only the shared file did
echo # => a blank line separator before the cleanup step below
kill "$WORKER_A_PID" "$WORKER_B_PID" 2>/dev/null || true # => co-05: stops both background processes; || true tolerates an already-exited pid
echo "== workers stopped ==" # => confirms cleanup ran to completionRun: WORKER_PORT=8003 uvicorn app:app --port 8003 and WORKER_PORT=8004 uvicorn app:app --port 8004 as two separate background processes (or bash two_workers.sh, which does both), plus pytest -v against the same directory for the in-process TestClient half of the proof.
Output (curl):
== starting worker A on :8003 ==
== starting worker B on :8004 ==
== confirm TWO distinct OS processes are running ==
40209 ../../../.venv/bin/python -m uvicorn app:app --port 8003
40210 ../../../.venv/bin/python -m uvicorn app:app --port 8004
== worker A identifies itself ==
{"port":"8003"}
== worker B identifies itself ==
{"port":"8004"}
== create a task on WORKER A (:8003) ==
{"id":1,"title":"created on worker A","handled_by":"8003"}
== read the SAME task from WORKER B (:8004) -- proves the file, not memory, is the source of truth ==
{"id":1,"title":"created on worker A","handled_by":"8003"}
== workers stopped ==(The two pid values above -- 40209/40210 -- are OS-assigned and differ on every run; the
important fact is that they are two DIFFERENT numbers, proving two separate processes.)
Output (pytest -v):
============================= test session starts ==============================
plugins: anyio-4.14.2
collecting ... collected 2 items
test_app.py::test_create_then_read_round_trips_through_the_db_alone PASSED [ 50%]
test_app.py::test_reading_an_unknown_id_is_404_not_a_crash PASSED [100%]
=============================== warnings summary ===============================
../../../.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 2 passed, 1 warning in 0.14s =========================Key takeaway: The handled_by column exists purely so this demo can PROVE which process wrote each row by reading it back from the OTHER process -- a real client never needs to know or care which worker answered, which is exactly the point.
Why it matters: This is the payoff for every earlier example that flagged an in-memory dict (SESSIONS in Examples 57/64, ITEMS/NEXT_ID in Example 63) as "not multi-worker safe." That exact risk is made concrete and RESOLVED here: two real, independent uvicorn processes are started, a task is created through worker A, and reading that SAME id back through worker B returns byte-identical data -- because the SQLite file, not either process's memory, is the actual source of truth. A production deployment can therefore add worker processes for capacity without breaking correctness, as long as state lives in shared storage.
← Previous: Intermediate Examples · Next: Capstone →
Last updated July 13, 2026