// python · wsgi

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 WSGI — the dict-and-callable contract under every Python framework the problem it solves WSGI = Web Server Gateway Interface (PEP 3333) pre-2003: N frameworks × M servers = custom glue each pair Python-specific — Rack (Ruby) / PSGI (Perl) are cousins Gunicorn doesn't know Django exists — only the contract the contract app = callable(environ, start_response) environ = plain dict: method, path, query, HTTP_* headers app returns an iterable of bytes routing framework = callable with a route table top-down scan, first match wins regex: ^…$ whole match, \d+ digits, (?P<sid>…) named capture captures become view kwargs — Django's urls.py middleware an app that wraps another app — an onion request descends, response climbs back short-circuit: reject before the view ever runs Django's skeleton gunicorn → MIDDLEWARE stack → urls.py → view settings.WSGI_APPLICATION points at this same callable ASGI = the async sibling, same shape

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:

  1. The server parsed HTTP for you and flattened the request into environ — a plain dict. The request line becomes REQUEST_METHOD, PATH_INFO, QUERY_STRING; each header becomes an HTTP_* key (Host:HTTP_HOST); the body waits in environ["wsgi.input"].
  2. Your app announced the response head by calling start_response(status, headers) — a callback the server handed you.
  3. 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:

piecemeans
^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 linesDjango
make_server(...) (wsgiref)Gunicorn / uWSGI in production
application = Logging(GateKeeper(app))the MIDDLEWARE list in settings.py
app.routes + top-down scanurls.py / URLconf
student_detail(environ, sid)a view function taking (request, sid)
return "200 OK", bodyreturning an HttpResponse
the template strings from the SSR labsDjango 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.
  • environ is 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.py is 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 MIDDLEWARE list 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