How WSGI works: build a micro web framework in ~40 lines
WSGI is the contract that lets any Python framework run on any server — and it's small enough to implement over tea. You'll write a bare WSGI app, grow it into a framework with routing and middleware, and watch three simulators run the handshake, the route match, and the middleware onion.
mindmap — quick refresh
Prerequisites: Server-side rendering in pure Python, part 1 and How HTTP servers work — you'll need the request/response loop and Python basics. No regex experience needed — the lab introduces the four symbols it uses. Time: ~40 minutes.
In the SSR labs you built views by subclassing BaseHTTPRequestHandler — which welds your app to one specific server. Real Python web apps don't do that, because of one number: before 2003, N frameworks times M servers meant every pair needed custom glue. The fix was WSGI — the Web Server Gateway Interface (say "whiz-gee"): a Python standard (PEP 333, 2003, updated as PEP 3333) whose name says exactly what it is — an agreed interface between a web server and the gateway into your application. The whole contract fits in a sentence: an application is a callable that takes a dict and a callback, and returns an iterable of bytes. Gunicorn doesn't know Django exists. Django doesn't know Gunicorn exists. They both know WSGI — and by the end of this lab, so will you, because you'll have implemented both sides' meeting point and grown it into a framework.
The contract: one callable, two arguments
Here is a complete, running WSGI application — standard library only. Save it as hello_wsgi.py:
# hello_wsgi.py — the whole WSGI contract in 9 lines
from wsgiref.simple_server import make_server
def application(environ, start_response):
body = f"hello from {environ['PATH_INFO']}".encode()
start_response("200 OK", [("Content-Type", "text/plain; charset=utf-8"),
("Content-Length", str(len(body)))])
return [body]
make_server("", 8000, application).serve_forever()
Run it (python3 hello_wsgi.py), then curl -i localhost:8000/anything — you get hello from /anything. Three things just happened, and they're the entire spec:
- The server parsed HTTP for you and flattened the request into
environ— a plain dict. The request line becomesREQUEST_METHOD,PATH_INFO,QUERY_STRING; each header becomes anHTTP_*key (Host:→HTTP_HOST); the body waits inenviron["wsgi.input"]. - Your app announced the response head by calling
start_response(status, headers)— a callback the server handed you. - Your app returned the body as an iterable of
bytes. The server serialized everything back into raw HTTP on the socket.
Watch the handshake happen — raw request in, environ in the middle, raw response out:
That dict-and-callable handshake is the whole interface. Everything a framework does — routing, middleware, sessions, ORM calls — happens inside that one function call. Which means a framework is not a special kind of program. It's a callable with better organization. Let's prove it.
One scoping note before we build: WSGI itself is Python-specific. It's literally a Python Enhancement Proposal, and both sides of the contract are Python objects — a dict and a callable. But the idea is universal: it descends from CGI (1993, language-neutral), and other ecosystems standardized the same shape for themselves — Ruby has Rack, Perl has PSGI, Java has the Servlet API. Learn this one well and you've learned the architecture of all of them.
A framework is a callable with a route table
The naive way to serve multiple pages is an if ladder on PATH_INFO. It works and then it rots — every new page edits the same growing function. The framework move is to make the data structure hold the pages and keep the dispatch logic generic.
One new tool appears here: regular expressions (Python's re module) — a mini-language for describing text patterns, so one pattern can match a whole family of paths. Regexes go deep, but this lab uses exactly four symbols:
| piece | means |
|---|---|
^ | the match must start here (nothing before it) |
$ | the match must end here (nothing after it) |
\d+ | one or more digits, 0–9 |
(?P<sid>...) | capture whatever matched inside, under the name sid |
Read together, ^/students/(?P<sid>\d+)/$ says: the entire path is /students/, then some digits, then / — and hand me those digits as sid. So it matches /students/42/ (capturing sid = "42") but rejects /students/abc/ and /students/42/extra. That's all the regex you need here — the full story (backtracking, character classes, and why the same notation works in every tool) is in How regular expressions work. In Django you'll mostly write the friendlier path("students/<int:sid>/", ...), which compiles down to patterns like this anyway. Save as framework.py:
# framework.py — a micro web framework, standard library only
import re
from wsgiref.simple_server import make_server
class App:
def __init__(self):
self.routes = [] # (compiled regex, view) pairs
def route(self, pattern):
def register(view):
self.routes.append((re.compile(f"^{pattern}$"), view))
return view
return register
def __call__(self, environ, start_response):
path = environ["PATH_INFO"]
for regex, view in self.routes: # top-down, first match wins
m = regex.match(path)
if m:
status, body = view(environ, **m.groupdict())
break
else:
status, body = "404 Not Found", f"no route matches {path}"
data = body.encode()
start_response(status, [("Content-Type", "text/html; charset=utf-8"),
("Content-Length", str(len(data)))])
return [data]
Note the shape: App defines __call__, so an instance of it is a WSGI application — the server can't tell the difference between this and the 9-line function. (This is the dunder machinery doing real work.) Views are now small, separate functions that register themselves:
app = App()
@app.route(r"/")
def home(environ):
return "200 OK", "<h1>it works</h1>"
@app.route(r"/students/")
def student_list(environ):
return "200 OK", "<h1>all students</h1>"
@app.route(r"/students/(?P<sid>\d+)/")
def student_detail(environ, sid):
return "200 OK", f"<h1>student #{sid}</h1>"
make_server("", 8000, app).serve_forever()
curl localhost:8000/students/42/ → student #42. The regex's named group became the view's keyword argument. Step through the dispatch, including a miss:
You have just written Django's URLconf. path("students/<int:sid>/", views.student_detail) in a Django urls.py compiles to a pattern in a list; dispatch scans top-down; first match wins; captures arrive as view kwargs. Same machine, nicer syntax.
Middleware: apps wrapping apps
Cross-cutting jobs — logging, auth, compression — shouldn't be pasted into every view. WSGI's answer is elegant: since an app is just a callable taking (environ, start_response), you can write a callable that holds another app inside it and passes the call through. That's middleware — same interface on the outside, another app on the inside:
class Logging:
def __init__(self, inner):
self.inner = inner # composition: has-a, not is-a
def __call__(self, environ, start_response):
print("→", environ["REQUEST_METHOD"], environ["PATH_INFO"])
return self.inner(environ, start_response)
class GateKeeper:
def __init__(self, inner):
self.inner = inner
def __call__(self, environ, start_response):
if environ["PATH_INFO"].startswith("/admin/"):
data = b"403 - admins only" # short-circuit: inner never runs
start_response("403 Forbidden", [("Content-Type", "text/plain"),
("Content-Length", str(len(data)))])
return [data]
return self.inner(environ, start_response)
application = Logging(GateKeeper(app)) # the onion, innermost last
make_server("", 8000, application).serve_forever()
Now every request prints a log line, and /admin/… dies at the gate — the router and views never even run. The nesting Logging(GateKeeper(app)) builds an onion: requests descend through the layers, responses climb back out, and any layer can stop the descent. Watch both cases:
Two details worth noticing. Order matters: swap the layers and admin rejections stop being logged. And a middleware can act on the way in (auth), on the way out (compression, timing headers), or both — it holds the call in its hands.
You just built Django's skeleton
Line up what you wrote against what Django ships, and the mapping is one-to-one:
| Your ~40 lines | Django |
|---|---|
make_server(...) (wsgiref) | Gunicorn / uWSGI in production |
application = Logging(GateKeeper(app)) | the MIDDLEWARE list in settings.py |
app.routes + top-down scan | urls.py / URLconf |
student_detail(environ, sid) | a view function taking (request, sid) |
return "200 OK", body | returning an HttpResponse |
| the template strings from the SSR labs | Django templates |
Django's own entry point makes the point for us — a project's wsgi.py is four meaningful lines ending in application = get_wsgi_application(): the same callable you wrote, wearing the whole framework inside it. When Gunicorn runs 4 workers, that's 4 processes each holding this onion and answering application(environ, start_response) in a loop — exactly the worker model from How HTTP servers work.
One forward pointer: WSGI is synchronous — one request occupies one worker until the response returns. Its async sibling ASGI keeps the same spirit (an app is a callable the server invokes per event) but speaks async/await, which is what Django uses for websockets and async views. Learn WSGI first; ASGI is a variation on a theme you now own.
Takeaways
- WSGI is a contract, not a library — an app is any callable taking
(environ, start_response)and returning an iterable of bytes; that's why any server can run any framework. environis just HTTP, flattened into a dict — request line →PATH_INFO/REQUEST_METHOD/QUERY_STRING, headers →HTTP_*keys, body →wsgi.input.- A framework is a callable with a route table — top-down scan, first match wins, regex captures become view kwargs. Django's
urls.pyis this with nicer syntax. - Middleware is an app wrapping an app — the nesting builds an onion; order matters, and a layer can short-circuit so the view never runs. Django's
MIDDLEWARElist is the same onion written vertically. - Frameworks aren't magic, they're organization — everything Django does happens inside one function call you can now write from scratch.
References
- Django Software Foundation. (n.d.). How to deploy with WSGI (Django documentation). Retrieved August 17, 2026, from https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/
- Django Software Foundation. (n.d.). Middleware (Django documentation). Retrieved August 17, 2026, from https://docs.djangoproject.com/en/stable/topics/http/middleware/
- Eby, P. J. (2010). PEP 3333 – Python Web Server Gateway Interface v1.0.1. Python Software Foundation. https://peps.python.org/pep-3333/
- Python Software Foundation. (n.d.). re — Regular expression operations (Python documentation). Retrieved August 17, 2026, from https://docs.python.org/3/library/re.html
- Python Software Foundation. (n.d.). wsgiref — WSGI Utilities and Reference Implementation (Python documentation). Retrieved August 17, 2026, from https://docs.python.org/3/library/wsgiref.html