A guided tour of everything that happens between someone clicking a link and your view function running. No traps, no gotchas, just how it actually works.
Most articles about Django are about what goes wrong. This one isn't. This is a tour.
Every time someone loads a page in your Django app, a small, elegant machine springs into motion. It unwraps the request, threads it through a stack of layers, finds the right view, runs your code, and packages the answer back up, usually in well under a tenth of a second.
Most of us write views for years without ever watching that machine run. We know our view function gets called with a request object, and we know we return a response, and the space between is a comfortable fog.
Let's clear the fog. We're going to follow a single request from the instant it leaves a browser to the instant the response arrives back, and at each stop I'll show you what Django is actually doing and why.
By the end, request and response won't be magic. They'll be things you understand from the inside. That understanding is quietly one of the most useful things you can have as a Django developer, because once you can see the machine, you know exactly where to reach when something needs explaining, debugging, or extending.
Here's the whole journey at a glance, and then we'll walk it step by step:
Browser → Nginx → Gunicorn (WSGI) → Django handler
→ Middleware (in) → URL resolver → Your view (ORM + templates)
→ Middleware (out) → Gunicorn → Nginx → Browser
Stop 1: The Browser and the Web Server
It starts with a browser making an HTTP request (GET /articles/42/) and sending it across the internet to your server. The first thing it hits isn't Django at all. It's a web server, typically Nginx, sitting at the front door.
Nginx handles the things Django shouldn't be bothered with:
- Serving static files (CSS, images) directly
- Terminating SSL
- Load-balancing across multiple app processes
For anything dynamic (anything that needs your code), Nginx hands the request off to an application server like Gunicorn or uWSGI.
Think of Nginx as the receptionist who deals with routine visitors themselves and only forwards the real business inward.
So already there are two programs involved before Django wakes up. This is worth knowing, because when people say "Django is slow to serve static files" or debug a deployment, the answer usually lives right here in the handoff between the web server and the app server.
Stop 2: WSGI, the Universal Handshake
Gunicorn now has the request, but Gunicorn and Django are separate projects written by different people. How do they talk? Through a contract called WSGI (Web Server Gateway Interface), and understanding this contract demystifies a huge amount.
WSGI is dead simple. The server calls your application as a plain function with two arguments:
-
environ: a dictionary holding everything about the request (the path, headers, method, all of it) -
start_response: a callback for setting the status and headers
Your application does its work and returns the response body as bytes.
That's the entire contract. And it's why you can run Django under Gunicorn or uWSGI or others interchangeably. They all speak WSGI, so any of them can talk to any WSGI framework.
Django's end of this handshake lives in a file you've seen and probably never opened: wsgi.py. That file exposes a WSGIHandler, the object Gunicorn calls. This is the true front door of Django itself.
(There's a newer async version of this contract called ASGI, in asgi.py, which is what makes websockets and async views possible. Same idea, built for concurrency. But the classic synchronous path is WSGI, and that's the one we're walking.)
Stop 3: Django Wakes Up and Builds the Request Object
Now Django's WSGIHandler takes over, and the first meaningful thing it does is fire a signal: request_started. This is genuinely your earliest possible hook into the lifecycle. Code can subscribe to that signal to run the moment any request begins, before anything else happens.
Then Django takes that raw environ dictionary and wraps it into the object you know and love: the HttpRequest. This is the request that gets passed to every view you've ever written: request.method, request.path, request.GET, request.user. It's not magic; it's a friendly Python object built from the raw WSGI environment.
Here's a lovely detail most people never learn: parts of the request are lazy. request.body, request.POST, and request.FILES don't actually read the incoming data from the network socket until you access them.
Django creates the request object instantly without reading the (potentially huge) body, and only pulls it in when your code asks. It's an elegant efficiency: why read a 10MB upload into memory if this particular view never looks at it?
(One consequence: once you've read request.body, you generally can't read it again. The stream's been consumed.)
Stop 4: The Middleware Onion (Going In)
Now the request begins its journey inward through the middleware stack, and the onion is the perfect mental image. The request travels from the outside layer toward the center, and later the response travels back out through the same layers in reverse.
Middleware is just a list in your settings.py, and Django processes it top to bottom on the way in. Each layer is a chance to inspect, modify, or even stop the request before it reaches your view. Walk the default stack and you'll recognize old friends, each doing one job:
| Middleware | What it does on the way in |
|---|---|
SecurityMiddleware |
Enforces HTTPS and security-related rules |
SessionMiddleware |
Loads the user's session from the session store and attaches it |
CommonMiddleware |
Handles URL normalization and a few housekeeping rules |
CsrfViewMiddleware |
Checks the CSRF token on form submissions |
AuthenticationMiddleware |
Gives you request.user, by reading the session and looking up who's logged in |
That last one explains something you've used a thousand times: request.user isn't conjured from nowhere. A specific middleware put it there, on the way in, before your view ran. (And it's lazy too: request.user is only actually fetched from the database the first time you touch it.)
The power of the onion is that any layer can short-circuit the whole thing. If CsrfViewMiddleware sees a bad token, it returns a 403 right there. Your view never runs, and the response turns around and heads back out.
Middleware is where cross-cutting concerns (security, sessions, auth) live so your views don't have to repeat them.
Stop 5: The URL Resolver Finds Your View
The request has made it through middleware. Now Django needs to answer: which view handles /articles/42/? That's the job of the URL resolver (the URL dispatcher), driven by your urls.py.
Django takes the request path and tests it against your URL patterns, in order, top to bottom, until one matches. When path("articles/<int:article_id>/", views.article_detail) matches, two things come out of it:
-
The view function to call (
article_detail) -
The captured arguments from the URL (
article_id=42, already converted to anintby the<int:>converter)
A great under-the-hood detail: the resolver is cached. Django builds the URL-pattern structure once and keeps it in memory rather than rebuilding it on every request. That's an important efficiency, since every single request needs it.
This is also the quiet reason that changing your urls.py requires a server restart in production: the resolved URL configuration is cached at startup, so new patterns aren't picked up until the process reloads. If you've ever wondered why a URL change "didn't take" until you restarted, now you know exactly why.
Stop 6: Your View Runs (the Part You Already Know)
Here we are at the center of the onion: your code. Django calls your view with the request object and the captured URL arguments:
def article_detail(request, article_id):
article = Article.objects.get(id=article_id) # ORM → SQL → database
return render(request, "article.html", {"article": article})
This is the part you write every day, so I'll just point out the two systems it usually reaches for:
-
The ORM.
Article.objects.get(...)doesn't talk to the database directly. It builds a description of a query that the ORM translates into SQL, sends to the database, and turns the result rows back into PythonArticleobjects. (Lazy, as ever: the query runs when the data is actually needed.) -
The template engine.
render(...)takes your template and the context dictionary, and the template engine produces the final HTML string, which gets wrapped in anHttpResponseobject.
One detail worth carrying with you, especially for class-based views: Django creates a new view instance for every request. That's deliberate. It keeps requests isolated from each other.
(It's also why a mutable class attribute on a view is shared across all requests while instance attributes aren't: a direct consequence of this one-instance-per-request design.)
Your view's job ends the moment it returns a response object. Now that response begins its journey back out.
Stop 7: Back Out Through the Middleware (in Reverse)
The response now travels back out through the same middleware stack, but in reverse order, bottom to top. This symmetry is the elegant heart of the onion: each middleware that got a look at the request on the way in gets a look at the response on the way out.
And they use it. On the way out:
-
SessionMiddlewaresaves any changes you made to the session back to the session store. -
SecurityMiddlewareand others add security headers to the outgoing response (things likeX-Content-Type-Options). - Other middleware can compress the body, set cookies, or add their own headers.
So the response that leaves is richer than the one your view returned. Middleware decorated it on the way out with sessions, headers, and cookies.
The same layers, in mirror order: request going in, response coming out. That mirror is the whole design.
Stop 8: The Response Goes Home
The finished HttpResponse (status code, headers, and body) is handed back to the WSGI server (Gunicorn), which converts it into raw HTTP bytes and passes it to Nginx, which sends it across the internet to the browser, which renders the page. The machine goes quiet, waiting for the next request.
The whole round trip happened in a fraction of a second:
- Browser to Nginx to Gunicorn to Django's handler
- In through middleware
- Resolve the URL
- Run the view, query the database, render the template
- Back out through middleware
- And home again
And now you can see every stop.
Why Knowing This Makes You Better at Django
Here's the payoff, and it's real. Almost every Django concept that feels like "magic" is actually just one specific stop on this tour, and once you can place it, it stops being mysterious:
| The question | Where it lives |
|---|---|
Where does request.user come from? |
AuthenticationMiddleware, on the way in (Stop 4) |
| Why do my security headers appear even though my view didn't add them? | Middleware, on the way out (Stop 7) |
| Why did my URL change not work until I restarted? | The cached URL resolver (Stop 5) |
| Where should I put code that runs on every request? | Middleware, or the request_started signal (Stops 3–4) |
| Why is a class attribute on my view shared between users? | One view instance per request (Stop 6) |
| How can I run Django under Gunicorn or uWSGI? | They both speak WSGI (Stop 2) |
None of these need memorizing once you hold the map. They all fall out of understanding the flow. That's the difference between using a framework and understanding it, and it's the difference between guessing where a problem lives and knowing.
Django's request/response cycle is, honestly, a beautiful piece of design. A raw network request enters, gets progressively wrapped and enriched into a friendly Python object, passes through orderly layers that each do one job, reaches your code at the calm center, and the answer flows back out through the same layers in mirror image.
It's an onion, and now you've seen every ring of it. The next time you write def my_view(request):, you'll know exactly what that request went through to reach you, and exactly where it's going next.
The 30-Second Takeaway
- A request hits a web server (Nginx) first. It serves static files and forwards dynamic requests to an app server (Gunicorn/uWSGI).
-
Gunicorn and Django talk through WSGI, a tiny contract (
environdict in, response bytes out), which is why any WSGI server can run any WSGI framework. Django's entry point iswsgi.py(async apps useasgi.py). -
Django fires the
request_startedsignal, then wraps the rawenvironinto theHttpRequestyou know, withbody/POST/FILESread lazily, only when accessed. -
The request travels inward through middleware (top→bottom):
SecurityMiddleware,SessionMiddleware,AuthenticationMiddleware(this is what setsrequest.user), etc. Any layer can short-circuit and return early. -
The URL resolver matches the path against
urls.py(top→bottom) and extracts view + arguments. It's cached, which is why URL changes need a restart. -
Your view runs (a new instance per request for CBVs), uses the ORM and template engine, and returns an
HttpResponse. - The response travels back out through middleware in reverse (bottom→top), saving the session and adding security headers, then back through WSGI → Nginx → browser.
- Nearly every "magic" Django behavior is just one stop on this tour. Knowing the map turns mysteries into locations.
Your Turn
Enjoyed the tour? The best way to make it stick is to open your own project's settings.py, read your MIDDLEWARE list top to bottom, and trace one real URL from urls.py to its view. You'll never see def my_view(request): the same way again.
Which stop on the tour surprised you most? Tell me in the comments 👇
If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.


Top comments (0)