Python Lesson 58 of 71

Web Development: Flask & FastAPI — Building REST APIs

In the requests lesson you learned the client side of HTTP: you sent a request and read the response somebody else produced. This lesson flips it. Now you are the server. A request arrives from the network — a browser, a mobile app, another service, a curl command — and it lands in your Python function. You decide what it means, whether it’s allowed, whether it’s valid, and what comes back. Everything the requests lesson taught you to worry about as a caller — status codes, JSON bodies, timeouts, auth headers — is now something you produce.

Here is the entire job, in one sentence: a request comes in, you route it to a function, that function does the work, and you return a response. Request → route → handler → response. Every web framework in every language is a variation on making those four steps ergonomic and fast. This lesson is about the two that matter in Python today — Flask, the small one you’ll meet in a decade of existing codebases, and FastAPI, the modern default you’ll reach for when you build something new.

Everything below targets Python 3.12+. Every output block is real — copied from a 3.12.3 run against a live server (an in-process TestClient or a background uvicorn process hit with httpx), not paraphrased. The versions in play: FastAPI 0.139, Starlette 1.3, pydantic 2.13, uvicorn 0.51, Flask 3.1, httpx 0.28. One install, in a virtual environment:

python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
python -m pip install fastapi uvicorn pydantic httpx flask pytest

httpx is here because FastAPI’s TestClient is built on it — you get the test client for free by installing it. (You may also see pip install "fastapi[standard]", which bundles uvicorn, httpx and a few CLI extras in one go.)

This is Part 1 of two. It covers the framework, the request pipeline, validation, and REST design — everything up to a working, tested, in-memory API. Part 2 picks up where an in-memory dict stops being honest: real databases (SQLAlchemy sessions injected through the same Depends you’ll meet here), authentication (OAuth2, JWT, hashing passwords), and production deployment. Where this lesson stubs something out, that’s the seam.


Why this matters

The hard part of writing a server is not the happy path. Returning {"status": "ok"} when everything is correct takes four lines in any framework. The hard part is that the request is untrusted input from a stranger, and your function is the thing standing between that stranger and your database. Every field might be missing, the wrong type, out of range, malicious, or simply absent. A price arrives as the string "-5" instead of a positive number. A required name isn’t there at all. Someone sends you 40 MB of JSON to see if you fall over. The client is, at best, a mobile app written by a hurried colleague and, at worst, a script probing you for holes.

In a lot of codebases the response to that reality is a wall of hand-written checks at the top of every handler — if "name" not in body: return error(400), if not isinstance(price, (int, float)): ..., if price <= 0: ... — repeated, subtly inconsistent, and forgotten in exactly the spot that gets exploited. FastAPI’s central idea is to delete that wall. You declare the shape you accept as a Python type, and the framework rejects anything that doesn’t match, before your code runs, with a precise machine-readable error. The validation becomes the type signature. That one move — validation as declaration — is why the bulk of this lesson is FastAPI.

The second thing that makes servers different from scripts is concurrency. A script does one thing and exits. A server handles many requests at once, potentially thousands, and the way it overlaps them decides whether it serves 50 users or 50,000 on the same box. That’s the WSGI-versus-ASGI story, and it’s the reason a single misplaced time.sleep or a synchronous database call inside an async def can bring a healthy-looking server to its knees — a trap we’ll measure directly.

So carry three ideas through the lesson:

Get those straight and a framework is just the machinery that makes them ergonomic.


The server side of HTTP: request → route → handler → response

You already know the anatomy of an HTTP exchange from the client side. Nothing about the bytes changes when you’re the server — you’re just on the other end, reading the request the client built and writing the response it will read.

POST /items HTTP/1.1                             <- method + path: what + which
Host: api.example.com
Content-Type: application/json                    <- headers: how to read the body
Authorization: Bearer ghp_xxx

{"name": "Widget", "price": 9.99}                 <- body: the data you must validate

HTTP/1.1 201 Created                              <- STATUS CODE you choose
Content-Type: application/json
Location: /items/1                                <- headers you set

{"id": 1, "name": "Widget", "price": 9.99}        <- body you produce

A web framework’s job is to turn the top half into something Pythonic — a function call with the path, headers and parsed body as arguments — and to turn your return value into the bottom half. The four steps map cleanly onto the four things a framework gives you:

Step What happens Who does it Your job
Request Raw bytes parsed into method, path, headers, body The server (uvicorn/gunicorn) + framework Declare what body/params you accept
Route Method + path matched to one function The framework’s router Write @app.post("/items")
Handler Your function runs the actual work You The business logic
Response Return value serialized to an HTTP response The framework Return data + a status code

The distinction that trips people coming from scripting: you never write the loop that accepts connections. There’s no while True: accept(). The server (uvicorn, gunicorn) owns the socket and the accept loop; the framework (FastAPI, Flask) owns the routing and the request/response objects; you own the handlers. Three layers, and knowing which one owns what is most of understanding a stack trace when something breaks. When a request hangs, is it the server’s socket handling, the framework’s routing, or your handler? They fail differently and you fix them in different files.


WSGI vs ASGI: two contracts, sync and async

Between “the server owns the socket” and “the framework owns the routing” sits a standard interface so any server can talk to any framework. Python has two, and the difference between them is the single most important architectural fact in Python web development.

WSGI (Web Server Gateway Interface, 2003) is the classic one. It’s a dead-simple contract: the server calls a Python function, that function returns the response, done. It is fundamentally synchronous and one-request-at-a-time per worker — a WSGI worker picks up a request, runs your handler start to finish, and only then picks up the next. Flask, Django (classically), and every mature Python web app for two decades are WSGI. To handle concurrency you run many worker processes/threads (via gunicorn or uWSGI), and each one blocks on its single request.

ASGI (Asynchronous Server Gateway Interface, 2018) is the modern one, built for async/await. An ASGI worker runs an event loop: when a handler awaits something slow (a database query, an outbound HTTP call), the worker doesn’t block — it sets that request aside and works on another, coming back when the slow thing is ready. One worker, one thread, many concurrent requests in flight. FastAPI, Starlette (which FastAPI is built on), and modern Django are ASGI. This is the same event-loop model from the asyncio lesson — a web server is one of the places async pays off most, because serving requests is overwhelmingly I/O-bound (waiting on databases and other services), which is exactly what an event loop is good at.

WSGI ASGI
Introduced 2003 (PEP 333/3333) 2018
Model Synchronous, blocking Async, event-loop
Concurrency unit 1 request per worker Many concurrent requests per worker
Handler style def view(request) async def view(request) (or def)
Servers gunicorn, uWSGI, mod_wsgi uvicorn, hypercorn, daphne
Frameworks Flask, Bottle, classic Django FastAPI, Starlette, modern Django
WebSockets / SSE / HTTP2 ❌ Awkward or impossible ✅ First-class
Scales I/O-bound work by Adding worker processes The event loop (+ workers)
CPU-bound work Fine (already blocking) ⚠️ Blocks the loop — needs care
Best when Simple sync apps, CPU work High-concurrency I/O, real-time

The practical upshot: FastAPI is ASGI, which is why it can be async, which is why it scales I/O-bound workloads on fewer resources than a Flask app doing the same job. But ASGI’s power is also its footgun — because one worker juggles many requests on one thread, anything that blocks that thread stalls all of them. Hold that thought; we’ll measure it.

One reassuring detail: on ASGI you can still write plain synchronous def handlers. FastAPI runs those in a threadpool so they don’t block the loop. You are not forced into async everywhere. The rule for when to use which is later, and it’s more subtle than “async is faster.”


Flask: the microframework you’ll meet everywhere

Flask is a microframework: a tiny, unopinionated core (routing, request/response, a template engine) and nothing else — no ORM, no validation, no auth, no admin. You add what you need from a huge ecosystem of extensions. That minimalism made it the default Python web framework for a decade, which means you will inherit Flask code. Even building new things in FastAPI, you must be able to read and maintain Flask. Here’s enough to do that.

The whole “hello world” is genuinely this small:

# hello.py
from flask import Flask

app = Flask(__name__)          # the application object; __name__ helps it find files

@app.route("/")                 # decorator: bind this URL to this function
def index():
    return "Hello, KloudVin"    # a str return becomes a 200 text/html response

if __name__ == "__main__":
    app.run(port=5000)          # dev server ONLY - never in production
flask --app hello run       # or: python hello.py

The @app.route decorator is the router: it maps a URL to a view function. By default a route answers only GET; you opt into other methods explicitly. The core surface you’ll actually use:

Flask piece What it does Example
Flask(__name__) The application object app = Flask(__name__)
@app.route(path, methods=[...]) Bind a URL + methods to a view @app.route("/items", methods=["POST"])
@app.get(p) / @app.post(p) Shorthands (Flask 2.0+) @app.get("/items")
<converter:name> in the path Capture a path variable @app.route("/items/<int:id>")
request The incoming request (a global proxy) request.get_json(), request.args
jsonify(...) Build a JSON response with the right header return jsonify(data), 201
return body, status, headers Set the status code / headers return {"x": 1}, 201
abort(code) Bail out with an error status abort(404)
render_template(...) Render a Jinja HTML template server-rendered HTML
Blueprint(...) Group routes into a module bp = Blueprint("api", __name__)

The one genuinely confusing thing for newcomers is request. It’s a global you import — but it magically refers to the current request even under concurrency. That’s Flask’s “context locals”: it’s not really global, it’s thread-local, swapped in per request. It reads oddly (a global that isn’t) and it’s the opposite of FastAPI’s explicit “the request is a function argument” approach. Here’s the request object’s surface:

request. Gives you Notes
.method "GET", "POST" The verb
.args Query string params request.args.get("limit") — a MultiDict
.get_json() Parsed JSON body ⚠️ Raises 400 on bad JSON; silent=True returns None
.form Form-encoded body fields HTML form posts
.files Uploaded files multipart/form-data
.headers Request headers Case-insensitive
.view_args Captured path variables {"id": 7}
.json Property alias for get_json() Convenient, same caveats

Here’s a real GET + POST pair with a JSON body — the same two endpoints we’ll build in FastAPI, so the contrast is exact. Notice there is no automatic validation: you check every field by hand.

# flask_api.py
from flask import Flask, jsonify, request

app = Flask(__name__)
_DB, _NEXT = {}, {"n": 0}

@app.get("/items/<int:item_id>")
def read_item(item_id):
    row = _DB.get(item_id)
    if row is None:
        return jsonify(error=f"Item {item_id} not found"), 404
    return jsonify({k: v for k, v in row.items() if k != "owner_token"})   # strip secret by hand

@app.post("/items")
def create_item():
    data = request.get_json(silent=True) or {}
    errors = {}                                        # ⚠️ manual validation, every field
    name, price = data.get("name"), data.get("price")
    if not isinstance(name, str) or not name:
        errors["name"] = "required non-empty string"
    if not isinstance(price, (int, float)) or isinstance(price, bool) or price <= 0:
        errors["price"] = "required number > 0"
    if errors:
        return jsonify(errors=errors), 422
    _NEXT["n"] += 1
    row = {"id": _NEXT["n"], "name": name, "price": price,
           "tags": data.get("tags", []), "owner_token": f"secret-{_NEXT['n']}"}
    _DB[_NEXT["n"]] = row
    return jsonify({k: v for k, v in row.items() if k != "owner_token"}), 201

Exercised with Flask’s built-in test client (no server needed):

c = app.test_client()
print("POST valid ->", c.post("/items", json={"name": "Widget", "price": 9.99, "tags": ["a"]}).status_code,
      c.post("/items", json={"name": "Widget", "price": 9.99}).get_json())
print("POST bad   ->", c.post("/items", json={"price": -5}).status_code,
      c.post("/items", json={"price": -5}).get_json())
print("GET  ok    ->", c.get("/items/1").status_code, c.get("/items/1").get_json())
print("GET  miss  ->", c.get("/items/9").status_code, c.get("/items/9").get_json())
POST valid -> 201 {'id': 2, 'name': 'Widget', 'price': 9.99, 'tags': []}
POST bad   -> 422 {'errors': {'name': 'required non-empty string', 'price': 'required number > 0'}}
GET  ok    -> 200 {'id': 1, 'name': 'Widget', 'price': 9.99, 'tags': ['a']}
GET  miss  -> 404 {'error': 'Item 9 not found'}

It works, and it’s clear. But look at how much of create_item is validationisinstance checks, the sneaky isinstance(price, bool) guard (because in Python True is an int, so isinstance(True, int) is True and True <= 0 is False, meaning a raw bool would sail through your price check), the manual secret-stripping in two places. Every route repeats this. That repetition, and the bugs that hide in it, is precisely what FastAPI automates. Keep this example in mind — it’s the “before” picture.

Jinja templates: when the server renders HTML

Flask also does server-rendered HTML via the Jinja2 template engine — the other half of “web development,” for apps that return pages, not JSON. You won’t use this for a REST API, but you’ll meet it constantly in Flask apps:

from flask import Flask, render_template_string
app = Flask(__name__)

@app.get("/hello/<name>")
def hello(name):
    return render_template_string("<h1>Hi {{ name }}</h1><p>{{ 2 + 2 }}</p>", name=name)

print(app.test_client().get("/hello/<script>").get_data(as_text=True))
<h1>Hi &lt;script&gt;</h1><p>4</p>

Two things to note. {{ name }} interpolates a variable and {{ 2 + 2 }} evaluates an expression (4) — that’s Jinja. And critically, the <script> in the name came back escaped to &lt;script&gt;: Jinja auto-escapes HTML by default, which is your first line of defence against cross-site scripting (XSS). In real apps you use render_template("page.html", ...) with files in a templates/ folder, plus template inheritance ({% extends %}, {% block %}) — a whole topic, but this is the shape. Django, by contrast, ships this (and an ORM and an admin) in the box; Flask makes it opt-in.

The Flask ecosystem, briefly

Because the core is tiny, real Flask apps are a stack of extensions. Recognising the names helps you read a codebase:

Extension Adds FastAPI equivalent
Flask-SQLAlchemy ORM / database SQLAlchemy directly (Part 2)
Flask-Migrate (Alembic) DB migrations Alembic directly
Marshmallow / Flask-RESTX Serialization + validation Built in via pydantic
Flask-Login Session auth Depends + OAuth2 (Part 2)
Flask-JWT-Extended JWT auth Depends + python-jose (Part 2)
Flask-CORS CORS headers Built-in CORSMiddleware
Blueprints Route grouping APIRouter

Notice how many of those FastAPI folds into its core. That’s the philosophical difference in one table: Flask is a small core plus a bazaar of extensions you assemble; FastAPI is a batteries-included core where validation, serialization, docs and CORS are already there.


FastAPI: path operations and routing

Everything from here is FastAPI, because it’s the modern default and where you’ll spend your time. Start with routing. FastAPI calls a route a path operation: a path (/items/{id}) combined with an operation (the HTTP method). You declare one with a decorator named after the method.

from fastapi import FastAPI

app = FastAPI(title="Widget API", version="1.0.0")

@app.get("/")
def root():
    return {"service": "widgets", "status": "ok"}    # a dict becomes a JSON 200

Returning a dict (or list, or pydantic model, or None) gives you a JSON response automatically — no jsonify. The method decorators are the CRUD verbs from the requests lesson, now on the receiving end:

Decorator HTTP method REST meaning Typical status on success
@app.get(path) GET Read a resource / list 200 OK
@app.post(path) POST Create a resource 201 Created
@app.put(path) PUT Replace a resource wholesale 200 OK
@app.patch(path) PATCH Partially update 200 OK
@app.delete(path) DELETE Remove a resource 204 No Content
@app.head / @app.options HEAD / OPTIONS Metadata / capabilities 200 OK

Those verbs map onto CRUD — Create, Read, Update, Delete — which is the backbone of REST design we’ll formalise later. For now: POST creates, GET reads, PUT/PATCH update, DELETE removes.

As an app grows past a handful of routes you split them with APIRouter, FastAPI’s equivalent of Flask blueprints — a group of path operations you define in their own module and include_router into the app under a shared prefix. It keeps a large API navigable and is where versioning lives:

from fastapi import APIRouter
router = APIRouter(prefix="/v1/items", tags=["items"])   # groups + prefixes routes

@router.get("")           # becomes GET /v1/items
def list_items():
    return ["a", "b"]

app.include_router(router)         # GET /v1/items -> 200 ['a', 'b']

The prefix is prepended to every route in the group and the tags group them in /docs, so /v1/items and /v1/users live in separate files and the app just include_routers each. We’ll keep everything in one file for the lab, but reach for APIRouter the moment you have more than one resource — it’s also the cleanest place to bump an API version.


Pydantic: the killer feature

This is the reason FastAPI exists and the bulk of what makes it productive. A pydantic model is a class that declares the shape of some data as typed fields; FastAPI uses it to parse, validate, document, and serialize both requests and responses. You met dataclasses in the domain-models lesson; a pydantic BaseModel is that idea with runtime validation and coercion welded on.

from pydantic import BaseModel, Field

class ItemCreate(BaseModel):
    name: str = Field(min_length=1, max_length=50)
    price: float = Field(gt=0, description="Price in INR, must be positive")
    tags: list[str] = Field(default_factory=list)

That class is simultaneously four things: a parser (JSON → typed Python object), a validator (rejects anything violating the types or constraints), a schema (generates OpenAPI/JSON Schema), and a serializer (typed object → JSON). You wrote validation once, as types, and got the other three free.

To use it as a request body, you type a handler parameter with it. FastAPI sees a pydantic-typed parameter and knows: read the request body as JSON, validate it against this model, and pass the validated instance in.

@app.post("/items")
def create_item(payload: ItemCreate):        # <- body validated against ItemCreate
    return {"received": payload.name, "price": payload.price}

When the body is valid, payload is a fully-typed ItemCreatepayload.price is a float, editor autocomplete works, and you never wrote a single if. When it’s invalid, the caller never reaches your function. FastAPI returns a 422 Unprocessable Entity whose body names every problem. This is the demonstration the whole section is built around — a POST with a missing name and a negative price:

from fastapi.testclient import TestClient
c = TestClient(app)
r = c.post("/items", json={"price": -5})       # name missing, price <= 0
print("status:", r.status_code)
print(r.json())
status: 422
{'detail': [
  {'type': 'missing',      'loc': ['body', 'name'],  'msg': 'Field required',
   'input': {'price': -5}},
  {'type': 'greater_than', 'loc': ['body', 'price'], 'msg': 'Input should be greater than 0',
   'input': -5, 'ctx': {'gt': 0.0}}]}

Read that response, because it is doing your error handling for you. It found both problems in one pass (not just the first). Each entry has a type (machine-readable: missing, greater_than), a loc pointing at the exact field (["body", "name"]), a human msg, and the offending input. A client can render that straight into form errors. You wrote none of it — it fell out of the type declaration. That is the “before/after” against the Flask create_item above: the wall of isinstance checks, deleted, replaced by field types.

Field constraints

Field(...) attaches validation rules to a field. The common ones:

Constraint Applies to Meaning
min_length / max_length str, list Length bounds
gt / ge / lt / le numbers Greater/less than (or equal)
multiple_of numbers Divisibility
pattern str Regex must match
default=... any Default value → field is optional
default_factory=... mutable defaults list, dict, datetime.now
description= / examples= any Feeds the OpenAPI docs
alias= any Accept a different JSON key (e.g. camelCase)

Types themselves are constraints too — EmailStr validates an email, HttpUrl a URL, int rejects "abc", datetime parses ISO-8601 strings. And pydantic coerces where it’s safe: send "5" for an int field and you get 5 (an actual int). That leniency is usually a convenience, occasionally a surprise — worth knowing it happens.

class C(BaseModel):
    n: int
print(C(n="5").n, type(C(n="5").n).__name__)     # -> 5 int   (string coerced)

Custom validation with field_validator

Constraints cover the common cases; for anything bespoke — normalising a value, cross-checking, a business rule — you write a @field_validator. It’s a classmethod that receives the field’s value and either returns a (possibly transformed) value or raises ValueError to reject it, which FastAPI surfaces as a 422 like any other validation failure.

from pydantic import BaseModel, Field, field_validator

class ItemCreate(BaseModel):
    name: str = Field(min_length=1, max_length=50)
    tags: list[str] = Field(default_factory=list)

    @field_validator("name")
    @classmethod
    def normalise_name(cls, v: str) -> str:
        return v.strip().title()                 # transform: "  wIDget " -> "Widget"

    @field_validator("tags")
    @classmethod
    def no_duplicate_tags(cls, v: list[str]) -> list[str]:
        if len(v) != len(set(v)):
            raise ValueError("tags must be unique")   # reject -> 422
        return v

print(ItemCreate(name="  wIDget ", tags=["a", "b"]).name)     # -> Widget
normalized: Widget
rejected  : value_error | Value error, tags must be unique

A validator that transforms (like normalise_name) runs on every valid request and cleans the data before your handler sees it; one that raises (like no_duplicate_tags) rejects the request with the value_error type in the 422 detail. For rules that span multiple fields (e.g. “end_date must be after start_date”), use @model_validator(mode="after"), which sees the whole validated model. This is the extension point that keeps validation declarative even when the rule isn’t a simple bound.

response_model: shaping the output and stripping secrets

Validation on the way in is half the value. The other half is controlling what goes out. By default, whatever you return gets serialized — and if your handler returns a database row that happens to contain a password hash, an internal owner_token, or a soft-delete flag, it ships to the client. The fix is response_model: declare the output shape, and FastAPI serializes only those fields.

class ItemPublic(BaseModel):        # the PUBLIC shape - no secrets
    id: int
    name: str
    price: float
    tags: list[str]

@app.post("/items", response_model=ItemPublic, status_code=201)
def create_item(payload: ItemCreate):
    row = payload.model_dump()
    row["id"] = 1
    row["owner_token"] = "secret-1"      # an internal field
    return row                           # returns the WHOLE dict...

The handler returns a dict including owner_token, but the response is filtered to ItemPublic:

POST /items -> 201
{"id": 1, "name": "Widget", "price": 9.99, "tags": ["a", "b"]}   <- owner_token GONE

Now watch the same handler without response_model, returning the same dict:

@app.get("/items-leaky/{item_id}")       # NO response_model
def read_leaky(item_id: int):
    return {"id": item_id, "name": "Widget", "owner_token": "secret-1"}
GET /items-leaky/1 -> 200
{"id": 1, "name": "Widget", "owner_token": "secret-1"}          <- SECRET LEAKED

That contrast is one of the most important habits in this lesson: response_model is a security boundary, not a formality. It also decouples your public API from your internal storage — you can refactor the row’s fields without changing what clients see. Related knobs:

Option Effect
response_model=Model Serialize only Model’s fields; drop the rest
response_model_exclude={"debug"} Drop specific fields
response_model_exclude_none=True Omit fields that are None
response_model_exclude_unset=True Omit fields the caller never set
status_code=201 The success status for this operation
class Out(BaseModel):
    id: int; name: str; debug: str | None = None

@app.get("/a", response_model=Out, response_model_exclude={"debug"})
def a(): return Out(id=1, name="x", debug="SECRET")
# GET /a -> {"id": 1, "name": "x"}       debug excluded

The free interactive docs

Because every request and response model is also a schema, FastAPI generates a complete OpenAPI 3.1 description of your API and serves two interactive documentation UIs with zero extra work: Swagger UI at /docs (try requests in the browser) and ReDoc at /redoc. The raw schema lives at /openapi.json.

spec = c.get("/openapi.json").json()
print("openapi:", spec["openapi"], "| title:", spec["info"]["title"])
print("ItemCreate required:", spec["components"]["schemas"]["ItemCreate"]["required"])
print("price constraint:", spec["components"]["schemas"]["ItemCreate"]["properties"]["price"])
print("/docs ->", c.get("/docs").status_code, c.get("/docs").headers["content-type"][:24])
openapi: 3.1.0 | title: Widget API
ItemCreate required: ['name', 'price']
price constraint: {'type': 'number', 'exclusiveMinimum': 0.0, 'title': 'Price', 'description': 'Price in INR, must be positive'}
/docs -> 200 text/html; charset=utf-8

The gt=0 became exclusiveMinimum: 0.0 in the schema; Field(min_length=1) became minLength; the description is there for readers. Your validation rules are your documentation, always in sync because they’re the same source. Here’s the full request pipeline these pieces form:

Diagram of one FastAPI request flowing left to right through five zones: an HTTP client sends POST /items; the uvicorn ASGI server and its router match the method and path; the Depends dependency and the pydantic model validate the body, with a red 422 branch that rejects bad input before the handler runs; the Python handler executes the business logic; and the response zone runs response_model to filter out secret fields, returns a 201 with a JSON body, and exposes the same models as auto-generated OpenAPI docs at /docs

Trace it left to right and the design falls out: types on the function signature become the contract, pydantic enforces it at the door (the red 422 branch fires before your code), Depends injects shared logic, and response_model re-serializes the result so a secret can’t slip out — while the same models power /docs for free. The two coloured badges to watch are red (validation → 422) and teal (response filtering); those are the gates most bugs try to sneak past.


Path, query, and body parameters

FastAPI reads a handler’s signature and decides, per parameter, where each value comes from — path, query string, or body — using a clear rule. Getting this rule wrong is a top-three beginner confusion, so here it is explicitly.

Parameter kind Comes from How FastAPI decides Example
Path The URL path Name matches a {placeholder} in the path /items/{item_id}item_id: int
Query The ?a=1&b=2 string A simple type (int, str, bool) not in the path def list(limit: int = 10)
Body The request body (JSON) Typed with a pydantic model def create(payload: ItemCreate)

So in def read(item_id: int, verbose: bool = False, payload: ItemCreate): item_id is a path param (it’s in the path), verbose is a query param (simple type, has a default, not in the path → ?verbose=true), and payload is the body (it’s a pydantic model). The types aren’t decoration — they’re parsed and validated. Ask for item_id: int and a request to /items/abc gets an automatic 422; you never see "abc".

Query parameters get the same Field-style constraints via Query(...), and validation failures there produce a 422 just like body validation:

from typing import Annotated
from fastapi import Query

@app.get("/items")
def list_items(limit: Annotated[int, Query(ge=1, le=100)] = 10,
               tag: str | None = None):        # optional query param
    ...
print(c.get("/items", params={"limit": 999}).status_code)   # over the max
print(c.get("/items", params={"limit": 999}).json())
422
{'detail': [{'type': 'less_than_equal', 'loc': ['query', 'limit'],
   'msg': 'Input should be less than or equal to 100', 'input': '999', 'ctx': {'le': 100}}]}

Note 'loc': ['query', 'limit'] — the error tells the caller it was the query parameter, not a body field. The Annotated[int, Query(...)] form is the modern, recommended way to attach metadata to a parameter (it keeps the type and the constraint together and plays well with type checkers); you’ll also see the older limit: int = Query(10, ge=1) style in existing code.


Dependency injection with Depends

Real handlers share logic: “get the current user from the token,” “open a database session,” “parse common pagination params,” “enforce this API is rate-limited.” Copy-pasting that into every route is how it drifts out of sync. FastAPI’s answer is dependency injection: write the shared logic as a function, declare it as a dependency with Depends, and FastAPI runs it per request and hands the result to your handler.

from fastapi import Depends, HTTPException
from typing import Annotated

_DB: dict[int, dict] = {}

def get_item_or_404(item_id: int) -> dict:      # shared lookup, used by 3 routes
    row = _DB.get(item_id)
    if row is None:
        raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
    return row

ItemDep = Annotated[dict, Depends(get_item_or_404)]    # name the dependency once

@app.get("/items/{item_id}", response_model=ItemPublic)
def read_item(item: ItemDep):                   # FastAPI runs get_item_or_404, injects the row
    return item

@app.delete("/items/{item_id}", status_code=204)
def delete_item(item: ItemDep):                 # SAME dependency, reused
    _DB.pop(item["id"])

get_item_or_404 itself takes item_id — a path parameter — so a dependency can have its own parameters, which FastAPI resolves from the request the same way it resolves a handler’s. It can raise HTTPException to short-circuit the request (the 404 here happens before the handler runs). And crucially, the same ItemDep is reused by read, update, and delete — write the lookup once, get consistent 404s everywhere.

Depends is how you do Example dependency Returns / does
Fetch-or-404 get_item_or_404(item_id) The row, or raises 404
Auth / current user get_current_user(token) The user, or raises 401
Database session get_db() A session, cleaned up after (Part 2)
Shared query params pagination(skip=0, limit=10) A params object
Feature gate / rate limit require_pro_plan(user) Passes or raises 403

Two things make Depends more than a helper function. It’s testable: app.dependency_overrides[get_db] = fake_db swaps a dependency in tests without touching the routes — the seam that makes the Part 2 database code unit-testable. And it’s recursive: get_current_user can itself Depends on get_db, and FastAPI resolves the whole chain, caching each dependency once per request. This is the bridge to Part 2: the database session and the authenticated user both arrive through Depends.

⚠️ The classic mistake is calling the dependency instead of passing it. You pass the function object, Depends(get_db); FastAPI calls it. Write Depends(get_db()) — with parentheses — and you’ve called it yourself at import time and handed Depends the return value, which isn’t callable:

TypeError: {'conn': 'db-handle'} is not a callable object

That fires at startup, when the route is registered — a fail-fast you’ll see immediately, not a runtime surprise.


Async endpoints and the blocking-call trap

FastAPI lets you write a handler as either def or async def, and choosing wrong is the sharpest performance edge in the framework. Here is the rule, and it is not “async is faster”:

This is not theoretical, and it’s the direct application of everything in the asyncio lesson. Three endpoints, each “taking one second,” hit with 5 concurrent requests:

import asyncio, time
from fastapi import FastAPI
app = FastAPI()

@app.get("/block")
async def block():
    time.sleep(1)            # ❌ BLOCKING inside async def - freezes the loop
    return {"route": "block"}

@app.get("/await")
async def await_ok():
    await asyncio.sleep(1)   # ✅ yields to the loop - others proceed
    return {"route": "await"}

@app.get("/sync")
def sync_ok():
    time.sleep(1)            # ✅ plain def -> threadpool -> does NOT block the loop
    return {"route": "sync"}

Run it under uvicorn and fire 5 concurrent requests at each route with httpx.AsyncClient:

/block   5 concurrent x 1s sleep -> 5.06s wall     <- SERIALIZED: 1+1+1+1+1
/await   5 concurrent x 1s sleep -> 1.02s wall     <- overlapped
/sync    5 concurrent x 1s sleep -> 1.02s wall     <- overlapped (threadpool)

Read those numbers slowly. /await and /sync handled five concurrent one-second requests in one second — they overlapped. /block took five seconds: because time.sleep blocked the single event-loop thread, the five requests ran one after another, and a sixth would have waited six seconds. The async version is 5x slower than the sync one — the exact opposite of the intuition that “async is faster.” And there is no error, no warning, no traceback; the server just gets mysteriously slow under load. This is why the rule matters: async def is a promise that you will only await inside it. Break the promise with one synchronous database call and you’ve converted your concurrent server into a sequential one.

Handler style Loop behaviour Use for Danger
async def + await Yields on I/O; concurrent Async drivers, httpx.AsyncClient
async def + blocking call ❌ Freezes the loop — never Serializes everything
plain def Runs in threadpool Sync DB, requests, CPU, files Threadpool has a size limit
async def + run_in_threadpool Offload a blocking bit One blocking call in async code Slightly more code

The safe default if you’re unsure: write plain def. FastAPI’s threadpool handles it correctly, and you can’t create the blocking trap. Reach for async def deliberately, when you have real awaits.


Status codes, HTTPException, and error handling

You are now the one choosing the status code, and getting it right is part of a good API. The requests lesson had a table of what codes mean; here’s what you return, per operation:

Situation Return Why
GET succeeded 200 Default for GET
POST created a resource 201 ⚠️ Not 200 — signals creation; set Location
POST did work, nothing to return 200 or 204 204 if truly empty
DELETE succeeded 204 No body — ⚠️ don’t return JSON
Accepted for async processing 202 “Got it, working on it”
Validation failed (framework) 422 Automatic from pydantic
Bad request (your check) 400 Malformed in a way pydantic didn’t catch
Not authenticated 401 Missing/invalid token
Authenticated but forbidden 403 Known, not allowed
Resource not found 404 raise HTTPException(404)
Conflict (duplicate) 409 Unique constraint violated
Your bug 500 Uncaught exception → FastAPI returns 500

You set the success code with status_code= on the decorator (use the status module for readable names: status.HTTP_201_CREATED). You signal an error by raising HTTPException — from a handler or a dependency — which FastAPI turns into a JSON error response:

from fastapi import HTTPException, status

@app.get("/items/{item_id}")
def read(item_id: int):
    row = _DB.get(item_id)
    if row is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
                            detail=f"Item {item_id} not found")
    return row
GET /items/999 -> 404
{"detail": "Item 999 not found"}

raise rather than return matters: it unwinds cleanly from anywhere in the call stack — a dependency three levels deep can raise HTTPException(401) and the request stops there. For domain-specific errors you’d rather not sprinkle HTTPException through your business logic, register a custom exception handler that maps your own exception type to a response:

from fastapi import Request
from fastapi.responses import JSONResponse

class OutOfStock(Exception):
    def __init__(self, sku): self.sku = sku

@app.exception_handler(OutOfStock)
async def out_of_stock_handler(request: Request, exc: OutOfStock):
    return JSONResponse(status_code=409, content={"detail": f"{exc.sku} is out of stock"})

@app.post("/order/{sku}")
async def order(sku: str):
    if sku == "SOLD":
        raise OutOfStock(sku)        # business code raises a DOMAIN error...
    return {"ordered": sku}
POST /order/SOLD -> 409
{"detail": "SOLD is out of stock"}

Now your service layer raises OutOfStock — a clean domain concept — and the HTTP concern (which status code) lives in one handler at the edge. That separation scales; scattering status codes through business logic doesn’t.

Background tasks

Some work should happen after you’ve answered the client — sending a confirmation email, writing an audit log, invalidating a cache. Blocking the response on it makes the client wait for something they don’t care about. BackgroundTasks runs it after the response is sent:

from fastapi import BackgroundTasks

AUDIT = []
def write_audit(msg: str): AUDIT.append(msg)

@app.post("/order/{sku}")
async def order(sku: str, bg: BackgroundTasks):
    bg.add_task(write_audit, f"ordered {sku}")   # runs AFTER the response
    return {"ordered": sku}
# response returns immediately; AUDIT == ['ordered ABC'] a moment later

⚠️ Background tasks run in the same process, so they’re for quick, best-effort work. If the process dies, the task is lost; if the work is heavy or must not be lost (payment processing, video encoding), you need a real task queue (Celery, RQ, Dramatiq) — a Part 2 / distributed-systems concern.

Middleware and CORS (the browser gotcha)

Middleware wraps every request/response — for logging, timing, adding headers. The one you’ll need on day one is CORS. Here’s the gotcha that will cost you an afternoon: browsers enforce the same-origin policy, so JavaScript on https://app.example.com calling your API on https://api.example.com is blocked by the browser unless your API explicitly says that origin is allowed. Your API is working perfectly; curl and your tests pass; only the browser fails, with a console error about “CORS policy” and no useful status code. The fix is CORSMiddleware:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],   # ⚠️ NOT ["*"] with credentials
    allow_methods=["*"],
    allow_headers=["*"],
)
OPTIONS /order (preflight, Origin: app.example.com) -> 200, allow-origin: https://app.example.com
GET     /order (Origin: evil.example.com)           -> allow-origin header: None (blocked)

The browser sends a preflight OPTIONS request first to ask “am I allowed?”; the middleware answers with the Access-Control-Allow-Origin header for permitted origins and omits it for others, and the browser enforces the rest. ⚠️ Don’t reflexively set allow_origins=["*"] — it’s fine for a public read-only API but forbidden in combination with allow_credentials=True (cookies), and it’s a security smell on anything authenticated. Name your real front-end origins.


Designing a REST API

Framework mechanics aside, REST is a set of conventions for modelling your API around resources (nouns) manipulated by HTTP methods (verbs). Follow them and your API is predictable to anyone who’s used another REST API — which is the whole point.

The core discipline: URLs are nouns, methods are verbs. Don’t put verbs in URLs (/getItem, /createItem, /deleteAllItems); name the resource and let the method say what to do:

Operation ✅ RESTful ❌ Not RESTful Success code
List items GET /items GET /getAllItems 200
Get one GET /items/1 GET /getItem?id=1 200 / 404
Create POST /items POST /createItem 201
Replace PUT /items/1 POST /updateItem 200
Partial update PATCH /items/1 POST /editItem 200
Delete DELETE /items/1 POST /deleteItem 204
Nested (an item’s tags) GET /items/1/tags GET /getItemTags?id=1 200

A few conventions worth building in from the start, because retrofitting them hurts:

Concern Convention Notes
Versioning /v1/items in the path ✅ Simplest, most visible. Also seen: an Accept header version
Pagination ?limit=20&offset=40 or a cursor ✅ Never return an unbounded list; cursor is stable under writes (see the requests lesson)
Filtering ?status=active&tag=x Query params, not new endpoints
Sorting ?sort=-created_at - prefix for descending is a common idiom
Errors Consistent JSON shape ({"detail": ...}) Same shape everywhere; FastAPI’s default is {"detail": ...}
Idempotency PUT/DELETE safe to repeat; POST is not The idempotency-key pattern from the requests lesson

The idempotency point ties straight back to the requests lesson from the other side of the wire: because you’re now the server, you are the one who must make PUT /items/1 produce the same result whether it’s called once or five times, and DELETE return cleanly on a second call (the item’s already gone — a 404 is a fine, honest answer). Design your writes so a client’s retry can’t double-charge or duplicate. That’s not a framework feature; it’s your handler’s responsibility.


Running it: development and production

In development you run uvicorn with auto-reload, so editing a file restarts the server:

uvicorn app:app --reload            # app.py, the FastAPI() object named `app`
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process using StatReload
INFO:     Application startup complete.
INFO:     127.0.0.1:63870 - "GET /items HTTP/1.1" 200 OK

That access-log line is the server-side echo of every request you sent as a client in the last lesson — method, path, protocol, status. The --reload flag is development only: it watches files and costs performance. ⚠️ Shipping --reload (or the bare dev server) to production is a real mistake — it’s slower, single-process by default, and not hardened.

For production you run multiple worker processes so you use all your CPU cores, typically gunicorn managing uvicorn workers:

gunicorn app:app -k uvicorn.workers.UvicornWorker -w 4 --bind 0.0.0.0:8000
Concern Development Production
Command uvicorn app:app --reload gunicorn -k uvicorn.workers.UvicornWorker -w N
Reload on edit --reload ❌ Never
Workers 1 N ≈ CPU cores (I/O-bound: more)
Process manager none gunicorn / systemd / container orchestrator
Behind nothing a reverse proxy (nginx) / load balancer
Flask equivalent flask run gunicorn app:app (sync workers)

The rule of thumb: -w (2 × cores) + 1 for a starting point, then measure. Each worker is a separate process with its own memory — which is the honest reason your in-memory _DB dict won’t survive contact with production: four workers means four separate dicts, and a request hitting worker 2 can’t see what worker 1 stored. That’s not a bug to fix here; it’s the exact reason Part 2 moves the data into a real database that all workers share. Deployment, containers, and the reverse proxy in front are also Part 2.


Flask vs FastAPI vs Django REST: choosing

Three mainstream ways to build an API in Python. The honest guidance:

Flask FastAPI Django REST Framework
Style Microframework Modern, typed, async Batteries-included
Server model WSGI (sync) ASGI (async + sync) WSGI (ASGI-capable)
Validation ❌ Manual / Marshmallow Built-in (pydantic) ✅ Serializers
Auto API docs ❌ Add-on Built-in (/docs) ✅ (browsable API)
Async support ⚠️ Limited ✅ First-class ⚠️ Partial
ORM included ❌ (Flask-SQLAlchemy) ❌ (bring your own) Django ORM
Admin UI Django admin
Type hints drive it Yes Partly
Learning curve ✅ Gentle ✅ Gentle–moderate ⚠️ Steeper
Best for Small apps, legacy, full control New APIs, microservices, async, ML serving Full web apps with DB + admin
Performance Good Excellent (async I/O) Good

The short version. FastAPI is the default for a new API, microservice, or ML model server — the validation, docs, and async are exactly what an API needs, and the type-driven design catches bugs before runtime. Django REST Framework wins when you’re building a whole web application — you want the ORM, migrations, admin, auth, and templates in one integrated box, and the API is one part of it. Flask is the choice for something small where you want total control and minimal magic, and it’s what you’ll maintain in the enormous body of existing Python web code. There’s no wrong answer, only a fit — but if someone says “we’re building a new JSON API in 2026,” FastAPI is the boring, correct default.


Hands-on lab

Build a complete CRUD REST API in FastAPI — create, read, list, update, delete an in-memory Item — with pydantic request/response models, validation, proper status codes, and a Depends. Run it, hit every endpoint (including a 422 from bad input and a 404), view the auto docs, then write the Flask equivalent of GET + POST for contrast.

⚠️ Everything is local and in-memory. No database, no external services, no cost.

Step 1 — Set up

mkdir widget-api && cd widget-api
python3 -m venv .venv
source .venv/bin/activate                 # Windows: .venv\Scripts\activate
python -m pip install fastapi uvicorn pydantic httpx flask pytest

What just happened: an isolated environment. httpx also gives FastAPI’s TestClient its engine, so the tests in Step 5 need no extra install.

Step 2 — The API

# app.py
from __future__ import annotations
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query, status
from pydantic import BaseModel, Field

app = FastAPI(title="Widget API", version="1.0.0")

_DB: dict[int, dict] = {}                  # in-memory store (dies with the process)
_NEXT_ID = {"n": 0}

# ---- models ----
class ItemCreate(BaseModel):
    name: str = Field(min_length=1, max_length=50)
    price: float = Field(gt=0, description="Price in INR, must be positive")
    tags: list[str] = Field(default_factory=list)

class ItemUpdate(BaseModel):               # all optional -> partial update
    name: str | None = Field(default=None, min_length=1, max_length=50)
    price: float | None = Field(default=None, gt=0)
    tags: list[str] | None = None

class ItemPublic(BaseModel):               # the PUBLIC shape - no owner_token
    id: int
    name: str
    price: float
    tags: list[str]

# ---- a dependency: fetch-or-404, reused by read/update/delete ----
def get_item_or_404(item_id: int) -> dict:
    row = _DB.get(item_id)
    if row is None:
        raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
    return row

ItemDep = Annotated[dict, Depends(get_item_or_404)]

# ---- routes ----
@app.post("/items", response_model=ItemPublic, status_code=status.HTTP_201_CREATED)
def create_item(payload: ItemCreate):
    _NEXT_ID["n"] += 1
    row = payload.model_dump()
    row["id"] = _NEXT_ID["n"]
    row["owner_token"] = f"secret-{_NEXT_ID['n']}"     # internal, must not leak
    _DB[_NEXT_ID["n"]] = row
    return row                                         # response_model strips owner_token

@app.get("/items", response_model=list[ItemPublic])
def list_items(limit: Annotated[int, Query(ge=1, le=100)] = 10, tag: str | None = None):
    rows = list(_DB.values())
    if tag is not None:
        rows = [r for r in rows if tag in r["tags"]]
    return rows[:limit]

@app.get("/items/{item_id}", response_model=ItemPublic)
def read_item(item: ItemDep):
    return item

@app.put("/items/{item_id}", response_model=ItemPublic)
def update_item(item: ItemDep, payload: ItemUpdate):
    item.update(payload.model_dump(exclude_unset=True))     # only fields the caller sent
    return item

@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item: ItemDep):
    _DB.pop(item["id"])

# a deliberately-leaky twin, to prove response_model matters:
@app.get("/items-leaky/{item_id}")
def read_leaky(item: ItemDep):
    return item                                          # NO response_model -> secret leaks

What just happened: five CRUD routes, three pydantic models (create / partial-update / public), one shared Depends, correct status codes (201 create, 204 delete), and a secret (owner_token) that the response_model will strip everywhere except the leaky twin.

Step 3 — Run it and open the docs

uvicorn app:app --reload
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Application startup complete.

Open http://127.0.0.1:8000/docs in a browser: you get a full interactive Swagger UI, every endpoint with its schema, and a “Try it out” button that fires real requests. That page is generated entirely from your type hints — you wrote no documentation.

Step 4 — Hit every endpoint (real request/response)

In a second terminal (same venv), drive the running server with httpx:

# drive.py
import httpx

B = "http://127.0.0.1:8000"
with httpx.Client(base_url=B, timeout=5) as x:
    print("CREATE ->", (r := x.post("/items", json={"name": "Widget", "price": 9.99, "tags": ["a", "b"]})).status_code, r.json())
    x.post("/items", json={"name": "Gadget", "price": 20, "tags": ["a"]})
    print("LIST   ->", x.get("/items").status_code, x.get("/items").json())
    print("FILTER ->", x.get("/items", params={"tag": "b"}).json())
    print("READ   ->", x.get("/items/1").status_code, x.get("/items/1").json())
    print("404    ->", x.get("/items/999").status_code, x.get("/items/999").json())
    print("UPDATE ->", x.put("/items/1", json={"price": 12.50}).json())
    print("422    ->", (r := x.post("/items", json={"price": -5})).status_code, r.json()["detail"][0]["type"], r.json()["detail"][1]["type"])
    print("LEAK   ->", x.get("/items-leaky/1").json())
    print("DELETE ->", x.delete("/items/2").status_code, repr(x.delete("/items/2").status_code))
CREATE -> 201 {'id': 1, 'name': 'Widget', 'price': 9.99, 'tags': ['a', 'b']}
LIST   -> 200 [{'id': 1, 'name': 'Widget', 'price': 9.99, 'tags': ['a', 'b']}, {'id': 2, 'name': 'Gadget', 'price': 20.0, 'tags': ['a']}]
FILTER -> [{'id': 1, 'name': 'Widget', 'price': 9.99, 'tags': ['a', 'b']}]
READ   -> 200 {'id': 1, 'name': 'Widget', 'price': 9.99, 'tags': ['a', 'b']}
404    -> 404 {'detail': 'Item 999 not found'}
UPDATE -> {'id': 1, 'name': 'Widget', 'price': 12.5, 'tags': ['a', 'b']}
422    -> 422 missing greater_than
LEAK   -> {'name': 'Widget', 'price': 12.5, 'tags': ['a', 'b'], 'id': 1, 'owner_token': 'secret-1'}

What just happened: the whole CRUD lifecycle over a real socket. CREATE returned 201 and the response has no owner_tokenresponse_model stripped it. 404 came from the dependency, before the handler. 422 caught both the missing name and the negative price. And LEAK — the twin without response_model — shows owner_token: secret-1 sitting right there in the response. That single diff is the argument for response_model.

Step 5 — Tests that need no running server

TestClient runs the app in-process — no uvicorn, no socket, no network — so tests are fast and deterministic. (Under the hood it uses httpx; on the newest Starlette you may see a StarletteDeprecationWarning about it — harmless.)

# test_app.py
import pytest
from fastapi.testclient import TestClient
from app import app, _DB, _NEXT_ID

@pytest.fixture
def client():
    _DB.clear(); _NEXT_ID["n"] = 0        # isolate each test from the shared store
    return TestClient(app)

def test_create_returns_201_and_strips_secret(client):
    r = client.post("/items", json={"name": "Widget", "price": 9.99, "tags": ["a"]})
    assert r.status_code == 201
    assert r.json() == {"id": 1, "name": "Widget", "price": 9.99, "tags": ["a"]}
    assert "owner_token" not in r.json()          # response_model did its job

def test_read_missing_is_404(client):
    assert client.get("/items/999").status_code == 404

def test_bad_body_is_422_naming_fields(client):
    r = client.post("/items", json={"price": -5})
    assert r.status_code == 422
    assert {e["type"] for e in r.json()["detail"]} == {"missing", "greater_than"}

def test_query_out_of_range_is_422(client):
    assert client.get("/items", params={"limit": 999}).status_code == 422

def test_full_crud_round_trip(client):
    iid = client.post("/items", json={"name": "A", "price": 1}).json()["id"]
    assert client.put(f"/items/{iid}", json={"price": 2}).json()["price"] == 2
    assert client.delete(f"/items/{iid}").status_code == 204
    assert client.get(f"/items/{iid}").status_code == 404     # gone after delete
python -m pytest test_app.py -q
.....                                                                     [100%]
5 passed in 0.16s

What just happened: five real behaviours — the 201 + secret-stripping, the 404, the 422 naming both bad fields, query validation, and a full create→update→delete→gone round trip — verified in 0.16 seconds with no server running. This is the testing lesson’s pytest applied to an API: fast, offline, deterministic. Note the fixture clears _DB each test, because the in-memory store is shared module state — the same “stateful test double” gotcha you’ll meet in every integration suite.

Step 6 — The same two endpoints in Flask

For contrast, GET + POST in Flask — same behaviour, manual validation:

# flask_app.py
from flask import Flask, jsonify, request
app = Flask(__name__)
_DB, _NEXT = {}, {"n": 0}

@app.get("/items/<int:item_id>")
def read_item(item_id):
    row = _DB.get(item_id)
    if row is None:
        return jsonify(error=f"Item {item_id} not found"), 404
    return jsonify({k: v for k, v in row.items() if k != "owner_token"})

@app.post("/items")
def create_item():
    data = request.get_json(silent=True) or {}
    errors = {}
    if not isinstance(data.get("name"), str) or not data.get("name"):
        errors["name"] = "required non-empty string"
    p = data.get("price")
    if not isinstance(p, (int, float)) or isinstance(p, bool) or p <= 0:
        errors["price"] = "required number > 0"
    if errors:
        return jsonify(errors=errors), 422
    _NEXT["n"] += 1
    row = {"id": _NEXT["n"], "name": data["name"], "price": p,
           "tags": data.get("tags", []), "owner_token": f"secret-{_NEXT['n']}"}
    _DB[_NEXT["n"]] = row
    return jsonify({k: v for k, v in row.items() if k != "owner_token"}), 201
c = app.test_client()
print("POST ok  ->", c.post("/items", json={"name": "Widget", "price": 9.99, "tags": ["a"]}).status_code)
print("POST bad ->", c.post("/items", json={"price": -5}).status_code, c.post("/items", json={"price": -5}).get_json())
print("GET  404 ->", c.get("/items/9").status_code)
POST ok  -> 201
POST bad -> 422 {'errors': {'name': 'required non-empty string', 'price': 'required number > 0'}}
GET  404 -> 404

What just happened: the same API surface, and it works — but count the hand-written validation, the isinstance(p, bool) guard against Python’s True == 1 quirk, and the manual secret-stripping in two places. Then look back at the FastAPI version, where the same guarantees came from three lines of typed model. That is the case for FastAPI in one side-by-side, and also why you can still read and maintain the Flask you’ll inherit.


Common mistakes and troubleshooting

Symptom / traceback Cause Fix
422 Unprocessable Entity you didn’t expect Request body/params don’t match the pydantic model — wrong type, missing field, failed constraint Read detail[].loc — it names the exact field. Fix the client’s JSON or the model
A secret (token, hash) appears in the JSON response Returned a dict/row without response_model, or the model includes the field Add response_model=PublicModel; keep secrets out of the public model
Server mysteriously slow under load, no errors Blocking call inside async def — froze the event loop Use plain def (threadpool), or await a real async call. Measured 5x here
TypeError: ... is not a callable object at startup Depends(get_db()) — you called the dependency Pass the function: Depends(get_db), no parentheses
Browser: “blocked by CORS policy”; curl works fine No CORSMiddleware, or the origin isn’t allowed add_middleware(CORSMiddleware, allow_origins=[...]) with your front-end origin
POST returns 200, client expected 201 Forgot status_code=201 on the decorator @app.post(..., status_code=status.HTTP_201_CREATED)
AssertionError / ResponseValidationError on response Handler returned data that doesn’t fit response_model (missing required field) Return all fields the model requires, or relax the model
Value showed up as a query param, not the body (or vice-versa) Path/query/body inference: simple types → query, pydantic model → body Type body params with a BaseModel; match path names to {placeholders}
RuntimeError: no running event loop in a test Called an async def handler directly Use TestClient (it manages the loop) or pytest-asyncio
DELETE returns a body / breaks a strict client Returned JSON with a 204 status 204 means no bodyreturn nothing
Data vanishes / is inconsistent across requests in prod In-memory store + multiple workers = separate memory each Use a shared database (Part 2). In-memory only works single-process
--reload not picking up changes Editing a file uvicorn isn’t watching, or ran without --reload Run uvicorn app:app --reload; check the watched dir
ImportError: cannot import name 'TestClient' httpx not installed (TestClient depends on it) pip install httpx (or fastapi[standard])
Unknown JSON fields silently ignored pydantic v2 default is extra="ignore" Set model_config = ConfigDict(extra="forbid") to reject unknown keys
bool passed where a positive number expected slips through isinstance(True, int) is True in Python (Flask manual checks) Guard with isinstance(x, bool), or let pydantic’s float type handle it

Three of these are worth more than a row.

The blocking-call trap is the one with no traceback. Every other bug here throws an exception or a visible 4xx. This one just makes your server slow, and only under concurrency, so it passes every single-request test and every local check, then falls over in production when real load arrives. The tell is latency that scales with concurrency — response times fine at one request, terrible at fifty. The cause is almost always a synchronous call (a requests call, a sync DB driver, a time.sleep, a heavy CPU loop) inside an async def. The fix is a decision, not a patch: either make the handler plain def (FastAPI threadpools it) or replace the blocking call with a real await. When in doubt, write def — you cannot create this trap in a synchronous handler.

response_model is not cosmetic — it’s a data-exfiltration boundary. The leaky-twin demo wasn’t a toy: the single most common way secrets escape a Python API is a handler that returns a database row (return user) where the row object carries a password_hash, an internal flag, or another user’s data through a relationship. Without response_model, all of it serializes and ships. Declare the public shape and return through it, every time. Treat any handler returning a raw ORM object or a full dict without a response_model as a bug in code review.

The pydantic “mutable default” question has a surprising answer. In a plain function or a @dataclass, def f(tags=[]) is the classic Python bug — the list is created once and shared across every call. Many people carry that fear into pydantic and reach for Field(default_factory=list) believing a bare = [] is dangerous. It isn’t: pydantic deep-copies field defaults per instance, so a bare tags: list[str] = [] is actually safe on a model:

class A(BaseModel):
    tags: list[str] = []          # SAFE in pydantic (copied per instance)
a1, a2 = A(), A()
a1.tags.append("x")
print(a1.tags, a2.tags)           # -> ['x'] []    a2 is NOT polluted

def f(tags=[]):                   # the classic bug, for contrast
    tags.append(1); return tags
print(f(), f(), f())              # -> [1, 1, 1] [1, 1, 1] [1, 1, 1]  (same shared list)

Use Field(default_factory=list) anyway — not because the bare form is unsafe in pydantic, but because it’s unambiguously correct, it’s mandatory the moment you use a real @dataclass or a Query(default=...), and it documents intent. Know the nuance so you don’t cargo-cult the rule into the wrong place.


Cheat-sheet

FastAPI What it does
app = FastAPI(title=..., version=...) The application object
@app.get/post/put/patch/delete("/x") A path operation (route)
def h(item_id: int) Path param (name in the {...})
def h(limit: int = 10) Query param (simple type, default)
def h(payload: MyModel) Request body (pydantic model)
Annotated[int, Query(ge=1, le=100)] Query param with validation
status_code=status.HTTP_201_CREATED Success status for the operation
response_model=Public Filter the output — strip secrets
raise HTTPException(404, detail=...) Return an error response
dep: Annotated[T, Depends(fn)] Inject shared logic (auth, DB, 404)
bg: BackgroundTasks; bg.add_task(fn, x) Run work after the response
app.add_middleware(CORSMiddleware, ...) Allow browser cross-origin calls
@app.exception_handler(MyError) Map a domain exception to a response
/docs, /redoc, /openapi.json ✅ Free interactive docs + schema
pydantic What it does
class M(BaseModel): x: int A validated, typed model
Field(gt=0, max_length=50) Field constraints
Field(default_factory=list) ✅ Safe mutable default
x: str | None = None Optional field
m.model_dump() Model → dict
m.model_dump(exclude_unset=True) Only fields the caller set (partial update)
M.model_validate(data) Validate a dict → model (raises ValidationError)
ConfigDict(extra="forbid") Reject unknown JSON keys
Run it Command
Dev server uvicorn app:app --reload
Production gunicorn app:app -k uvicorn.workers.UvicornWorker -w 4
Test (in-process) TestClient(app) + pytest
Flask dev / prod flask run / gunicorn app:app
Status code Return it when
200 GET/PUT/PATCH succeeded
201 POST created a resource
204 DELETE succeeded (no body)
400 / 422 Bad request / validation failed
401 / 403 Not authenticated / forbidden
404 / 409 Not found / conflict

Interview and exam questions

Q: What’s the difference between WSGI and ASGI, and why is FastAPI ASGI? A: WSGI (2003) is the synchronous Python web interface: a worker handles one request at a time, start to finish, and you scale by adding worker processes (gunicorn, uWSGI). It powers Flask and classic Django. ASGI (2018) is the async interface: a worker runs an event loop and juggles many concurrent requests on one thread, setting a request aside whenever it awaits slow I/O. FastAPI is built on Starlette, which is ASGI, and that’s precisely why FastAPI can define async def handlers and serve high-concurrency I/O-bound workloads (the common case for APIs — mostly waiting on databases and other services) efficiently on fewer resources. WSGI can’t do that without a process per concurrent request. The trade-off: on ASGI a blocking call freezes the shared loop, so async requires discipline.

Q: Why does FastAPI return a 422 for bad input, and what’s in the response? A: Because you declared the request shape as a pydantic model (or typed parameters), and the input violated it — wrong type, missing required field, or a failed constraint like gt=0. FastAPI validates before your handler runs and returns 422 Unprocessable Entity with a detail list where each entry has type (machine-readable, e.g. missing, greater_than), loc (the exact field path, e.g. ["body", "price"]), msg, and the offending input. It reports all errors in one pass, not just the first. You wrote none of that checking — it comes from the type declaration, which is the core productivity win.

Q: What is response_model and why is it a security feature? A: response_model=SomeModel on a path operation tells FastAPI to serialize the return value through SomeModel, emitting only that model’s fields. It’s a security boundary because the most common way secrets leak from a Python API is returning a database row or dict that contains a password_hash, an internal token, or another user’s data — without response_model, all of it ships. With it, anything not in the public model is stripped. It also decouples your public API shape from internal storage, so you can refactor the model behind it without breaking clients. In review, a handler returning a raw ORM object or full dict with no response_model should be treated as a potential leak.

Q: You have an async def endpoint that makes a database call, and the server is slow under load. What’s likely wrong? A: The database call is almost certainly synchronous (a blocking driver like psycopg2 in sync mode, or a requests call) sitting inside async def. Because the ASGI worker is one event-loop thread, a blocking call seizes it and every other concurrent request stalls behind it — the server serializes instead of overlapping. Measured in this lesson: five concurrent one-second requests took 5 seconds blocking vs 1 second when done right. The fix is either make the handler a plain def (FastAPI runs it in a threadpool, so it doesn’t block the loop) or use a genuinely async driver / httpx.AsyncClient you can await. The rule: only await non-blocking work inside async def; if it blocks, use def.

Q: How does FastAPI decide whether a parameter is a path, query, or body param? A: By the parameter’s type and whether its name appears in the path. If the name matches a {placeholder} in the route path, it’s a path param. If it’s a simple type (int, str, bool, etc.) and not in the path, it’s a query param. If it’s typed with a pydantic BaseModel, it’s the body. So def read(item_id: int, verbose: bool = False, data: ItemModel) reads item_id from the path, verbose from ?verbose=, and data from the JSON body. All three are validated against their declared types, producing a 422 on mismatch.

Q: What is Depends and give two real uses. A: Depends is FastAPI’s dependency injection: you write shared logic as a callable, declare Depends(that_callable) on a parameter, and FastAPI runs it per request and injects the result — resolving the dependency’s own parameters and any sub-dependencies, caching each once per request. Real uses: (1) fetch-or-404 — a get_item_or_404(item_id) dependency reused by read/update/delete so the 404 logic exists once; (2) authget_current_user(token) that decodes the token and returns the user or raises 401; (3) database sessionsget_db() yielding a session cleaned up after the request. It’s also the test seam: app.dependency_overrides[get_db] = fake swaps dependencies in tests. Common bug: writing Depends(get_db()) (called) instead of Depends(get_db) (passed) — a startup TypeError.

Q: A front-end JavaScript app can’t call your API but curl works. Why? A: CORS. Browsers enforce the same-origin policy: JS on https://app.example.com calling https://api.example.com is blocked by the browser unless the API returns Access-Control-Allow-Origin naming that origin. curl and your tests aren’t browsers, so they don’t enforce it — which is why it “works everywhere except the browser,” with only a console “CORS policy” error and no useful status. Fix: app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"], ...). The browser sends a preflight OPTIONS request first; the middleware answers it. ⚠️ Don’t use allow_origins=["*"] with credentials/cookies — it’s disallowed and a security smell.

Q: What status codes should a RESTful CRUD API return, and why is 201 vs 200 a thing? A: GET200; POST that creates → 201 Created (ideally with a Location header to the new resource); PUT/PATCH200; DELETE204 No Content (empty body). 201 vs 200 matters because it communicates creation specifically — clients, caches, and API consumers can rely on it to mean “a new resource now exists here,” and it’s part of REST’s contract. Returning 200 from a create still “works” but throws away that signal and breaks strict clients. Errors follow the classes: 400/422 for bad input, 401/403 for auth, 404 for missing, 409 for conflicts, 500 for your bugs.

Q: How do you test a FastAPI app without running a server or hitting the network? A: TestClient(app) from fastapi.testclient runs the ASGI app in-process — it constructs requests and calls the app directly, no socket, no uvicorn, no network. You write ordinary pytest: r = client.post("/items", json=...); assert r.status_code == 201. It’s fast (5 tests in ~0.16s here) and deterministic. For dependency-heavy code you use app.dependency_overrides to inject fakes (e.g. a test database). If you have shared mutable state like an in-memory store, reset it in a fixture between tests. TestClient uses httpx internally, so httpx must be installed.

Q (coding): Add a partial-update (PATCH-style) endpoint that only changes the fields the caller sends. A: Use an all-optional model plus model_dump(exclude_unset=True) so untouched fields stay as they are:

class ItemUpdate(BaseModel):
    name: str | None = Field(default=None, min_length=1)
    price: float | None = Field(default=None, gt=0)

@app.patch("/items/{item_id}", response_model=ItemPublic)
def patch_item(item: ItemDep, payload: ItemUpdate):
    item.update(payload.model_dump(exclude_unset=True))   # only what the caller set
    return item

The key is exclude_unset=True: it emits only the fields present in the request, so sending {"price": 5} updates the price and leaves name alone. Without it, model_dump() includes the None defaults and would wipe fields the caller didn’t mention. Constraints (gt=0) still apply to whatever is sent, giving a 422 on a bad value.

Q (coding): Write a dependency that returns the current user from a Bearer token, or 401. A:

from fastapi import Depends, HTTPException, Header
from typing import Annotated

def get_current_user(authorization: Annotated[str | None, Header()] = None) -> dict:
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing or malformed token")
    token = authorization.removeprefix("Bearer ")
    user = lookup_user_by_token(token)          # Part 2: decode a JWT / hit the DB
    if user is None:
        raise HTTPException(status_code=401, detail="Invalid token")
    return user

CurrentUser = Annotated[dict, Depends(get_current_user)]

@app.get("/me")
def me(user: CurrentUser):                      # any route gets auth by adding this param
    return {"id": user["id"]}

Points tested: reading the Authorization header via Header(), raising 401 (not 403 — the caller isn’t known yet) for missing/invalid credentials, and returning the user so downstream handlers just declare user: CurrentUser. This is exactly the injection seam Part 2 fills with real JWT decoding and a DB lookup.

Q: When would you pick Flask or Django REST over FastAPI? A: Flask when you want a tiny, unopinionated core with total control and minimal magic, for a small service — and, realistically, when you’re maintaining the large body of existing Flask code. Django REST Framework when you’re building a whole web application, not just an API: you want the integrated ORM, migrations, admin UI, auth, and templates in one box, with the API as one facet. FastAPI is the default for a new standalone API, microservice, or ML model server, where its built-in validation, automatic docs, and first-class async are exactly what you need and the type-driven design catches errors before runtime. None is wrong; it’s about fit — but “new JSON API” with no other constraints points to FastAPI.


Key takeaways

pythonfastapiflaskrest-apipydanticasgiwsgiuvicornhttpdependency-injectionasyncopenapivalidationadvanced
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments