WSGI Explained: How Python Web Apps Talk to Servers (vs ASGI)

Blog / Python · May 4, 2024 · Updated June 10, 2026 · 10 min read
WSGI Explained: How Python Web Apps Talk to Servers (vs ASGI)

WSGI (Web Server Gateway Interface) is the standard, synchronous interface between Python web servers and Python web applications or frameworks. Defined in PEP 3333, it lets any WSGI-compliant server (Gunicorn, uWSGI, mod_wsgi) talk to any WSGI-compliant framework (Django, Flask, Pyramid) without either side knowing the other's internals. You write your app once and run it on any compatible server.

WSGI has been the backbone of Python web deployment since 2003. But in 2026 it tells only half the story: modern async frameworks like FastAPI and async-capable Django speak ASGI instead, the newer interface built for async I/O, WebSockets, and long-lived connections. This guide explains WSGI from first principles with runnable code, then contrasts it with ASGI so you know which to reach for.

What problem does WSGI solve?

Before WSGI, every Python web framework shipped its own way of plugging into a web server. A Zope app could not easily run under a server written for a different framework, and server authors had to support each framework individually. The result was an N x M integration mess: every framework had to be glued to every server.

WSGI fixes this by defining one contract in the middle:

  • Servers (the "WSGI server" / gateway) handle raw HTTP, networking, and process management, then call your app in a standard way.
  • Applications / frameworks receive a normalised request and return a response, without caring what server is running them.

Because both sides agree on the same calling convention, you can swap Gunicorn for uWSGI, or move a Flask app behind Apache's mod_wsgi, with little more than configuration changes. This server-to-framework decoupling is the entire point of WSGI.

The WSGI spec in one callable

At its core, a WSGI application is just a callable that accepts two arguments and returns an iterable of bytes:

application(environ, start_response) -> iterable of bytes

The two arguments are:

  • environ — a plain dict holding the request. It merges CGI-style variables (REQUEST_METHOD, PATH_INFO, QUERY_STRING, CONTENT_TYPE, HTTP headers as HTTP_*) with WSGI-specific keys (wsgi.input for the request body stream, wsgi.url_scheme, etc.).
  • start_response — a callable you invoke once to send the HTTP status line and response headers, e.g. start_response('200 OK', [('Content-Type', 'text/plain')]).

Your callable then returns an iterable of byte strings (the response body). HTTP moves bytes, so you return bytes, never str. That single, small contract is all WSGI is.

A minimal WSGI app from scratch

You don't need any framework to write a WSGI app — the standard library's wsgiref ships a reference server perfect for learning. Save this as helloworld_wsgi.py (works on Python 3.12+):

# helloworld_wsgi.py
from wsgiref.simple_server import make_server


def application(environ, start_response):
    """A minimal WSGI app: greets using the request path."""
    path = environ.get("PATH_INFO", "/")
    method = environ.get("REQUEST_METHOD", "GET")

    body = f"Hello, WSGI! You sent {method} {path}".encode("utf-8")

    status = "200 OK"
    headers = [
        ("Content-Type", "text/plain; charset=utf-8"),
        ("Content-Length", str(len(body))),
    ]
    start_response(status, headers)
    return [body]  # an iterable of byte strings


if __name__ == "__main__":
    with make_server("", 8000, application) as server:
        print("Serving on http://localhost:8000 ...")
        server.serve_forever()

Run it and open the URL in a browser:

python helloworld_wsgi.py
# then visit http://localhost:8000/anything

That is a complete, spec-compliant WSGI application. wsgiref.simple_server is single-threaded and meant for development and testing only — never production. In production you swap make_server for a real WSGI server (next section), but your application callable stays exactly the same. That portability is WSGI's whole promise.

How Django and Flask expose application

You almost never hand-write the environ/start_response plumbing — frameworks do it for you and expose a ready-made WSGI callable, conventionally named application (or app).

Django generates a wsgi.py in every project. A WSGI server points at the application object inside it:

# myproject/wsgi.py (generated by django-admin)
import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
application = get_wsgi_application()

Flask apps are themselves WSGI callables, so the Flask instance you create is the application:

# app.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def index():
    return "Hello from Flask over WSGI"

In both cases the server just needs the import path to that object — for example myproject.wsgi:application or app:app. The framework handles routing, request parsing, and building the byte response; WSGI is only the handshake at the edge. For deeper Django and Python work, see our Django development services and Python development services.

WSGI servers: Gunicorn, uWSGI, mod_wsgi

A WSGI server is the process that actually listens on a socket, parses HTTP, and calls your application. The common production options:

  • Gunicorn — "Green Unicorn", the most popular choice. Pure-Python, simple to configure, pre-fork worker model. The default for most Django and Flask deployments.
  • uWSGI — extremely feature-rich and fast (C-based), with knobs for almost everything. Powerful but heavier to configure; often paired with Nginx.
  • mod_wsgi — an Apache module that embeds Python directly in the Apache web server. A good fit when you're already standardised on Apache.

Running a Django app under Gunicorn is a one-liner — point it at the module path of your application object:

# install
pip install gunicorn

# run: module_path:callable_name
gunicorn myproject.wsgi:application --workers 4 --bind 0.0.0.0:8000

# Flask example (app object named "app" in app.py)
gunicorn app:app --workers 4 --bind 0.0.0.0:8000

A common rule of thumb for CPU-bound sync workloads is (2 x CPU cores) + 1 workers, then tune from real traffic. We walk through a full production setup in Django hosting on Nginx with uWSGI for high performance.

WSGI vs ASGI: the sync/async divide

WSGI's one limitation is baked into its design: it is synchronous. Each worker handles one request at a time, blocking until that request finishes. That is fine for typical request/response web apps, but it cannot natively handle WebSockets, server-sent events, long-polling, or thousands of concurrent slow connections without burning a worker on each one.

ASGI (Asynchronous Server Gateway Interface) is the spiritual successor, created to support async/await and long-lived connections. Instead of a single blocking call, an ASGI app is an async callable taking three arguments — scope (connection metadata), receive (await incoming events), and send (push events out) — which lets one worker juggle many concurrent connections via the event loop.

ASGI is broader in scope than WSGI: it handles HTTP and WebSockets, and frameworks like Django can run their existing sync views under ASGI too. It's the standard for FastAPI, Starlette, and async Django.

When each one matters

  • Reach for WSGI when your app is classic request/response (server-rendered pages, CRUD APIs, admin dashboards) and your stack is sync Django, Flask, or Pyramid. It's mature, simple, and battle-tested.
  • Reach for ASGI when you need real-time features (chat, live dashboards, notifications), high-concurrency I/O-bound work (lots of waiting on databases or external APIs), or you're building on FastAPI / async Django. async def views only pay off under ASGI.

You can also run a sync Django app under ASGI today and adopt async views incrementally — you are not forced to choose forever on day one.

WSGI vs ASGI at a glance

Aspect WSGI ASGI
Spec PEP 3333 ASGI specification (asgi.readthedocs.io)
Model Synchronous, one request per worker Asynchronous, many concurrent connections per worker
Concurrency Processes / threads (pre-fork workers) asyncio event loop (+ worker processes)
Callable application(environ, start_response) async application(scope, receive, send)
Frameworks Django (sync), Flask, Pyramid, Bottle FastAPI, Starlette, async Django, Sanic
Servers Gunicorn, uWSGI, mod_wsgi Uvicorn, Hypercorn, Daphne
WebSockets / SSE Not supported natively First-class support
Best for Classic request/response apps, CRUD, admin Real-time, streaming, high-concurrency I/O-bound APIs

ASGI servers and a minimal ASGI app

Just as WSGI has Gunicorn and uWSGI, ASGI has its own servers:

  • Uvicorn — lightning-fast, built on uvloop and httptools; the default for FastAPI. Often run as Gunicorn workers in production via uvicorn.workers.UvicornWorker.
  • Hypercorn — supports HTTP/2 and HTTP/3 in addition to HTTP/1.1 and WebSockets.
  • Daphne — the reference ASGI server from the Django Channels project, great for WebSocket-heavy Django apps.

Here's the ASGI equivalent of our hello-world, written against the raw spec so you can see the three-argument async contract (Python 3.12+):

# helloworld_asgi.py
async def application(scope, receive, send):
    """Minimal raw ASGI app. Handles a single HTTP request/response."""
    assert scope["type"] == "http"

    body = b"Hello, ASGI! (async)"
    await send({
        "type": "http.response.start",
        "status": 200,
        "headers": [
            (b"content-type", b"text/plain; charset=utf-8"),
            (b"content-length", str(len(body)).encode()),
        ],
    })
    await send({
        "type": "http.response.body",
        "body": body,
    })


# Run with an ASGI server:
#   pip install uvicorn
#   uvicorn helloworld_asgi:application --port 8000

Compare this to the WSGI version: same idea, but the async scope/receive/send model is what unlocks WebSockets and concurrency. For production FastAPI you typically run Uvicorn workers under Gunicorn:

# FastAPI / async app in production (Uvicorn workers managed by Gunicorn)
pip install "uvicorn[standard]" gunicorn

gunicorn main:app \
    --worker-class uvicorn.workers.UvicornWorker \
    --workers 4 \
    --bind 0.0.0.0:8000

Middleware: layering logic around your app

Both WSGI and ASGI support middleware — wrappers that sit between the server and your application to add cross-cutting behaviour (logging, auth, compression, CORS, error handling) without touching app code.

A WSGI middleware is simply a callable that takes the app, intercepts the environ/start_response cycle, and returns a new WSGI-compatible callable:

class TimingMiddleware:
    def __init__(self, app):
        self.app = app

    def __call__(self, environ, start_response):
        import time
        start = time.perf_counter()
        response = self.app(environ, start_response)
        # ... measure / log elapsed time after the response iterates ...
        return response

Frameworks layer their own middleware stacks on top of this idea — Django's MIDDLEWARE setting and Flask's before_request hooks both build on the same wrap-the-app pattern. For Django's request/response pipeline specifically, see understanding middleware functionality in Django.

Production deployment notes

A typical Python web deployment puts a WSGI (or ASGI) server behind a reverse proxy:

  • App server — Gunicorn/uWSGI (WSGI) or Uvicorn/Hypercorn (ASGI) runs your application across multiple worker processes.
  • Reverse proxy — Nginx (or a load balancer) sits in front to terminate TLS, serve static files, buffer slow clients, and load-balance across workers. App servers like Gunicorn are deliberately not hardened to face the raw internet alone.
  • Process supervision — systemd, Supervisor, or a container orchestrator keeps workers alive and restarts them on failure.

Practical tips: size sync workers from your CPU count ((2 x cores) + 1 is a starting point) and load-test before locking it in; use --worker-class uvicorn.workers.UvicornWorker to serve async apps under Gunicorn; set sensible timeouts; and run several workers so a single slow request can't stall the whole site.

Over 12+ years and 50+ delivered projects, MicroPyramid has shipped and operated Python apps across exactly these stacks — Gunicorn/uWSGI with Nginx for Django, and Uvicorn-based deployments for FastAPI and async services.

Frequently Asked Questions

What is WSGI in simple terms?

WSGI (Web Server Gateway Interface) is a standard, defined in PEP 3333, that describes how a Python web server and a Python web application talk to each other. It's a simple contract: the server calls your application as application(environ, start_response), your app reads the request from the environ dict, calls start_response with a status and headers, and returns the body as bytes. Because both sides follow the same rule, any WSGI server can run any WSGI app.

WSGI vs ASGI — what's the difference?

WSGI is synchronous: each worker handles one request at a time and blocks until it finishes, which is fine for classic request/response apps but cannot natively do WebSockets or high-concurrency long-lived connections. ASGI is the asynchronous successor built for async/await; its app is async application(scope, receive, send), letting one worker juggle many concurrent connections and support WebSockets and streaming. Use WSGI for sync Django and Flask, ASGI for FastAPI, async Django, and real-time features.

Do I need WSGI for FastAPI?

No. FastAPI is an ASGI framework, so you run it with an ASGI server such as Uvicorn (often as Uvicorn workers managed by Gunicorn), not a plain WSGI server like Gunicorn's default worker or mod_wsgi. FastAPI's async def endpoints rely on the ASGI event loop, which WSGI doesn't provide. Reach for WSGI only with sync frameworks like classic Django or Flask.

What WSGI server should I use in production?

Gunicorn is the most common and easiest choice for Django and Flask, with a simple pre-fork worker model. uWSGI is more feature-rich and very fast but heavier to configure, and mod_wsgi suits teams already on Apache. Whichever you pick, run it behind Nginx (or a load balancer) for TLS termination, static files, and buffering — app servers shouldn't face the internet directly. For async apps, use Uvicorn (often under Gunicorn) instead.

Can Django run on ASGI?

Yes. Modern Django supports both WSGI and ASGI — django-admin generates both wsgi.py and asgi.py. Run Django under an ASGI server (Uvicorn, Daphne, Hypercorn) when you want async def views, WebSockets via Channels, or better concurrency for I/O-bound work. Your existing sync views keep working under ASGI, so you can adopt async incrementally rather than rewriting everything at once.

Is WSGI still used in 2026?

Yes, widely. WSGI remains the standard for the huge ecosystem of synchronous Python apps — most Django and Flask deployments still run on Gunicorn or uWSGI over WSGI, and it's mature, stable, and well understood. ASGI is growing fast and is the right choice for new async, real-time, and high-concurrency projects, but it complements rather than replaces WSGI. In practice, both interfaces are first-class parts of the Python web stack today.

Share this article