WSGI

WSGI defines the callable protocol between synchronous Python web servers and applications, including environ, middleware, and byte responses.

level intermediate time 13 min at Standard depth
version Python 3.14
what

WSGI, the Web Server Gateway Interface , is the protocol between synchronous Python web servers and applications: the server calls the application, which returns a response made of byte chunks.

trap

WSGI is a synchronous calling protocol, not a single-threaded server model. Common contract bugs return str, read wsgi.input without a bound, or lose close() and exc_info in middleware.

fix

Treat environ, start_response(), and the response iterable as one lifecycle, then test the boundary with wsgiref.validate and the real server.

What it is and why it exists

WSGI is the Python Web Server Gateway Interface defined by PEP 3333. It specifies how a web server represents an HTTP request as Python data, calls an application, and receives a status, response headers, and body. Frameworks and servers can depend on that shared protocol, so a Flask or Django application doesn’t need a custom adapter for every WSGI server.

WSGI solves in-process interface compatibility; it isn’t a complete HTTP server specification. The server owns sockets, HTTP parsing, connection management, and client disconnects, while the application owns routing, domain logic, and response content. Reverse proxies, TLS, worker counts, and deployment topology also sit outside the WSGI contract.

You encounter WSGI in framework application objects, server startup targets, and middleware. A server typically imports an object named like module:application and calls it for each request. Middleware accepts the server’s call and then acts as a server to the next application, so components compose through the same interface.

WSGI describes a synchronous call: an ordinary callable must complete one request in its current execution context. It neither requires the server to use only one thread or process nor guarantees that global state is safe. A server can execute several independent WSGI calls concurrently with processes, threads, or another implementation strategy.

Long-lived connections, WebSockets, and native asynchronous send and receive operations aren’t WSGI’s target. Compare ASGI when you need those capabilities, but mechanically changing a mature synchronous application to async def doesn’t automatically increase capacity. Choose from protocol requirements, dependencies, and measured waiting time rather than a framework label.

How it works

One request revolves around three objects: the server supplies environ and start_response, and the application returns an iterable . environ describes the request and server capabilities, start_response() submits the status and headers, and the iterable supplies the body in chunks. Together they make the protocol; checking only the function signature isn’t enough.

The returns in the diagram don’t imply that the application has produced the whole body. It can return a list or a generator that runs later and yields chunks. The server obtains the body as it iterates and calls close() when the result provides that method.

The application callable

A minimal application has the shape application(environ, start_response). It computes the status and headers, calls start_response() before making the first body chunk available, and returns an iterable. Every item yielded by the iterable must be bytes, apart from an empty response that yields no items.

In start_response(status, response_headers, exc_info=None), status is a string such as "200 OK". The headers are a list of string (name, value) pairs. The third argument is only for an application trying to replace unsent headers while handling an exception; an ordinary success path shouldn’t pass it.

start_response() returns the legacy write() callable for compatibility with an early push style. New code should return a body iterable instead of relying on write(). Mixing both body paths makes ordering, streaming, and error handling harder to reason about.

Requests in environ

WSGI environ is an ordinary dictionary whose keys and values follow protocol rules. CGI-style keys describe the HTTP request, wsgi.* keys describe the protocol and server capabilities, and servers or middleware may add collision-resistant extension keys. An application shouldn’t assume that every HTTP request header has a corresponding key.

KeyMeaningApplication-side concern
REQUEST_METHODHTTP methodDon’t change case and then guess at semantics
SCRIPT_NAMEApplication mount pathPreserve it when building the application’s own URLs
PATH_INFOPath after the mount pointIt isn’t the original URL bytes
QUERY_STRINGQuery string without ?It may be empty and stays separate from the path
CONTENT_TYPERequest body media typeDon’t look for HTTP_CONTENT_TYPE
CONTENT_LENGTHDeclared body lengthIt may be absent or empty; validate before parsing
wsgi.inputBinary input streamRead a controlled length, not an unbounded read()
wsgi.errorsText error streamUse it for diagnostics, not as the HTTP body
wsgi.multithreadWhether same-process threaded calls may occurA true value forbids assuming threads never interleave
wsgi.multiprocessWhether multiprocess calls may occurA true value means process memory isn’t globally consistent

Except for CONTENT_TYPE and CONTENT_LENGTH, request headers normally become uppercase, replace hyphens with underscores, and gain an HTTP_ prefix. For example, X-Request-ID maps to HTTP_X_REQUEST_ID. This is the server’s normalized view, not proof of trust: a public client can forge a header unless a trusted proxy explicitly strips and replaces it.

The application reads a request body from wsgi.input; it doesn’t receive an already parsed object in environ. It should validate CONTENT_LENGTH and its own size limit before reading the permitted bytes. Media type handling, character decoding, and structural parsing such as JSON belong to the application or framework.

Status, headers, and body

The status and response headers must reach start_response() before the first body chunk can be sent by the server. An application can’t generate hop-by-hop response headers such as Connection or Transfer-Encoding, because the server manages the current HTTP connection. It may set Content-Length when the byte count is known exactly, never by guessing from a character count.

The body’s byte boundary matters. The application encodes text as bytes with a selected encoding, then calculates Content-Length from those bytes. Returning a Python str violates the protocol even when it contains only ASCII, and a validator or server should reject it.

The server must handle chunks in iterable order and complete each chunk’s transmission before requesting the next one. WSGI doesn’t promise that each yield becomes one network packet or that a proxy immediately passes it to the client. An application can produce data incrementally, but end-to-end buffering still needs verification in the real deployment path.

The middleware chain

Middleware wraps another WSGI application while presenting the same protocol to its caller. It can change environ, wrap start_response() to observe status or headers, or wrap the returned iterable. Authentication, tracing, exception mapping, and security headers often live at this layer.

Transparent middleware must preserve protocol details it doesn’t change. If it consumes a downstream iterable, it must preserve body order and ensure the downstream close() is called; if it wraps start_response(), it must accept and forward exc_info. Collecting the entire response in memory just to log a status code silently breaks streaming.

Middleware order changes behavior. Exception middleware outside authentication can translate failures from the authentication layer; placed inside, it never sees them. Test ordering as part of the architecture rather than checking every middleware unit only in isolation.

Concurrency is declared by the server

wsgi.multithread, wsgi.multiprocess, and wsgi.run_once are booleans that describe the execution environment, not configuration commands from the application to the server. An application can use them to judge whether an optimization is safe, but correctness is usually more robust when it doesn’t depend on one worker model. Database pools, caches, and locks need scopes that match the actual process and thread boundaries.

A synchronous protocol means one call can’t yield control to an ASGI-style event loop while it waits. The server can still let other threads or processes serve other requests. It is therefore wrong to derive “WSGI handles only one request at a time” from “WSGI is synchronous.”

Examples

The three examples use only the Python standard library. Their output comes from running each file with the local python3; the protocol they use was checked against the Python 3.14 documentation and PEP 3333.

A minimal application with protocol validation

The first application reads a method, path, and query string and returns an already encoded body list. A small invoker builds a base environment with setup_testing_defaults() and lets wsgiref.validate.validator() check WSGI assertions on both sides.

minimal_wsgi.py
from wsgiref.util import setup_testing_defaults
from wsgiref.validate import validator


def application(environ, start_response):
    query = environ.get("QUERY_STRING", "")
    suffix = f"?{query}" if query else ""
    body = f"{environ['REQUEST_METHOD']} {environ['PATH_INFO']}{suffix}".encode()
    headers = [
        ("Content-Type", "text/plain; charset=utf-8"),
        ("Content-Length", str(len(body))),
    ]
    start_response("200 OK", headers)
    return [body]


def invoke(path, query):
    environ = {}
    setup_testing_defaults(environ)
    environ.update(PATH_INFO=path, QUERY_STRING=query)
    captured = {}

    def start_response(status, headers, exc_info=None):
        captured.update(status=status, headers=headers)
        return lambda data: None

    result = validator(application)(environ, start_response)
    try:
        body = b"".join(result)
    finally:
        if hasattr(result, "close"):
            result.close()
    return captured["status"], captured["headers"], body


status, headers, body = invoke("/orders", "limit=2")
print(status)
print(f"{headers[0][0]}: {headers[0][1]}")
print(f"{headers[1][0]}: {headers[1][1]}")
print(body.decode())
200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 19
GET /orders?limit=2

The invoker also fulfills server-side duties: it records status and headers, consumes the body, and calls close() when available. validator() belongs in development and tests. It is middleware that checks protocol assertions, not a production security boundary or a proof that the domain logic is correct.

This example returns the whole body at once, so it can set an exact Content-Length. len(body) counts bytes. Calling len() on the Unicode string first would give a different result when it contains non-ASCII characters.

Reading a bounded JSON body

The second application separates the transport boundary from the parsing boundary. It converts CONTENT_LENGTH to an integer and applies a 64-byte limit, reads exactly that permitted length from wsgi.input, and only then parses JSON.

json_body.py
from io import BytesIO
import json

MAX_BODY = 64

def respond(start_response, status, payload):
    body = json.dumps(payload, separators=(",", ":")).encode()
    start_response(status, [("Content-Type", "application/json"),
                            ("Content-Length", str(len(body)))])
    return [body]


def application(environ, start_response):
    raw_length = environ.get("CONTENT_LENGTH", "")
    try:
        length = int(raw_length or "0")
    except ValueError:
        return respond(start_response, "400 Bad Request", {"error": "bad length"})
    if length < 0 or length > MAX_BODY:
        return respond(start_response, "413 Content Too Large", {"error": "too large"})
    try:
        document = json.loads(environ["wsgi.input"].read(length))
    except (UnicodeDecodeError, json.JSONDecodeError):
        return respond(start_response, "400 Bad Request", {"error": "bad json"})
    return respond(start_response, "200 OK", {"received": document})

def invoke(payload):
    environ = {"CONTENT_LENGTH": str(len(payload)), "wsgi.input": BytesIO(payload)}
    captured = []

    def start_response(status, headers, exc_info=None):
        captured.append(status)
        return lambda data: None

    body = b"".join(application(environ, start_response))
    return captured[0], body.decode()


print(*invoke(b'{"order_id":7}'), sep="\n")
print(*invoke(b"x" * 65), sep="\n")
200 OK
{"received":{"order_id":7}}
413 Content Too Large
{"error":"too large"}

The 64-byte number is an explicit test boundary for this runnable example, not a production recommendation. A real service should derive its limit from the endpoint, media type, and infrastructure constraints, then test server behavior when the declared length and actual transfer disagree.

This small application doesn’t enforce a media type or distinguish an empty body from a JSON parsing error. Frameworks normally provide fuller request objects and error mapping, but the same byte count, input stream, and resource limit remain underneath.

Middleware that preserves streaming

The third example wraps start_response() to log the method, path, and final status, then adds a security response header. The middleware returns the downstream generator directly instead of collecting the body into a list.

streaming_middleware.py
class SecurityHeaderMiddleware:
    def __init__(self, app):
        self.app = app

    def __call__(self, environ, start_response):
        method = environ["REQUEST_METHOD"]
        path = environ["PATH_INFO"]

        def add_header(status, headers, exc_info=None):
            print(f"{method} {path} -> {status.split()[0]}")
            updated = [*headers, ("X-Content-Type-Options", "nosniff")]
            return start_response(status, updated, exc_info)

        return self.app(environ, add_header)


def application(environ, start_response):
    start_response("200 OK", [("Content-Type", "text/plain")])
    yield b"part-1"
    yield b"|part-2"


captured = {}


def start_response(status, headers, exc_info=None):
    captured.update(status=status, headers=headers)
    return lambda data: None


wrapped = SecurityHeaderMiddleware(application)
result = wrapped({"REQUEST_METHOD": "GET", "PATH_INFO": "/report"}, start_response)
try:
    body = b"".join(result)
finally:
    result.close()

print(captured["headers"][-1])
print(body.decode())
GET /report -> 200
('X-Content-Type-Options', 'nosniff')
part-1|part-2

Because application() is a generator function, its body doesn’t run until the server begins iteration, so the log also appears then. The middleware doesn’t consume the result itself, leaving iteration and closure owned by the outer server.

If middleware needs to change the body, it must return its own wrapping iterable and close the downstream result from that wrapper’s close(). A generator’s finally can clean up resources that generator has acquired; it doesn’t replace explicit forwarding of the downstream lifecycle.

Pitfalls

Mistaking synchronous for single-threaded

Fix: inspect the real server configuration and wsgi.multithread and wsgi.multiprocess, then design state for those boundaries. Put cross-request data in external storage with an explicit consistency contract, and document the per-process scope of in-memory caches and connection pools.

Returning text instead of bytes

Fix: choose a character encoding and call .encode() explicitly. Declare that encoding in Content-Type, calculate Content-Length from the encoded bytes, and use wsgiref.validate in tests to catch type violations.

Reading the input stream without a bound

Fix: validate missing, empty, negative, and nonnumeric lengths before applying an endpoint-specific byte limit. For a streaming upload, read bounded chunks, set compatible limits in the server or proxy, and handle an early client disconnect.

Breaking the lifecycle in middleware

Fix: return the downstream iterable directly when the body is unchanged. When changing it, implement a wrapper that forwards order, exceptions, and close(). Preserve and pass through the third start_response argument, then test a generator response and an exception during iteration.

Trusting normalized request headers

Fix: trust only headers that a known proxy strips and rewrites, and constrain the trusted proxy path. Derive identity from a verified authentication mechanism. For auditing, log both the direct peer and the client address produced by the explicit proxy policy.

Replacing an error response after sending begins

Fix: finish failure-prone validation and authorization before committing response headers. After an exception, use exc_info so the server can enforce its already-sent decision, and accept that a committed response can only be terminated and cleaned up rather than replaced with a complete new response.

Deep WSGI contract boundaries

WSGI contract boundaries

The iterable timeline

Calling the application and consuming its result are separate phases. An ordinary function can call start_response() before returning a list, while a generator function normally doesn’t execute its body until its first iteration. The server must therefore allow status and headers to arrive after the application returns, but it must receive them before handling the first body chunk.

The server can’t assume that an iterable has a length or consume the whole result early to calculate Content-Length. The specification allows it to infer a length only when the result has exactly one item and the server can determine that reliably; if the application set the header, the server must respect the validated value. For a general generator, the server and HTTP version decide whether to use chunked transfer or close the connection.

Each nonempty body chunk should be handled promptly, and the server can’t arbitrarily wait for later chunks before processing it. That requirement covers buffering between the WSGI application and server only. A reverse proxy, compression layer, TLS stack, or client library can buffer again, so latency-sensitive streaming requires an end-to-end measurement.

If the returned object provides close(), the server must call it whether the request completes normally or terminates early. Middleware that substitutes its own object for the downstream result takes responsibility for forwarding that closure. Closing releases generator finally blocks, file handles, and other iteration-time resources without relying on garbage-collection timing.

start_response() and exception replacement

An application normally calls start_response() once. If it catches an exception and wants to replace headers that haven’t been sent, it can call again with the current sys.exc_info(). A server may accept the new status and headers if nothing has been sent; if headers are already out, it must re-raise the original exception.

This mechanism can’t retract bytes that have reached the client. Error middleware must treat “not committed” and “already committed” as separate states. The first can become a complete error response; the second can only clean up, record the failure, and terminate the connection or stream. Pretending both states can always become a JSON error produces responses whose status and body contradict each other.

The server reads the header list passed to start_response() as application data, but middleware should still avoid mutating a list held by downstream code. Building a new list limits aliasing surprises and makes duplicate-header handling explicit. Headers that allow repetition, such as Set-Cookie, must not be flattened into a dictionary.

Hop-by-hop headers describe one transport connection, while a WSGI application sits above connection management. Applications and middleware must not generate Connection, Keep-Alive, Transfer-Encoding, TE, Trailer, or Upgrade. The server needs exclusive control over those fields to adapt correctly to HTTP versions, proxies, and connection reuse.

Strings and URL bytes

PEP 3333 uses Python str for CGI-style metadata, but those strings don’t represent arbitrary Unicode text. The protocol carries original bytes through strings with an ISO-8859-1-compatible mapping, after which a framework interprets paths using URL rules. Treating PATH_INFO as already decoded user text can cause double decoding or routing differences.

An application that constructs its own URLs must consider both SCRIPT_NAME and PATH_INFO. The former is the application mount prefix already consumed by the server, and the latter is the remaining in-application path. Code that ignores SCRIPT_NAME works in root-path tests but generates bad redirects and links when mounted under /service.

The query string remains in QUERY_STRING without a leading question mark. Don’t split PATH_INFO on ? again or decode the same percent escape twice before validation. Mature frameworks centralize these compatibility details. A direct WSGI application should use tested URL utilities and retain raw boundary data for diagnosis.

Status and response header names and values are strings, while the body is strictly bytes. The correct order for a text response is to choose an encoding, encode to bytes, count those bytes, and declare a matching media type and charset. The same order applies when middleware rewrites a body; after changing bytes, it must remove or recalculate the old Content-Length.

Input streams and resource limits

wsgi.input is a binary stream supplied by the server. When reading by CONTENT_LENGTH, the application shouldn’t attempt to obtain bytes beyond the declared length; a server may simulate end of file with a limited stream or may block until more data arrives. The interface itself doesn’t choose an application body limit.

A missing length must not automatically mean “read without limit.” A particular server may offer an extension such as wsgi.input_terminated to signal that reading to end of stream is safe, but that isn’t a core PEP 3333 guarantee. A portable application should define lengthless-request behavior through its framework or server documentation rather than assuming an extension exists.

The read length is only the first limit. Compressed input may grow after decompression, while form fields and nested JSON consume extra CPU and memory. Bound transport bytes, decompressed bytes, parse depth, and domain object counts separately so a seemingly small body can’t expand into an expensive object graph.

A client disconnect can surface as an exception while reading input, writing body chunks, or closing the result. The application needs explicit transaction and external-effect boundaries; failure to send a response doesn’t mean that a write didn’t happen. Use an idempotency protocol for retryable writes, and distinguish application failure, client disconnect, and server cancellation in logs.

Server, framework, and application responsibilities

The server converts an HTTP connection into a WSGI call, supplies required wsgi.* keys, and drives response iteration. A framework converts the low-level mapping into request objects, route parameters, and response objects. The application owns authorization, domain invariants, and side effects, while middleware owns only the cross-cutting policies explicitly delegated to it.

Clear boundaries imply layered tests. Pure application tests can use a small invoker for fast status and body coverage, protocol tests add wsgiref.validate, and server integration tests cover proxy headers, upload limits, disconnects, streaming buffers, and the concurrency model. Calling a view function alone bypasses WSGI and middleware and can’t prove the deployed boundary correct.

wsgiref.simple_server is a standard-library reference implementation suitable for examples and local tests. It isn’t a production-server recommendation. Validate a production choice against maintenance status, platform support, worker model, and measured workload, and follow the framework’s deployment documentation.

The current WSGI version key is (1, 0), while PEP 3333 is the Python 3 clarification labeled 1.0.1. Don’t infer another application protocol version from a Python package release or server brand. A component that needs nonstandard behavior should use an owner-prefixed extension key and define a fallback or explicit rejection when the extension is absent.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

before this Backend development
next up Flask Django WSGI and ASGI Nginx soon Testing soon
Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?