How a Python Web App Works

Lesson 1 of 5

From Request to Response

"The Web is a distributed hypermedia application. Its most common use is for retrieving and displaying documents, but the architecture must be general enough to support other uses." — Roy T. Fielding, Architectural Styles and the Design of Network-based Software Architectures (2000)

Volume 14 left you fluent in Python. This volume is about the two frameworks that carry most production Python on the web — and it starts, deliberately, with neither of them.

The reason is that Django and FastAPI look nothing alike and do almost the same thing. One gives you a views.py and a URL conf; the other gives you a decorator and a type annotation. Underneath both, the shape is identical: bytes arrive over a socket, something parses them into an object, something chooses a function to call, that function returns an object, and something turns it back into bytes. Every framework decision either sits in that pipeline or configures it.

So this chapter builds the pipeline. By the end you will have parsed a real HTTP request from raw text, produced a valid response, and handled the two details that account for a surprising share of production bugs — that header names are case-insensitive and that some of them may legitimately appear more than once. None of this needs a framework, and knowing it is what makes both frameworks legible rather than magical.

The big question

Django and FastAPI share no code and almost no vocabulary, yet a request travels through the same five stages in both. What are those stages, what exactly is the "request object" that every framework hands your function — and which parts of HTTP are you responsible for that the framework will not do for you?

A request is text, and the first line carries the intent

Strip away the tooling and an HTTP request is a byte string with a strict shape.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Four parts, in a fixed order: a start line giving the method, the target and the protocol version; headers, one per line as Name: value; a blank line; and an optional body. The blank line is the only thing separating metadata from payload, which is why \r\n\r\n appears in every HTTP parser ever written.

Content-Length is the detail worth pausing on. HTTP runs over a stream, so the receiver has no way to know where the body ends except by being told — either a byte count up front, or chunked encoding. A mismatch between the declared and actual length is not a warning; it desynchronises the connection, and it is the mechanism behind an entire class of request-smuggling attacks that Volume 13 covers.

Notice what is not here: no notion of a "route", no JSON, no user. Those are all things a framework builds on top.

Everyday analogy

A request is a delivery docket, not a conversation. The first line is the instruction — collect this, drop off that — the headers are the metadata clipped to it (who sent it, what format the contents are in, what the recipient will accept), and the body is the parcel. The depot does not open the parcel to work out where it goes; it reads the docket. That separation is why a router can dispatch a 40MB upload without touching a byte of it, and why Content-Length matters so much: the loading bay needs to know where one parcel ends and the next begins, because they all arrive down the same conveyor.

The target is not a path

The thing after the method looks like a path and is not one.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Three facts that frameworks paper over and interviews probe. The target is a path plus a query string, and splitting on ? yourself is the bug — urlsplit handles fragments, empty queries and edge cases you will not think of. Query values are percent-encoded, so %20 is a space and %2F is a slash that is not a path separator. And a key may appear more than once, which is why parse_qs returns lists.

That last one explains a real difference you will meet in Part IV: Django's request.GET is a QueryDict that returns the last value from [] and all of them from getlist(), while FastAPI decides based on whether you annotated the parameter as str or list[str]. Both are reasonable; neither is "how HTTP works".

Headers are case-insensitive and repeatable

Two properties, both mandated by the spec, both easy to get wrong with a plain dict.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

The plain-dict comparison is the point: dict keeps Accept-Language under one exact-cased key and silently loses the first of the two values. Every framework ships a class like the one above because both behaviours are required.

Two details in that output are worth carrying. Parameters live inside a header value — Content-Type: application/json; charset=utf-8 is one header with a media type and a parameter, so naive == comparison against "application/json" fails on a perfectly valid request. And X-Forwarded-For is a list with the client first, appended to by each proxy — which means trusting it blindly lets a caller forge their own IP, a Volume 13 concern that shows up here as a one-line parsing decision.

A response is the same shape, reversed

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

The response mirrors the request exactly: a status line, headers, a blank line, a body. Content-Length is computed from the encoded bytes, not the string — a distinction that only bites once you send a name with an accent in it, and then bites hard.

The status table is worth memorising as classes rather than codes. The pairs interviewers actually probe: 201 vs 200 (created, with a Location header, versus merely fine), 401 vs 403 (we do not know who you are, versus we do and you still may not), 400 vs 422 (malformed, versus well-formed but semantically wrong — the one FastAPI returns for a failed validation), and 404 vs 403 for a resource that exists but is not yours, where returning 404 deliberately hides its existence.

A little history

The shape you just parsed is older than almost everything built on it. HTTP/0.9 (1991) was a single line — GET /page — with no headers, no status codes and no versions; the response was the document and then the connection closed. HTTP/1.0 (RFC 1945, 1996) added the status line, headers and content types, and HTTP/1.1 (1997, revised 1999 and again in 2014) added persistent connections, Host — which is what made virtual hosting and therefore shared web hosting possible — and chunked encoding. Roy Fielding co-authored those specifications and then, in his 2000 dissertation, described the architectural style he had been designing toward and named it REST. That order matters: REST is a description of why HTTP is shaped the way it is, not a set of rules invented afterwards for JSON APIs. HTTP/2 (2015) and HTTP/3 (2022) changed the wire format completely — binary framing, multiplexing, and QUIC over UDP — while keeping this exact semantic model of methods, targets, headers and status codes, which is why your framework code did not change when your load balancer started speaking HTTP/2.

The five stages, assembled

Here is the whole pipeline, small enough to read at once. Every framework in this volume is an elaboration of it.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Parse, route, call, serialise, respond. That is the entire lifecycle, and it is worth naming the stages because every framework feature attaches to one of them:

Stage Django FastAPI
Parse WSGIRequest built from the environ Starlette Request built from the ASGI scope
Route urls.py patterns, matched in order Decorators registering a path table
Call The view function or class The endpoint, with dependencies resolved first
Serialise Template rendering or DRF serializers Pydantic model → JSON
Respond HttpResponse Response / JSONResponse

The two frameworks differ in how much they do for you at each stage, not in the stages. Django parses the body only when you ask; FastAPI parses and validates it before your function runs. Neither is doing something the other cannot.

Methods mean things, and two of the meanings are load-bearing

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Safe means the request does not change server state, so a crawler, a prefetcher or a browser's address bar may issue it freely — which is why "log out" behind a GET link gets users logged out by their antivirus scanner. Idempotent means repeating the request leaves the same end state, which is what lets a client retry after a timeout without asking whether the first attempt landed.

That second property is not academic. Networks drop responses, not just requests, so a client that times out genuinely does not know whether the order was created. Idempotent methods make the retry safe by definition; for POST you need an idempotency key — a client-supplied identifier the server records so a repeat is recognised rather than re-executed. Payment APIs all work this way, and it is a common senior interview question in Part V.

The engineer's notebook

Watch a real request instead of imagining one: curl -v https://example.com/ prints the exact start line, request headers, status line and response headers you just built by hand, with > marking what was sent and < what came back. Then open your browser's DevTools Network tab, click any request, and read the "Raw" view of the headers — the same four-part shape, on your own traffic. Two habits worth forming from that: when an API misbehaves, look at the status class before the body, because 4xx and 5xx point at completely different people's bugs; and when a header is not arriving, check the case and check for duplicates, since a proxy that folded two values into one is a failure mode you will otherwise never guess.

In the real world

The parsing you just wrote is real code that real servers get wrong. Request smuggling exploits disagreement between a front-end proxy and a back-end server about where a body ends — one honours Content-Length, the other Transfer-Encoding: chunked — letting an attacker prepend bytes to the next user's request. Host header injection works because frameworks build absolute URLs (password-reset links, most memorably) from a header the client controls, which is why Django ships ALLOWED_HOSTS and refuses to start in production without it. And X-Forwarded-For spoofing defeats rate limiting and IP allow-lists whenever an application trusts the header without knowing how many proxies sit in front of it — the fix in both frameworks is to count from the right-hand side and trust only the hops you operate. Every one of those is a bug in stage one of the pipeline, before any of your code runs.

Common Mistakes

  1. Splitting the target on ? by hand. urlsplit handles fragments, empty queries and encoding; a manual split fails on the first URL that is not the shape you pictured.
  2. Forgetting that query values are percent-encoded. %20 is a space and %2F is a slash that is not a path separator. Decode with parse_qs/unquote, never by hand.
  3. Assuming a query key appears once. ?q=a&q=b is legal, which is why parse_qs returns lists and why Django has getlist.
  4. Using a plain dict for headers. Names are case-insensitive and some may repeat, so a dict silently drops values and misses lookups.
  5. Comparing Content-Type with ==. application/json; charset=utf-8 is a valid JSON content type. Compare the media type before the semicolon.
  6. Computing Content-Length from the string. It is a count of bytes; a multi-byte character makes the declared length wrong and desynchronises the connection.
  7. Trusting Host or X-Forwarded-For. Both are client-controlled. Host drives absolute URLs (hence ALLOWED_HOSTS), and X-Forwarded-For is appended to by every proxy.
  8. Putting a state change behind GET. Safe methods get issued by crawlers, prefetchers and scanners. Anything that changes data needs a non-safe method.
  9. Returning 200 for everything. The status class tells a client whether to retry, re-authenticate, fix its payload or page someone. Collapsing it into the body throws that away.
  10. Confusing 401 with 403, or 400 with 422. Unauthenticated versus forbidden; malformed versus well-formed but invalid. Clients branch on these.

Interview Corner

Backend interviews open here surprisingly often, because a candidate who can describe the lifecycle precisely will be able to place every later question — and one who cannot will treat the framework as magic.

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
Walk me through what happens between a browser sending a request and your view function running.
Model answer — The browser opens a connection and sends text in four parts: a start line with the method, target and HTTP version; headers, one per line; a blank line; and an optional body. The server reads that and parses it into a request object — pulling the path and query string apart, lower-casing header names because they are case-insensitive, and reading exactly Content-Length bytes of body. Then it routes: it matches the method and path against a table of registered handlers, and calls the one that matches, passing the request object in. My function returns something, the framework serialises it — to JSON, or by rendering a template — and builds the response, which has the same shape in reverse: a status line, headers, a blank line, a body. So the five stages are parse, route, call, serialise, respond. The framework does all of those; my code is normally only stage three. The two things I would flag as mine to get right are choosing a truthful status code and not putting a state change behind a GET.
Mid
Why can't you just use a dictionary for HTTP headers?
Model answer — Two reasons, both from the spec. Header names are case-insensitive, so Host, host and HOST are the same header, and a dict would store them as three different keys and miss lookups depending on how the client happened to capitalise. And some headers may legitimately appear more than once — Accept-Language, Set-Cookie, X-Forwarded-For — so a dict keeps only the last one and silently loses the rest. That is why every framework ships a multi-dict with case-insensitive lookup: Django's HttpHeaders, Starlette's Headers. The related trap is that a header value can carry parameters — Content-Type: application/json; charset=utf-8 — so comparing the whole value to "application/json" fails on a perfectly valid request; you compare the media type before the semicolon. And I would never trust Host or X-Forwarded-For without configuration, because both are client-controlled: Host is what Django's ALLOWED_HOSTS exists to constrain, and X-Forwarded-For is appended to by each proxy, so you count trusted hops from the right rather than taking the first value.
Senior
A client times out on a POST and retries. What are the consequences and how do you design for it?
Model answer — The critical point is that the timeout tells the client nothing about what the server did. The request may never have arrived, or it may have been fully processed and the response lost on the way back — so a naive retry can create the order twice, and for anything involving money that is the bug that ends up in a post-mortem. HTTP gives me part of the answer through method semantics: GET, PUT and DELETE are idempotent by definition, so repeating them leaves the same end state and a retry is always safe, whereas POST and PATCH are not. So where I can model the operation as "put this resource into this state at this URL", I use PUT and the problem disappears. Where I genuinely need create-with-server-assigned-id, I make it idempotent explicitly with an idempotency key: the client generates a UUID, sends it in a header, and the server records it with the result — a repeat with the same key returns the original response rather than re-executing. That needs the key stored transactionally with the effect, or I have just moved the race. I would also make sure the retry is safe on the client side — exponential backoff with jitter, and a bounded number of attempts, because synchronised retries against a struggling service are how a slowdown becomes an outage.
Company lens — how the same topic gets asked
micro1Concept: the five stages between a socket and your view function.
GoogleDepth: why headers are a multi-dict and Content-Length must be exact.
MetaDesign: where the framework should stop and your code should start.
AmazonReality: a truncated body that became a 200 instead of a 500.
Hands-on

Write the parser every framework starts with. parse_request must split the four parts and decode the query, Headers must be case-insensitive and keep repeated values, media_type must ignore parameters after the semicolon, and build_response must compute Content-Length from bytes rather than characters. The last check is the one that catches people — a name with an accent in it has more bytes than characters. The checklist reports each requirement as PASS, FAIL or ERR; run it before writing anything and watch the lines flip.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Summary

  • A request is four parts in a fixed order: start line, headers, blank line, optional body. The blank line is the only boundary between metadata and payload.
  • Content-Length tells the receiver where the body ends. It counts bytes, not characters, and a mismatch desynchronises the connection rather than merely warning.
  • The target is a path plus a query string. Split it with urlsplit, decode with parse_qs, and remember a key may appear more than once — which is why values are lists.
  • Headers are case-insensitive and repeatable. A plain dict misses lookups and silently drops values; every framework ships a case-insensitive multi-dict instead.
  • A header value can carry parameters. application/json; charset=utf-8 is valid JSON, so compare the media type before the semicolon.
  • A response mirrors a request: status line, headers, blank line, body. The status class is what tells a client whether to retry, re-authenticate or fix its payload.
  • The lifecycle is five stages — parse, route, call, serialise, respond. Django and FastAPI differ in how much they do at each stage, not in the stages themselves.
  • Safe means "changes nothing". Crawlers, prefetchers and scanners issue safe methods freely, which is why a state change behind GET gets triggered by software you never wrote.
  • Idempotent means "repeating it lands in the same state". That is what makes a retry after a timeout safe, because a timeout never tells the client what the server did.
  • POST needs an idempotency key to be retry-safe. A client-supplied identifier, stored transactionally with the effect, so a repeat returns the original result rather than re-executing.

In the next chapter, WSGI & ASGI, we look at the contract that lets any Python web application run on any Python web server — a callable, two arguments, and a return value — plus the async successor that exists because the original one had no way to express a connection that stays open.

Editorial reflection

It is worth noticing how little of this chapter was about Python. The four-part message, the method semantics, the status classes and the case-insensitivity rule are all properties of a protocol designed before most working engineers were writing code, and they are the reason a Django service and a FastAPI service can sit behind the same load balancer and be called by the same client without either knowing the other exists. That is what a protocol buys: not convenience, but the ability for independently designed things to interoperate without negotiating. The practical consequence for the rest of this volume is that when the two frameworks disagree, the disagreement is almost never about HTTP — it is about how much of it they choose to handle for you, and that is a matter of taste and trade-off rather than correctness.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.