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.
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:
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.
- A browser or an API client sends an HTTP request.
- An application server receives it. WSGI servers include Gunicorn and uWSGI; ASGI servers include Uvicorn, Hypercorn and Daphne.
- 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).
- The application is usually a framework: Flask speaks WSGI, Django speaks WSGI or ASGI, and FastAPI speaks ASGI through Starlette.
- 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:
"""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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Step through that WSGI call, from the request to the last byte:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- One call per request. The server calls wsgi_app(environ, start_response), once for this request, and waits for it to return.
- 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.
- 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.
- 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
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
"""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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Step through the first call, the POST whose body arrives in three chunks:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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'.
- 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.
- 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.
- The second chunk. b'not to ' arrives, also with more_body true. Still not the end of the body.
- 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.
- 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.
- 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.
- 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
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 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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- One after another. Request 2 starts only when request 1 is done, at 0.2 s, and request 3 at 0.4 s.
- 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.
- 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.
- 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.
- 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
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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
Checkpoint
Check: Security work in every framework
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 /healthanswers status 200 with the bodyb'{"status": "ok"}'and the header(b"content-type", b"application/json").POST /echoreads the whole request body, which can arrive in severalhttp.requestevents (keep reading whilemore_bodyis true), and answers status 200 with exactly those bytes as the body. Its content type is the request'scontent-typeheader when it has one, elseb"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.startevent withstatusandheaders(a list of pairs of bytes, names in lower case), followed by onehttp.response.bodyevent. - A scope whose
typeis not"http", such as"lifespan", raisesValueError, 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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?
-
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.
Key takeaways
- WSGI apps are
app(environ, start_response)and return bytes; ASGI apps areasyncand exchange events throughreceiveandsend. - Both are plain calls, so a test can play the server with a hand-made scope, a
receive()that hands out events and asend()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
- PEP 3333: Python Web Server Gateway Interface v1.0.1 (Python Software Foundation)
- ASGI (Asynchronous Server Gateway Interface) specification (ASGI Team)
- ASGI HTTP and WebSocket specification (ASGI Team)
- wsgiref: WSGI utilities and reference implementation (Python Software Foundation)
- Welcome to Flask (Pallets)
- Flask: using async and await (Pallets)
- Django at a glance (Django Software Foundation)
- Django: asynchronous support (Django Software Foundation)
- Django: how to deploy with ASGI (Django Software Foundation)
- Django: how to deploy with WSGI (Django Software Foundation)
- Django: how to use CSRF protection (Django Software Foundation)
- django-admin and manage.py: runserver (Django Software Foundation)
- Flask: security considerations (Pallets)
- Flask: deploying to production (Pallets)
- FastAPI (FastAPI)
- FastAPI features (FastAPI)
- FastAPI CLI (FastAPI)
- Stack Overflow Developer Survey 2025: technology (Stack Overflow)
- Cross-Site Request Forgery Prevention Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress