Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 21 – Python everywhere: data, web, AI and next steps

Python for the web: WSGI, ASGI and frameworks

How WSGI and ASGI connect Python code to web servers, how Flask, Django and FastAPI build on them, and which one fits a site, an API or real-time features.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain WSGI and ASGI and how frameworks build on them
  • Test a WSGI or ASGI application by calling it directly, without a server
  • Predict when async helps a web app and when a blocking call stalls it
  • Compare Flask, Django and FastAPI, and pick one for a site, an API or live features
  • Name the security checks that stay your job in every framework

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain WSGI and ASGI and how frameworks build on them
  • Test a WSGI or ASGI application by calling it directly, without a server
  • Predict when async helps a web app and when a blocking call stalls it
  • Compare Flask, Django and FastAPI, and pick one for a site, an API or live features
  • Name the security checks that stay your job in every framework

Before you start

On this page

Three requests that each wait 0.2 seconds for a database take 0.6 seconds on one blocking worker and about 0.2 seconds on one event loop, as this lesson measures. The difference starts in the interface between the web server and your code: The Web Server Gateway Interface of PEP 3333: a server calls the application once per request as app(environ, start_response), and the application returns the body as bytes. calls a Python app once per request, while ASGI: The Asynchronous Server Gateway Interface: the standard by which an async Python web server calls an application, an async callable that takes the request's scope and two functions to receive and send messages. awaits an async app that exchanges events with the server, so one process can serve many waiting connections, and WebSockets too.

Frameworks build on those interfaces. Flask is a small WSGI framework you extend as you go, Django a large framework, with a database layer and an admin site, that runs under WSGI or ASGI, and FastAPI builds JSON APIs on ASGI from type hints. You will call one endpoint through both interfaces without a server, test an app with a fake client, measure what async changes, choose between the three, and finish with the security work that no framework does for you.

WSGI calls your app once per request

Every request travels the same path to your code: from the browser or client to an application server, through the interface, into a framework and on to your view:

A request goes from the client to an application server, through the WSGI or ASGI interface, to a framework and your view.Browser or API clientApplication serverWSGI: Gunicorn, uWSGIASGI: Uvicorn, Hypercorn, DaphneThe interfaceWSGI: app(environ, start_response)ASGI: await app(scope, receive, send)FrameworkFlask (WSGI), Django (WSGI or ASGI),FastAPI (ASGI, on Starlette)Your view functionHTTPcallsroutes the request

Where WSGI and ASGI sit between a request and your code

Text description of the diagram

The diagram is a flow from top to bottom.

  1. A browser or an API client sends an HTTP request.
  2. An application server receives it. WSGI servers include Gunicorn and uWSGI; ASGI servers include Uvicorn, Hypercorn and Daphne.
  3. The server calls the application through the interface: a WSGI application is called as app(environ, start_response), an ASGI application is awaited as app(scope, receive, send).
  4. The application is usually a framework: Flask speaks WSGI, Django speaks WSGI or ASGI, and FastAPI speaks ASGI through Starlette.
  5. The framework routes the request to your view function, which produces the response that travels back the same way.

PEP 3333 calls WSGI a standard interface between web servers and Python web applications or frameworks. The application is any callable with two arguments: environ, a dictionary with the request (method, path, query string, headers as HTTP_… keys and the body as a stream), and start_response, a function the application calls with a status line such as "200 OK" and a list of header pairs. It then returns an iterable that yields the body as bytes.

Because the interface is a function call, you can make the call yourself. This program writes one /hello endpoint for both interfaces and calls each with a hand-made request, exactly as a server would; the first three lines of its output are the WSGI call:

The same endpoint as WSGI and as ASGI, called directly Python · hello/hello_both.py
"""One /hello endpoint written twice, as a WSGI callable and as an ASGI callable, and each called without a server."""
import asyncio
from urllib.parse import parse_qs
from wsgiref.util import setup_testing_defaults


# WSGI (PEP 3333): a plain callable. The server passes environ and start_response; the app returns bytes.
def wsgi_app(environ, start_response):
    name = parse_qs(environ["QUERY_STRING"]).get("name", ["world"])[0]
    body = f"Hello, {name}!".encode()
    start_response("200 OK", [("Content-Type", "text/plain; charset=utf-8"), ("Content-Length", str(len(body)))])
    return [body]


# ASGI: an async callable. The server passes scope, receive and send; the app sends events.
async def asgi_app(scope, receive, send):
    if scope["type"] != "http":
        raise ValueError(f"this app speaks HTTP only, not {scope['type']}")
    name = parse_qs(scope["query_string"].decode()).get("name", ["world"])[0]
    body = f"Hello, {name}!".encode()
    headers = [(b"content-type", b"text/plain; charset=utf-8"), (b"content-length", str(len(body)).encode())]
    await send({"type": "http.response.start", "status": 200, "headers": headers})
    await send({"type": "http.response.body", "body": body})


def call_wsgi(app, path, query):
    environ = {"PATH_INFO": path, "QUERY_STRING": query}
    setup_testing_defaults(environ)  # fills in the rest of a valid environ
    seen = {}

    def start_response(status, headers, exc_info=None):
        seen["status"], seen["headers"] = status, headers

    body = b"".join(app(environ, start_response))
    return seen["status"], seen["headers"], body


async def call_asgi(app, path, query):
    scope = {"type": "http", "asgi": {"version": "3.0"}, "http_version": "1.1", "method": "GET", "scheme": "http",
             "path": path, "raw_path": path.encode(), "query_string": query.encode(), "root_path": "",
             "headers": [(b"host", b"testserver")], "client": ("127.0.0.1", 50000), "server": ("testserver", 80)}
    sent = []

    async def receive():
        return {"type": "http.request", "body": b"", "more_body": False}

    async def send(message):
        sent.append(message)

    await app(scope, receive, send)
    return sent


status, headers, body = call_wsgi(wsgi_app, "/hello", "name=Asha")
print("WSGI status: ", status)
print("WSGI headers:", headers)
print("WSGI body:   ", body)
for message in asyncio.run(call_asgi(asgi_app, "/hello", "name=Asha")):
    print("ASGI sent:   ", message)

Output

WSGI status:  200 OK
WSGI headers: [('Content-Type', 'text/plain; charset=utf-8'), ('Content-Length', '12')]
WSGI body:    b'Hello, Asha!'
ASGI sent:    {'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/plain; charset=utf-8'), (b'content-length', b'12')]}
ASGI sent:    {'type': 'http.response.body', 'body': b'Hello, Asha!'}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hello_both.py

Step through that WSGI call, from the request to the last byte:

Interactive · message by message One request to a WSGI app Step through the call the server makes for GET /hello?name=Asha. Arrows are calls and the values they hand back.
One request to a WSGI app, step 5 of 5. Messages so far: The server to wsgi_app: app(environ, start_response); wsgi_app to The server: start_response('200 OK', …); wsgi_app to The server: [b'Hello, Asha!'].The serverenviron: the request as a dictwsgi_appreturnedapp(environ, start_response)start_response('200 OK', …)[b'Hello, Asha!']

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A request arrives. A GET request for /hello?name=Asha reaches the server. It puts the request into environ, a dict: PATH_INFO is /hello, QUERY_STRING is name=Asha, and the headers become HTTP_ keys.
  2. One call per request. The server calls wsgi_app(environ, start_response), once for this request, and waits for it to return.
  3. Status and headers. The app reads the name from the query string and calls start_response() with the status line, 200 OK, and a list of header pairs: the content type and a Content-Length of 12.
  4. The body as bytes. The app returns an iterable of bytes, here the list [b'Hello, Asha!'], 12 bytes. The server joins what it yields and sends the response.
  5. A worker for the whole call. From the call to the last byte, the request held a worker of the server. Flask's documentation puts it plainly: a WSGI application uses one worker for one request and its response.

The WSGI version returned its status and headers through start_response and its body as a list of bytes; the standard library’s wsgiref.util.setup_testing_defaults() filled in the rest of a valid environ. Press Edit, replace both name=Asha with your own name and run it: the body and its Content-Length change together.

Checkpoint

Check: WSGI, one call per request

  1. One question A WSGI app calls start_response("404 Not Found", [("Content-Type", "text/plain")]). What must it return?

    Choose one answer.

    Show the answer: One question

    Answer: An iterable of bytes, such as [b"not found"]

    PEP 3333 splits the response in two: start_response(status, headers) for the status line and the header pairs, and the return value, an iterable that yields the body as bytes.

An ASGI app exchanges events with the server

The ASGI specification defines an application as an async callable, application(scope, receive, send). scope is a dictionary describing the connection; its type is "http", "websocket" or "lifespan". The application awaits receive() to get events from the client, such as a chunk of the request body, and awaits send() to send events back, first http.response.start with the status and headers, then one or more http.response.body events. Header names and values are bytes. The specification says its main goal is to let HTTP/2, HTTP/3 and WebSocket code live alongside ordinary HTTP handling, while keeping an easy path for existing WSGI servers and applications.

The last two lines of the program’s output above are the events of its ASGI version. Step through them:

Interactive · message by message The same request to an ASGI app Step through the events of GET /hello?name=Asha. The app is awaited once and talks to the server in events.
The same request to an ASGI app, step 6 of 6. Messages so far: The server to asgi_app: app(scope, receive, send); asgi_app to The server: http.response.start, 200; asgi_app to The server: http.response.body.The serverscope: type httpasgi_appdone after two eventsapp(scope, receive, send)http.response.start, 200http.response.body

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A scope for the request. For the same request, the server builds a scope, a dict whose type is http, with the method, the path /hello and the query string, in bytes: name=Asha.
  2. Await the app. It awaits asgi_app(scope, receive, send). receive and send are awaitable callables: the app awaits them to take events in and to send events out.
  3. Check the protocol. The app looks at scope["type"] first. It speaks HTTP only, and the specification asks an app to reject a protocol it does not understand by raising an exception.
  4. Start the response. await send() with an http.response.start event: status 200 and the headers, pairs of bytes with lower-case names, as the specification requires.
  5. Send the body. A second event, http.response.body, carries b'Hello, Asha!'. It has no more_body, so it is the last part and the response is complete.
  6. Room to wait. Every exchange is an await. While this app waits for an event, or for a database, the event loop can run other requests: the point of the interface.

The ASGI version sent two events, and its header pairs are bytes with lower-case names, as the specification requires for responses. It also refuses any scope that is not HTTP: the specification asks applications to reject protocols they do not understand with an exception, so that a server does not wait for a lifespan answer that never comes.

Checkpoint

Check: ASGI events

  1. One question An ASGI app that speaks HTTP only is called with {"type": "lifespan"}. What does the ASGI specification ask it to do?

    Choose one answer.

    Show the answer: One question

    Answer: Raise an exception, to reject a protocol it does not understand

    Every scope has a type. An app checks it first, handles the types it supports and raises an exception for the others, as both programs of this lesson do with ValueError.

A test can play the server

Because the app is a coroutine function: A function defined with async def. Calling it runs none of its body: it returns a coroutine object, which runs only when something drives it, such as await, a task or asyncio.run(). More in “Async Python: the event loop mental model”, a test only needs a receive() that hands out prepared events and a send() that records what comes back. The client below delivers a request body in three chunks, as a server may when a body arrives over the network in pieces:

A dozen-line test client for an ASGI app Python · testing/word_count_app.py
"""An ASGI app tested by calling it directly: a fake scope, a receive() that delivers the body in chunks, a send() that records."""
import asyncio
import json


async def read_body(receive):
    """Collect the request body, which may arrive in several http.request events."""
    chunks = []
    while True:
        event = await receive()
        if event["type"] == "http.disconnect":
            raise ConnectionError("the client went away")
        chunks.append(event.get("body", b""))
        if not event.get("more_body", False):
            return b"".join(chunks)


async def respond(send, status, data):
    body = json.dumps(data).encode()
    await send({"type": "http.response.start", "status": status,
                "headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())]})
    await send({"type": "http.response.body", "body": body})


async def app(scope, receive, send):
    if scope["type"] != "http":
        raise ValueError(f"unsupported protocol: {scope['type']}")
    if scope["path"] != "/count":
        return await respond(send, 404, {"error": "not found"})
    if scope["method"] != "POST":
        return await respond(send, 405, {"error": "use POST"})
    text = (await read_body(receive)).decode("utf-8")
    await respond(send, 200, {"words": len(text.split()), "characters": len(text)})


async def call(app, method, path, chunks=(b"",)):
    """A test client in a dozen lines: what an ASGI server would do, without a socket."""
    scope = {"type": "http", "method": method, "path": path, "query_string": b"", "headers": []}
    events = [{"type": "http.request", "body": c, "more_body": i < len(chunks) - 1} for i, c in enumerate(chunks)]
    sent = []

    async def receive():
        return events.pop(0)

    async def send(message):
        sent.append(message)

    await app(scope, receive, send)
    start, body = sent[0], b"".join(m.get("body", b"") for m in sent[1:])
    return start["status"], json.loads(body)


async def main():
    print(await call(app, "POST", "/count", [b"to be or ", b"not to ", b"be"]))  # three chunks
    print(await call(app, "GET", "/count"))
    print(await call(app, "POST", "/other"))
    try:
        await app({"type": "lifespan"}, None, None)
    except ValueError as error:
        print("lifespan:", error)


asyncio.run(main())

Output

(200, {'words': 6, 'characters': 18})
(405, {'error': 'use POST'})
(404, {'error': 'not found'})
lifespan: unsupported protocol: lifespan

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 word_count_app.py

Step through the first call, the POST whose body arrives in three chunks:

Interactive · message by message A test client plays the server Step through call(app, "POST", "/count", …). The test client hands the app three events and records what it sends back.
A test client plays the server, step 8 of 8. Messages so far: The test client to app: app(scope, receive, send); The test client to app: b'to be or ', more_body; The test client to app: b'not to ', more_body; The test client to app: b'be', last chunk; app to The test client: 200, then the JSON body.The test client(200, 6 words, 18 chars)appbody: 18 charactersapp(scope, receive, send)b'to be or ', more_bodyb'not to ', more_bodyb'be', last chunk200, then the JSON body

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. A request made by hand. call() builds what a server would: a scope for POST /count, and three http.request events holding b'to be or ', b'not to ' and b'be'.
  2. Start the app. It awaits app(scope, receive, send) with its own receive(), which hands out the next prepared event, and its own send(), which appends each event to a list.
  3. The first chunk. read_body() awaits receive() and gets b'to be or ' with more_body true. More is coming, so it keeps the chunk and asks again.
  4. The second chunk. b'not to ' arrives, also with more_body true. Still not the end of the body.
  5. The last chunk. b'be' arrives with more_body false, so the body is complete: the three chunks joined make to be or not to be, 18 characters.
  6. The answer. The app counts 6 words and sends two events back: http.response.start with status 200, then a JSON body. The fake send() only records them.
  7. Read from the record. call() reads the status and the body from the recorded events and returns them: (200, {'words': 6, 'characters': 18}), the first line of the output.
  8. Had it read one event. An app that read only the first event would have counted 3 words, to be or. The specification says to keep reading until more_body is false.

read_body() keeps calling receive() until an event has more_body set to false, then joins the chunks; an app that reads only the first event would have counted three words instead of six. Press Edit, pass the whole body as one chunk, [b"to be or not to be"], and run it: the first line stays the same, as it must. Framework test clients work on the same principle, and the exercise at the end of this lesson uses one just like this.

Reading only the first http.request event

event = await receive() followed at once by body = event["body"] drops every chunk after the first. Loop until more_body is false and join the chunks, as read_body() does.

Checkpoint

Check: Testing an app by calling it

  1. One question A test hands an ASGI app the body in two http.request events, b"hello " with more_body true and then b"world" with more_body false. What body should the app work with?

    Choose one answer.

    Show the answer: One question

    Answer: b'hello world', the two chunks joined

    The ASGI HTTP specification sends a request body in one or more http.request events. An app reads until an event has more_body false, then joins the bodies: b'hello world'.

Async overlaps waiting; it does not speed up computing

ASGI’s advantage is not speed of computation, it is waiting well. A request that waits for a database, another API or a slow client holds a WSGI worker for the whole wait; in an async app, await hands the event loop: The part of asyncio that runs tasks one at a time in one thread. It runs a task until it awaits something that is not ready, runs other ready tasks meanwhile, and resumes the first once its timer fires or its data arrives. More in “Async Python: the event loop mental model” to other requests in the meantime. This simulation serves three requests that each wait 0.2 seconds, first with one blocking worker and then with one event loop:

Three waiting requests, one sync worker against one event loop Python · waiting/one_worker.py
"""Three requests that each wait 0.2 s (say, for a database), served by one sync worker and by one async worker."""
import asyncio
import time


def sync_handler():
    time.sleep(0.2)  # a blocking wait: the worker can do nothing else meanwhile
    return "done"


async def async_handler():
    await asyncio.sleep(0.2)  # an await: the event loop runs other requests meanwhile
    return "done"


start = time.perf_counter()
results = [sync_handler() for _ in range(3)]  # one WSGI-style worker takes one request at a time
sync_seconds = time.perf_counter() - start


async def serve_three():
    return await asyncio.gather(*(async_handler() for _ in range(3)))


start = time.perf_counter()
results = asyncio.run(serve_three())  # one ASGI-style worker interleaves the three waits
async_seconds = time.perf_counter() - start

print(f"sync worker:  3 requests in {sync_seconds:.1f} s")
print(f"async worker: 3 requests in {async_seconds:.1f} s")
print("the async worker overlapped the waits:", async_seconds < sync_seconds / 2)

Output

sync worker:  3 requests in 0.6 s
async worker: 3 requests in 0.2 s
the async worker overlapped the waits: True

This output changes from run to run: the times depend on how busy the computer is.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_worker.py

Step through the two workers, with time running down the page:

Interactive · step by step Three waits of 0.2 s, on one worker each way Step through the two workers side by side. Time runs down the page; each box is a request waiting.
Three waits of 0.2 s, on one worker each way, step 7 of 7. Showing One sync worker, One event loop, Request 1 waits, Request 2 waits, Request 3 waits, All done after 0.6 s, Requests 1, 2, 3 wait, All done after 0.2 s. In focus: One sync worker, One event loop.One sync workertime.sleep(0.2), 3 timesOne event loopasyncio.sleep(0.2), 3 timesRequest 1 waitsfrom 0.0 s to 0.2 sRequest 2 waitsfrom 0.2 s to 0.4 sRequest 3 waitsfrom 0.4 s to 0.6 sAll done after 0.6 sRequests 1, 2, 3 waitall from 0.0 s to 0.2 sAll done after 0.2 s

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Three requests, one worker each. Three requests arrive together, and each must wait 0.2 seconds, say for a database. Each side has a single worker to serve them.
  2. A blocking wait. time.sleep(0.2) blocks the sync worker. Request 1 holds it for the whole wait, and requests 2 and 3 can only queue.
  3. One after another. Request 2 starts only when request 1 is done, at 0.2 s, and request 3 at 0.4 s.
  4. The sum of the waits. The sync worker is done after the sum of the three waits, 0.6 s: the first line of the output.
  5. An await. await asyncio.sleep(0.2) hands the event loop back while a request waits, so the loop starts requests 2 and 3 straight away.
  6. The waits overlap. All three waits run at the same time, so the loop is done after about one wait, 0.2 s: the second line, and the check that the waits overlapped.
  7. Computing is not waiting. Had each request spent its 0.2 s computing instead of waiting, both sides would need 0.6 s: async overlaps waits, and one thread still computes one thing at a time.

The blocking worker needed the sum of the waits; the event loop needed about one wait, because the three waits overlapped. Real WSGI servers start several worker processes or threads to serve requests in parallel, so the difference shows when there are more waiting requests than workers: long polling, WebSockets, or an API that calls slow services.

Key idea

An await lets one event loop overlap the waits of many requests, so async pays off while requests wait, not while they compute.

Two limits come with it. CPU-heavy work, such as resizing images, is not made faster by async, or in the words of Flask’s documentation, “async is not inherently faster than sync code”. And a single blocking call: A call that holds its thread until it finishes, such as time.sleep() or a network client not written for asyncio. Inside a coroutine it stops the event loop, and with it every other task, for as long as it runs. More in “Async Python: the event loop mental model” inside an async view, a time.sleep() or a database driver without async support, stalls every request on that event loop, which the async mental model lesson of this track covers in depth.

A blocking call inside an async view

time.sleep(1) in an async def view holds the event loop for a whole second, and every other request on it waits. Await the async form, await asyncio.sleep(1), and use database and HTTP clients written for asyncio.

Checkpoint

Check: What async changes, and what it does not

  1. Question 1 of 2 One sync worker serves 10 requests that arrive together, and each waits 0.2 s on a database. How many seconds pass before the last one is done?

    Type a number.

    A hint

    A blocking worker holds each request for its whole wait.

    Show the answer: Question 1 of 2

    Answer: 2 seconds

    The worker takes one request at a time, so the waits add up: 10 × 0.2 s = 2 s. One event loop that awaits each wait would overlap them and be done after about 0.2 s.

  2. Question 2 of 2 An async view calls time.sleep(1) while 50 other requests are waiting on the same event loop. What happens to them?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: All of them wait for that second, because the blocking call holds the loop's only thread

    An event loop overlaps waits only at awaits. A blocking call such as time.sleep(), or a database driver without async support, holds the loop and stalls every request on it; use await asyncio.sleep() or an async client.

Flask, Django and FastAPI build different amounts on top

Each framework turns the raw interface into routing, request and response objects, and error handling, and each makes different choices about what else comes in the box.

Flask describes itself as a lightweight WSGI web application framework. It is built on Werkzeug (the WSGI toolkit), Jinja (templates) and Click (the command line), and leaves databases, forms and accounts to extensions you choose. Flask can run async def views when it is installed with its async extra, but its documentation is explicit that it remains a WSGI application: each async view runs in an event loop in a thread, and each request still ties up one worker. For an async-first Flask style, the same project maintains Quart, a reimplementation on ASGI.

Django includes an object-relational mapper (ORM): A library that maps classes to tables and objects to rows, such as SQLAlchemy's ORM, so that code reads and changes objects while the library writes the SQL. More in “Beyond SQLite: DB-API drivers and ORMs”, so each database table is a Python class, together with migrations, authentication, forms, templates, and an admin site that it generates from your models. Its documentation names WSGI as its primary deployment platform and also supports ASGI. Under ASGI it has a fully async request stack; under WSGI, async views still work but run in a one-off event loop per request, without the benefits of an async stack. Its synchronous ORM calls are protected from async code and raise SynchronousOnlyOperation there; the ORM has a-prefixed async variants, such as aget() and acreate(), instead.

FastAPI describes itself as a framework for building APIs based on standard Python type hints. It is built on Starlette, an ASGI toolkit, for the web parts and on Pydantic for the data parts: the types of a function’s parameters decide how the request is parsed and validated, and the framework generates an OpenAPI description with interactive documentation pages from them. Its fastapi dev command starts the Uvicorn ASGI server.

Answer the four questions in the table for a project of your own:

Interactive · answer and compare Flask, Django or FastAPI for your project? Answer four questions about your project. The table marks the frameworks that fit and says why the others do not.
Do staff need a back office to edit the data, with user accounts?
Is the product mainly a JSON API for apps or other services?
Do you need WebSockets or many connections that stay open and wait?
Do you want to start small and choose each piece yourself?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three Python web frameworks against the five things that decide between them for a first project
Framework Interface Comes with Database WebSockets Typical first project
Flask Built on: WSGI, with Werkzeug and Jinja Mixed WSGI: async views run in a thread, one worker per request Mixed routing, templates and a development server; the rest from extensions Mixed your choice, through extensions Poor not in Flask itself; Quart, its ASGI version, has them Good a small site or API where you pick each piece
Django Built on: WSGI or ASGI Good WSGI or ASGI, with a fully async stack under ASGI Good ORM, migrations, admin, authentication, forms and templates Good built-in ORM with migrations Mixed through ASGI, with add-on packages Good a database-backed site with staff users
FastAPI Built on: ASGI, on Starlette and Pydantic Good ASGI, on Starlette Mixed validation from type hints and OpenAPI docs; the rest is your choice Mixed your choice of library Good built in, from Starlette Good a JSON API for an app or a service
How the answers decide
  • “Do staff need a back office to edit the data, with user accounts?” Yes, a back office and accounts: adds a caveat to Flask and FastAPI. Django brings authentication and an admin site generated from your models; Flask and FastAPI need extensions or other libraries for both.
  • “Is the product mainly a JSON API for apps or other services?” Yes, mainly a JSON API: adds a caveat to Flask and Django. FastAPI validates requests from the type hints of your functions and generates OpenAPI documentation; the others need extra packages for that.
  • “Do you need WebSockets or many connections that stay open and wait?” Yes, live features: rules out Flask. Flask itself has no WebSockets, and each request ties up one worker; Quart, its ASGI reimplementation, is the Flask-style choice for live features.
  • “Do you need WebSockets or many connections that stay open and wait?” Yes, live features: adds a caveat to Django. Django runs under ASGI with a fully async stack, but its WebSockets come from add-on packages.
  • “Do you want to start small and choose each piece yourself?” Yes, small and my own choices: adds a caveat to Django. Django comes as a full stack, ORM, migrations, admin, forms and templates, whether or not you use them all.

All three are widely used. In the Stack Overflow Developer Survey 2025, 14.8 % of all respondents reported extensive development work with FastAPI in the past year, 14.4 % with Flask and 12.6 % with Django, so any of them is a sound first choice. Pick by the project, not by the ranking, and expect to read code in all three during a career. Then build something small and complete with one of them: the planned Flask, Django and FastAPI tracks go into each in depth, the API Design track covers REST, versioning and errors, and Web Security covers the OWASP Top 10. If you are not sure, a database-backed site points to Django and a JSON API to FastAPI.

A development server in production

flask run, Django’s runserver and fastapi dev are for development. Flask’s documentation says its development server is not designed to be particularly secure, stable or efficient, Django’s warns that runserver has not gone through security audits or performance tests, and FastAPI’s says to use fastapi run in production. Deploy on a production server such as Gunicorn for WSGI or Uvicorn for ASGI.

Checkpoint

Check: Flask, Django and FastAPI

  1. One question A team needs a site with user accounts, a database of products and a back office where staff edit the products. Which framework gives them the most without extra packages?

    Choose one answer.

    Show the answer: One question

    Answer: Django

    Django comes with an object-relational mapper, migrations, authentication and an admin site generated from the models. Flask and FastAPI would need extensions or other libraries for the same pieces.

Security stays your job in every framework

A framework gives you tools; using them is still your decision. Whichever you choose, four points come up in every project:

  • Validate input at the edge: types, lengths and allowed values, and never build SQL, shell commands or HTML by pasting request data into strings.
  • Protect cookie sessions against CSRF. The OWASP cheat sheet’s first advice is to check whether your framework has built-in CSRF protection and use it. Django’s CSRF middleware is on by default, and the {% csrf_token %} tag puts the token into each POST form of your templates; Flask leaves the check to a form-validation extension, as its security page explains; and an API that sends tokens in headers instead of cookies is not exposed in the same way, because browsers attach only cookies by themselves.
  • Send secure headers, such as a Content Security Policy and Strict-Transport-Security, and turn debug pages off in production, because they show code and settings.
  • Keep secrets out of the code, read them from the environment or a secrets manager, as the configuration lesson shows.

Step through why a session kept in a cookie needs that token, and how the check refuses a form sent from another site:

Interactive · message by message A session cookie, a form from elsewhere, and the token check Step through what the browser sends to your site, first without a token check and then with Django's CSRF middleware.
A session cookie, a form from elsewhere, and the token check, step 7 of 7. Messages so far: Your site to Your browser: Set-Cookie: session; Another site to Your browser: a form aimed at your site; Your browser to Your site: POST, with the cookie; Your site to Your browser: 403 Forbidden.Your browserlogged in to your siteYour sitecookie and token checkedAnother sitenot yoursSet-Cookie: sessiona form aimed at your sitePOST, with the cookie403 Forbidden

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Logged in. You log in to your site, and it sets a session cookie. From now on the browser sends that cookie with every request to your site.
  2. A form from elsewhere. Later you open a page on another site. It holds a form whose action is your site, for example the page that changes your email address.
  3. The cookie goes along. When that form is submitted, the browser sends it to your site and attaches your session cookie by itself, as it does for every request to that site.
  4. Without a check. If your site only looks at the cookie, the request seems to come from you, and the change is made. That is cross-site request forgery, CSRF.
  5. A token in your forms. With CSRF protection, every form your site serves carries a secret token for your session, and the server checks it on each request that changes data.
  6. No token, no change. The form from the other site has no valid token, so the check fails. Django's middleware, on by default, answers 403 Forbidden and changes nothing.
  7. Two more layers. SameSite=Lax on the session cookie stops the browser sending it with form posts from other sites. An API that takes a token in a header instead is not exposed this way: browsers attach cookies by themselves, not headers.

Turning off CSRF protection to make a form work

A 403 from Django on one of your own forms means the form did not send its token. Put {% csrf_token %} back inside the <form>; removing the middleware or marking the view csrf_exempt leaves that form open to forged requests.

REST API Tester (Online API Client) Send GET and POST requests to an API you are building, set headers and bodies, and read the status and response headers it returns.

Checkpoint

Check: Security work in every framework

  1. One question Why does a site that keeps its login session in a cookie need CSRF protection, while an API that takes a token in an Authorization header is not exposed in the same way?

    Choose one answer.

    Show the answer: One question

    Answer: Browsers attach cookies to requests by themselves, even from another site's page, but never add your headers

    CSRF works because the browser adds the session cookie by itself. A token that the site's own forms carry, or a credential your code puts in a header, is something a page on another site cannot supply.

Practise: an ASGI app with two routes

The exercise asks for a small ASGI app without any framework, GET /health and POST /echo, which must read a body that arrives in several events and answer 405 or 404 where it should. Its sample tests run in your browser and call the app exactly as the lesson’s test client does:

Exercise · Medium · Python

A minimal ASGI app with two routes

Write app(scope, receive, send) in service.py, an ASGI application without any framework.

  • GET /health answers status 200 with the body b'{"status": "ok"}' and the header (b"content-type", b"application/json").
  • POST /echo reads the whole request body, which can arrive in several http.request events (keep reading while more_body is true), and answers status 200 with exactly those bytes as the body. Its content type is the request's content-type header when it has one, else b"application/octet-stream".
  • A known path with another method answers 405 with the body b"method not allowed" and an (b"allow", b"GET") or (b"allow", b"POST") header.
  • Any other path answers 404 with the body b"not found".
  • Every response is one http.response.start event with status and headers (a list of pairs of bytes, names in lower case), followed by one http.response.body event.
  • A scope whose type is not "http", such as "lifespan", raises ValueError, as the ASGI specification asks of protocols an app does not support.

Starter code · service.py

async def app(scope, receive, send):
    """GET /health and POST /echo, as an ASGI application without a framework."""
    # Replace these lines with your code.
    await send({"type": "http.response.start", "status": 500, "headers": []})
    await send({"type": "http.response.body", "body": b""})
The sample tests · test_service.py
from service import app


async def call(method, path, chunks=(b"",), headers=()):
    scope = {"type": "http", "asgi": {"version": "3.0"}, "http_version": "1.1", "method": method, "path": path,
             "query_string": b"", "headers": [list(h) for h in headers]}
    events = [{"type": "http.request", "body": c, "more_body": i < len(chunks) - 1} for i, c in enumerate(chunks)]
    sent = []

    async def receive():
        return events.pop(0)

    async def send(message):
        sent.append(message)

    await app(scope, receive, send)
    return sent


def header(start, name):
    return dict((bytes(k), bytes(v)) for k, v in start["headers"]).get(name)


async def test_health():
    """GET /health answers 200 with a JSON body"""
    start, body = await call("GET", "/health")
    assert start["type"] == "http.response.start"
    assert start["status"] == 200
    assert header(start, b"content-type") == b"application/json"
    assert body == {"type": "http.response.body", "body": b'{"status": "ok"}'}


async def test_echo_in_chunks():
    """POST /echo returns the whole body, however many events it took"""
    sent = await call("POST", "/echo", [b"to be ", b"or not ", b"to be"], [(b"content-type", b"text/plain")])
    start, body = sent
    assert start["status"] == 200
    assert header(start, b"content-type") == b"text/plain"
    assert body["body"] == b"to be or not to be"


async def test_echo_default_type():
    """without a content-type header, the echo is application/octet-stream"""
    start, body = await call("POST", "/echo", [b"\x00\x01"])
    assert header(start, b"content-type") == b"application/octet-stream"
    assert body["body"] == b"\x00\x01"


async def test_more_body_defaults_to_false():
    """an http.request event without more_body is the last one"""
    events = [{"type": "http.request", "body": b"abc"}]
    sent = []

    async def receive():
        return events.pop(0)

    async def send(message):
        sent.append(message)

    await app({"type": "http", "method": "POST", "path": "/echo", "headers": []}, receive, send)
    assert sent[1]["body"] == b"abc"


async def test_wrong_method():
    """a known path with another method answers 405 and says what is allowed"""
    start, body = await call("POST", "/health")
    assert start["status"] == 405
    assert header(start, b"allow") == b"GET"
    start, body = await call("GET", "/echo")
    assert start["status"] == 405
    assert header(start, b"allow") == b"POST"
    assert body["body"] == b"method not allowed"


async def test_unknown_path():
    """any other path answers 404"""
    start, body = await call("GET", "/missing")
    assert start["status"] == 404
    assert body["body"] == b"not found"


async def test_rejects_other_protocols():
    """a lifespan scope raises ValueError"""
    try:
        await app({"type": "lifespan"}, None, None)
    except ValueError:
        return
    raise AssertionError("a lifespan scope must raise ValueError")
A hint

Start with if scope["type"] != "http": raise ValueError(...). Then look at scope["path"] and scope["method"]. For the body, loop: event = await receive(), append event.get("body", b""), and stop when event.get("more_body", False) is false. Request headers are a list of [name, value] byte pairs, so find the content type with a loop or dict(scope["headers"]).get(b"content-type"). A small helper async def reply(send, status, headers, body) that sends the two events keeps every branch short.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then take the questions on the six ideas, from the WSGI call to CSRF. They start with whichever idea your checkpoints showed you know least, and every pick, right or wrong, comes with its reason:

Practice that adapts to you

Practise this lesson

14 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 What does a WSGI application return?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: An iterable of bytes, after it has called start_response(status, headers)

    PEP 3333 makes the application a callable of environ and start_response; it calls start_response with the status and a list of header pairs and returns an iterable that yields the body as bytes.

  2. Question 2 of 14 A request asks for /hello?name=Asha. Where does a WSGI app find name=Asha?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: In environ['QUERY_STRING']

    environ holds the request: the path in PATH_INFO, the query string in QUERY_STRING, the headers as HTTP_ keys and the body as a stream.

  3. Question 3 of 14 An ASGI app receives an http.request event with more_body set to true. What should it do?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: Keep calling receive() and joining the bodies until an event has more_body false

    The ASGI HTTP specification sends a request body in one or more http.request events; more_body true means more events follow, and the app joins their bodies in order.

  4. Question 4 of 14 Which headers value is right for an http.response.start event, by the ASGI specification?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: [(b"content-type", b"text/plain; charset=utf-8")]

    An ASGI response's headers are pairs of bytes, and the specification requires header names to be lower-cased: [(b"content-type", b"text/plain; charset=utf-8")], as hello_both.py sends.

  5. Question 5 of 14 What does word_count_app.py print?

    What does this program print? Choose one answer.

    """An ASGI app tested by calling it directly: a fake scope, a receive() that delivers the body in chunks, a send() that records."""
    import asyncio
    import json
    
    
    async def read_body(receive):
        """Collect the request body, which may arrive in several http.request events."""
        chunks = []
        while True:
            event = await receive()
            if event["type"] == "http.disconnect":
                raise ConnectionError("the client went away")
            chunks.append(event.get("body", b""))
            if not event.get("more_body", False):
                return b"".join(chunks)
    
    
    async def respond(send, status, data):
        body = json.dumps(data).encode()
        await send({"type": "http.response.start", "status": status,
                    "headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())]})
        await send({"type": "http.response.body", "body": body})
    
    
    async def app(scope, receive, send):
        if scope["type"] != "http":
            raise ValueError(f"unsupported protocol: {scope['type']}")
        if scope["path"] != "/count":
            return await respond(send, 404, {"error": "not found"})
        if scope["method"] != "POST":
            return await respond(send, 405, {"error": "use POST"})
        text = (await read_body(receive)).decode("utf-8")
        await respond(send, 200, {"words": len(text.split()), "characters": len(text)})
    
    
    async def call(app, method, path, chunks=(b"",)):
        """A test client in a dozen lines: what an ASGI server would do, without a socket."""
        scope = {"type": "http", "method": method, "path": path, "query_string": b"", "headers": []}
        events = [{"type": "http.request", "body": c, "more_body": i < len(chunks) - 1} for i, c in enumerate(chunks)]
        sent = []
    
        async def receive():
            return events.pop(0)
    
        async def send(message):
            sent.append(message)
    
        await app(scope, receive, send)
        start, body = sent[0], b"".join(m.get("body", b"") for m in sent[1:])
        return start["status"], json.loads(body)
    
    
    async def main():
        print(await call(app, "POST", "/count", [b"to be or ", b"not to ", b"be"]))  # three chunks
        print(await call(app, "GET", "/count"))
        print(await call(app, "POST", "/other"))
        try:
            await app({"type": "lifespan"}, None, None)
        except ValueError as error:
            print("lifespan:", error)
    
    
    asyncio.run(main())
    Show the answer: Question 5 of 14

    Answer: it prints

    (200, {'words': 6, 'characters': 18})
    (405, {'error': 'use POST'})
    (404, {'error': 'not found'})
    lifespan: unsupported protocol: lifespan

    The test client delivers the body in three chunks and the app joins all of them before counting, so it sees the whole sentence. GET on /count is refused with 405, /other does not exist, and the lifespan scope is rejected with ValueError, as the ASGI specification asks for protocols an app does not support.

  6. Question 6 of 14 Why can a test call an ASGI app without starting a server?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: The app is just an async function of scope, receive and send, so the test can pass its own

    Both interfaces are function calls. A test that builds a scope, a receive() and a send() is exactly what a server would do, without the network; framework test clients work on the same principle.

  7. Question 7 of 14 You add a test for an http.disconnect event that arrives before the body is complete. What should word_count_app.py's read_body() do, and how does the test check it?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: Raise ConnectionError; the test hands out a disconnect event and expects the error

    A fake receive() can hand out any event the specification defines, including http.disconnect, so tests can cover what a real network does only now and then.

  8. Question 8 of 14 Which statements about async frameworks are true?

    Choose every answer that is right.

    Show the answer: Question 8 of 14

    Answer:

    • One blocking call, such as time.sleep() or a sync database driver, stalls every request on that event loop
    • Async helps when requests spend their time waiting for databases, other APIs or slow clients

    Async overlaps waits, as the one-worker example showed; it does not make computation faster. A blocking call inside an async view holds up the loop for every other request. Under WSGI, Django and Flask run async views in an event loop per request: a view can still overlap its own calls, but every request holds a worker, as it does with sync views.

  9. Question 9 of 14 In one_worker.py, why did the async worker serve three 0.2 s waits in about 0.2 s?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: Each await asyncio.sleep() handed the event loop back, so the three waits overlapped

    A blocking sleep holds the worker, so three waits take 0.6 s in a row. An await gives the loop back, so the three waits overlap and take about one wait.

  10. Question 10 of 14 Which framework builds JSON APIs on ASGI from the type hints of your functions?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: FastAPI

    FastAPI describes itself as a framework for building APIs from standard Python type hints, built on Starlette for the web parts and Pydantic for the data parts.

  11. Question 11 of 14 A Flask site must add a live feed that keeps a WebSocket open to each of 2,000 visitors. What does the lesson's comparison suggest?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: Serve the feed from an ASGI stack, such as Quart or FastAPI, because Flask has no WebSockets and ties up a worker per request

    WebSockets and connections that stay open need ASGI. Flask's own project offers Quart for an async-first, Flask-style app, and FastAPI has WebSockets built in through Starlette.

  12. Question 12 of 14 What should serve a Flask app in production?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: A production WSGI server, such as Gunicorn

    Development servers are for development. A Flask app in production runs on a WSGI server such as Gunicorn, and ASGI apps on servers such as Uvicorn.

  13. Question 13 of 14 What does Django do about CSRF when you create a project?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: Its CSRF middleware is on by default, and forms carry the token with the csrf_token tag

    Django's CsrfViewMiddleware is active by default. Templates put {% csrf_token %} inside each POST form, and a request without a valid token is answered with 403 Forbidden.

  14. Question 14 of 14 A Django form on your own site fails with 403 Forbidden after a template change. What is the right fix?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Put the csrf_token tag back inside the form

    The 403 means the check worked: the form did not carry a token. Send the token, never turn the check off to make a form work.

Interview questions

What is the difference between WSGI and ASGI?

Both are standard interfaces between Python web applications and web servers. WSGI (PEP 3333) is synchronous: the server calls app(environ, start_response) once per request, and the app returns the whole body as an iterable of bytes, holding its worker for the whole request. ASGI is asynchronous: the server awaits app(scope, receive, send) once per connection, and the app exchanges events, so the body can arrive and leave in pieces and the connection can stay open. That lets one event loop serve many requests that are waiting on I/O, and it supports WebSockets and long-lived connections, which WSGI cannot carry. Flask is a WSGI framework, FastAPI an ASGI one, and Django supports both. ASGI does not make CPU-bound code faster, and blocking calls in async views stall every request.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A Flask API calls three external services per request, about 300 ms each, one after another, and times out under load. A colleague proposes rewriting it in FastAPI "because it is faster". How do you evaluate the proposal?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid

    You change a Django view to async def so that it can call an external API with an async HTTP client. It now fails with SynchronousOnlyOperation when it reads a model with Model.objects.get(). Explain why, and fix it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • WSGI apps are app(environ, start_response) and return bytes; ASGI apps are async and exchange events through receive and send.
  • Both are plain calls, so a test can play the server with a hand-made scope, a receive() that hands out events and a send() that records them.
  • ASGI overlaps waiting requests and carries WebSockets; it does not speed up computation, and a blocking call stalls every request on the loop.
  • Flask is a small WSGI core with extensions, Django a full stack with ORM and admin on WSGI or ASGI, and FastAPI typed JSON APIs on ASGI.
  • Validation, CSRF protection for cookie sessions, secure headers and secrets outside the code are your job in every framework.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.