LearnThatStack Ace your next interview

FastAPI

FastAPI 0.126+ on Pydantic v2 and Python 3.10+ Checked Nov 2026 97 code snippets

The model

  • FastAPI is a thin layer over Starlette, which handles requests, and Pydantic, which validates data. A server such as Uvicorn runs it through ASGI, the async standard between Python servers and apps.
  • FastAPI reads your type hints at startup. It uses them to validate input, serialize output and build the API docs.
  • async def runs on the event loop, and plain def runs in a threadpool. A blocking call inside async def stalls every request on that worker.
  • Depends() gives a route what it needs, such as a session or the current user. FastAPI calls each dependency once per request, and tests can swap it for a fake.
Clientbrowser, curlMiddlewareCORS, loggingValidate & DependsPydantic, Depends()Your functionpath operationINrequestcall_nexttyped argumentsOUTresponse_modelJSON responseSTOP422 bad input
A request passes middleware, then validation and dependencies, before your function runs. Bad input stops at a 422, and your function never sees it.

How FastAPI runs your code

ASGI, the event loop and startup code

ASGI and why FastAPI is fast

Define ASGIThe contract between async Python servers and apps.
Show an ASGI appasync def app(scope, receive, send)
Say what ASGI addsWebSockets, and many requests per process while each awaits I/O.
Explain the speedPydantic validates in compiled Rust. FastAPI reads type hints once, at startup.

TrapThe speed helps I/O-bound work. CPU-heavy Python in your endpoint runs at normal Python speed.

def or async def

awaitABCA, B, Call await I/OblockingA blocksBCdefA holds threadBCtime

With await, one loop serves A, B and C together. A blocking call in async def stops all three. In def, the call holds one thread and the others run.

Call a blocking library such as requestsdef handler():
Await an async library such as httpxasync def handler():

TipCPU-heavy work needs a process pool or a task queue, because of the GIL.

Traptime.sleep(1) inside async def freezes the whole worker for a second.

Run code at startup and shutdown

@asynccontextmanagerasync def lifespan(app: FastAPI): app.state.http = httpx.AsyncClient()startup, once yield await app.state.http.aclose()shutdown, onceapp = FastAPI(lifespan=lifespan)
Spot the deprecated way@app.on_event("startup")

TrapEach worker runs its own lifespan, so four workers load four copies of a model.

Routes & parameters

map URLs and inputs to your function

Routes and where inputs come from

@app.put("/items/{item_id}")def update( item_id: int,named in the path, so path item: Item,Pydantic model, so body q: str | None = None,single type, so query):
Run it and reload on editsuvicorn main:app --reload
Handle one HTTP method@app.post("/items")
Serve several methods from one function@app.api_route("/x", methods=["GET", "POST"])

TipA method with no function on a known path gets a 405, with no code from you.

Path and query parameters

Reject zero and negative idsitem_id: int = Path(..., gt=0)
Write the same rule the newer wayAnnotated[int, Path(gt=0)]
Allow only a fixed set of valuesclass Color(str, Enum):
Make a query parameter optionalq: str | None = NoneWith no default, it's required.
Limit a numberlimit: int = Query(10, le=100)
Limit a stringQuery(min_length=3, pattern="^a")
Accept ?tags=a&tags=btags: list[str] = Query([])

TipA list needs an explicit Query, or FastAPI reads it as the body.

TrapRoutes match in declaration order, so /users/me must come before /users/{user_id}.

Status codes

Set the success codestatus_code=201On the decorator.
Change it for one requestresponse.status_code = 404Declare response: Response.
Stop with an errorraise HTTPException(404)

TrapSetting response.status_code doesn't stop your function. Raising HTTPException does.

Split and version an API

Make a router for one featureAPIRouter(prefix="/users")
Include it in the main appapp.include_router(router)
Version by URL pathapp.include_router(v1, prefix="/v1")
Version by headerapi_version: str = Header("1.0")It hides the version from logs and links.

TipMake a new version only for breaking changes, and flag old routes with deprecated=True.

TrapNever import the app into a router module. That avoids circular imports.

Pydantic models

validate what comes in, shape what goes out

Request bodies and nested models

Say what Pydantic doesIt checks and converts data using type hints.
Make a field optional in the JSONis_offer: bool = False
Nest a model, or a list of themaddress: Addresshomes: list[Address]
Default a field to an empty listtags: list[str] = []It's safe, because Pydantic copies it for each instance.

TipA missing city fails with its full path, ["body", "address", "city"].

Custom validators

Check one field@field_validator("price")
Check several fields together@model_validator(mode="after")
Clean raw input before conversionmode="before"

TipRaise ValueError to fail, and FastAPI returns a 422.

TrapA field validator must return the value. An after-mode model validator must return self.

Pydantic v1 names to v2

Replace .dict().model_dump()
Replace orm_modefrom_attributes=True
Replace class Configmodel_config
Replace @validator@field_validator
Replace @root_validator@model_validator

TipFastAPI dropped v1 in 0.126.0. Since 0.135.2, it needs Pydantic 2.9+.

TrapMost old names only warn. pydantic.BaseSettings and a bare @root_validator raise errors.

Dependency injection

share sessions, auth and settings between routes

Declare and clean up a dependency

def get_db(): db = SessionLocal()before the route try: yield dbthe route gets this finally: db.close()after the response is sent
Say why it helpsShared code in one place, and tests can swap it.
Get its value in a routedb: Session = Depends(get_db)
Run it only for its side effectdependencies=[Depends(auth)]
Stop repeating the same DependsCurrentUser = Annotated[dict, Depends(get_user)]

TipThe finally runs even when the endpoint raises.

TrapPass the function, not its result. Write Depends(get_db), not Depends(get_db()).

Sub-dependencies and caching

routeauthitemsget_db
one callThe route, auth and items all declare get_db. FastAPI calls it once per request.
Build one dependency on anotherdef get_user(db=Depends(get_db)):
Run it every time it's declaredDepends(fn, use_cache=False)
Keep one copy across requests@lru_cache

Trap@lru_cache is per process. Never cache a session or the current user this way.

Auth, roles & CORS

decide who may call what

JWT login

Read the bearer tokenOAuth2PasswordBearer(tokenUrl="token")
Verify it with PyJWTjwt.decode(token, KEY, algorithms=[ALG])
Revoke a token earlyKeep a denylist of token IDs.

TipThe sub claim names the user and exp sets the expiry. JWTs are stateless, so keep them short-lived.

TrapAlways pass an explicit algorithms list. Otherwise a client could choose the algorithm.

API key auth

Read the key from a headerAPIKeyHeader(name="X-API-Key")
Compare it in constant timesecrets.compare_digest(a, b)

TipStore only a hash of each key, and give each client its own.

TrapA plain != stops at the first mismatch, so an attacker can guess a key by timing responses.

Role-based access

Build a role checkdef require_role(role):It returns a dependency that raises 403.
Protect one routeDepends(require_role("admin"))
Choose 401 or 403401 means not authenticated. 403 means known, but without the role.

TrapA role in a JWT stays valid until the token expires. Load it from the database for sensitive actions.

CORS

Allow a browser originadd_middleware(CORSMiddleware)allow_origins=[url]
Allow cookiesallow_credentials=TrueThen none of the lists may be ["*"].
Know what triggers a preflightA custom header or a PUT.

TipBy default no origin is allowed, and GET is the only method.

TrapCORS only controls what browsers let pages read. curl ignores it, so the API still needs authentication.

SQLAlchemy & migrations

sessions, transactions, pools and schema changes

SQLAlchemy and Alembic

Create one engine and a session factorycreate_engine(URL)sessionmaker(bind=engine)
Use it from async defcreate_async_engine(URL)
Use one class as table and schemaSQLModel. FastAPI's own tutorial uses it.
Point Alembic's env.py at your modelstarget_metadata=Base.metadata
Generate a migrationalembic revision --autogenerate
Apply every pending migrationalembic upgrade headAs a deploy step, not at app startup.

TipThe standard session blocks, so use it from plain def routes.

TrapAutogenerate writes a rename as a drop and an add, which loses data. Review every script.

Transactions and connection pools

Commit on success, roll back on errorwith db.begin():
Lock the rows you will change.with_for_update()
Size the poolpool_size=20max_overflow=30
Work out the total connectionsWorkers times pool_size + max_overflow.

TipLock rows in a fixed order to avoid deadlocks. Keep transactions short.

TrapA generic except Exception turns your 400 into a 500. Catch HTTPException first.

Middleware & errors

code that runs around every request

Middleware and request logging

Wrap every request@app.middleware("http")await call_next(request)
Choose middleware or a dependencyA dependency when only some routes need it.
Join the logs of one requestLog X-Request-ID and return it as a header.

TipDon't log request bodies, which often hold passwords or tokens.

TrapThe last middleware you add runs first, because each new one wraps the ones before it.

Handle errors in one place

Catch one exception type from any route@app.exception_handler(ValueError)
Catch 422 errors to change their shapeRequestValidationError
Catch everything elseExceptionLog it, and return a generic 500.

TipHandle Starlette's HTTPException to catch its 404 for unknown routes too.

TrapHTTPException raised in middleware reaches the client as a 500. Return a JSONResponse there.

Files, tasks & WebSockets

work that doesn't fit one JSON reply

File uploads and streaming

Accept a filefile: UploadFile = File(...)Needs python-multipart.
Read a large file in chunksawait file.read(1024 * 1024)
Stream from a generatorStreamingResponse(gen())
Send a file from diskFileResponse(path)

TipUploadFile spools to disk past a size limit. A bytes parameter reads it all into memory.

TrapA file request is form data, so it can't also carry a JSON body. Never trust the client's filename.

Background tasks

Run a function after the responsetasks.add_task(send_email, email)Declare tasks: BackgroundTasks.
Run work you can't loseUse a task queue such as Celery or ARQ.

TrapTasks run in the same process. If the worker crashes, pending tasks are lost, with no retries.

WebSockets

Accept, then wait for each messageawait websocket.accept()await websocket.receive_text()
Notice the client leftexcept WebSocketDisconnect:
Refuse a connectionraise WebSocketException(1008)

TipTo broadcast across workers, add a shared channel such as Redis pub/sub.

TrapBrowsers can't set custom headers on a WebSocket. Send the token in a query parameter or a cookie.

Tests & fake dependencies

send real requests with no server running

TestClient and overrides

Create the clientclient = TestClient(app)
Run the lifespan code toowith TestClient(app) as client:
Replace a dependency everywhereapp.dependency_overrides[get_db] = fake
Reset it after the testapp.dependency_overrides = {}

TipTest functions stay plain def, even for async endpoints.

TrapThe key must be the same function object the routes use. Import get_db, don't redefine it.

Test authentication

Sign a real test tokenjwt.encode(claims, KEY, algorithm=ALG)
Cover what an attacker would tryNo token, an expired token, another key's signature, and a user with no role.

TrapA made-up string such as "valid_token" fails the real jwt.decode.

Deploy, scale & monitor

keep it running under real traffic

Deploy

Use several CPU coresuvicorn main:app --workers 4
Let Gunicorn manage the workersgunicorn main:app -w 4 -k uvicorn_worker.UvicornWorkerThe old uvicorn.workers module is deprecated.
Run on KubernetesOne process per container, not --workers.

TipWrite the Dockerfile's CMD in exec form, so the server receives shutdown signals.

Trap--reload is for development only.

Environment settings

Read env vars and a .env file, with typesfrom pydantic_settings import BaseSettingsclass Settings(BaseSettings):
Share one copy that tests can replaceDepends(get_settings)With @lru_cache on it.

TipA missing required value raises a validation error. Call get_settings() at startup, so a bad deploy fails early.

Health checks

Prove the process answersLiveness. A failure restarts the container.
Test the database toodb.execute(text("SELECT 1"))Readiness. A 503 stops traffic to it.

TrapIf liveness tests the database, a short database outage restarts every container at once.

Speed and monitoring

Find the bottleneck firstProfile, then check p95 latency, database time and event loop lag.
Compress large responsesGZipMiddleware
Expose metrics for Prometheusgenerate_latest()
Count across workersTurn on multiprocess mode.
Choose what to alert onp95 latency and the 5xx rate.

TipA response model is the fastest way to serialize, because Pydantic's Rust core writes the JSON.

TrapLabel by route template, not request.url.path. A series per user ID overloads Prometheus.

Rate limits and Redis caching

Reject extra requestsJSONResponse(status_code=429)
Share counts across workersUse Redis. In memory, each worker counts alone.
Store a result for 10 minutesawait redis.setex(key, 600, value)
Stop a cache stampedeMany requests recompute an expired key. Use a lock or an early refresh.

TipA token bucket refills at a fixed rate, so it allows short bursts.

TrapLeave the user ID out of the cache key and one user sees another's response.

Found this useful? Pass it on.
Pro · $10/mo

This sheet stays free. With Pro, you can:

  • Every answer on this sheet. Read the full answer to all 51 questions it links, not just the 6 free ones.
  • My sheet. The cards you star and the ones you miss in Quiz me, from every sheet, on one page to quiz and print.
  • PDFs. Download this sheet and My sheet, laid out for A4, with every question a link to its answer.
  • The rest of Pro. Every answer in the question library, and 1,000 AI credits a month.