starlette
Starlette is the lightweight ASGI framework that FastAPI, and by extension a large slice of modern Python web development, is built on: routing, middleware, websockets, background tasks, static files, sessions, and a test client, with anyio as the only hard dependency. After eight years on ZeroVer it finally shipped 1.0 in March 2026 under maintainer Marcelo Trylesinski (Kludex), removing the long-deprecated event and decorator APIs. It works as a full microframework or as a toolkit of independently usable ASGI components.
One of the best-executed pieces of Python web infrastructure: small, readable, fast, and finally stable at 1.0 after eight years. Use it directly when you want control without a framework's opinions; use FastAPI when you want the opinions. Just do not trust any tutorial written before 2026.
Use it if
- You want an async web layer with minimal magic: explicit route tables, plain Request/Response objects, and nothing hidden behind decorator metaprogramming
- You are building ASGI middleware or a framework: Starlette components are designed to be used standalone, which is why FastAPI and the Python MCP SDK sit on top of it
- You need websockets, streaming responses, and background tasks without adopting a batteries-included framework
- You value a tiny dependency footprint: one hard dependency (anyio), with test client, templates, and forms as opt-in extras
- You want request validation, serialization, and OpenAPI docs out of the box: that is literally FastAPI, which is Starlette plus those things
- You need an ORM, admin, auth system, and migrations included: Django still earns its weight for CRUD-heavy products
- Your code and libraries are synchronous WSGI: Flask remains the simpler fit rather than wrapping sync code in an async server
- You are copy-pasting from pre-2026 tutorials: 1.0 removed @app.route, on_startup/on_shutdown, and @app.on_event, so most old Starlette snippets crash on current versions
Setup reality
pip install starlette uvicorn, because Starlette ships no server; that split surprises Flask people. The 1.0 cleanup is the real migration tax: on_startup, on_shutdown, @app.on_event, @app.route, @app.websocket_route, and @app.exception_handler as a decorator are all gone, replaced by the lifespan context manager and explicit routes= and exception_handlers= parameters, so years of blog posts now throw AttributeError. Optional features need optional deps you discover at runtime: httpx (or httpx2 since 1.2) for TestClient, python-multipart for form parsing, itsdangerous for sessions, jinja2 for templates; starlette[full] grabs them all.
Patterns
Minimal applicationminimal-app
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
async def homepage(request):
return JSONResponse({"hello": "world"})
app = Starlette(routes=[Route("/", homepage)])
# run: uvicorn main:appRoutes are declared as a list, not decorators: @app.route was deprecated for years and removed in 1.0. Endpoints can be sync or async; sync ones run in a threadpool.
Path parameters with converterspath-params
from starlette.routing import Route
async def user_detail(request):
user_id = request.path_params["user_id"] # already an int
return JSONResponse({"id": user_id})
routes = [Route("/users/{user_id:int}", user_detail)]Converters (int, float, uuid, path) validate and coerce in the route; a non-integer URL 404s before your handler runs. No converter means you get a string.
Startup and shutdown via lifespanlifespan-startup
import contextlib
from starlette.applications import Starlette
@contextlib.asynccontextmanager
async def lifespan(app):
app.state.db = await create_pool()
yield
await app.state.db.close()
app = Starlette(routes=routes, lifespan=lifespan)This is the only startup/shutdown mechanism since 1.0: on_startup, on_shutdown, and @app.on_event were removed. Handlers reach shared state via request.app.state.
Declare the middleware stackmiddleware-stack
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
app = Starlette(routes=routes, middleware=[
Middleware(CORSMiddleware, allow_origins=["https://app.example.com"],
allow_methods=["*"], allow_headers=["*"]),
Middleware(GZipMiddleware, minimum_size=1000),
])Middleware is a constructor list, outermost first; @app.middleware('http') is gone since 1.0. As of 1.4, GZipMiddleware offloads large compressions to a thread so the event loop stays responsive.
Serve static files under a mountstatic-files-mount
from starlette.routing import Mount
from starlette.staticfiles import StaticFiles
routes = [
Mount("/static", app=StaticFiles(directory="static"), name="static"),
Route("/", homepage),
]StaticFiles handles ETags and range requests; html=True turns it into a single-page-app fallback server. Mount also nests entire sub-applications, ASGI or Starlette alike.
WebSocket endpointwebsocket-endpoint
from starlette.routing import WebSocketRoute
async def ws(websocket):
await websocket.accept()
async for message in websocket.iter_text():
await websocket.send_text(f"echo: {message}")
routes = [WebSocketRoute("/ws", ws)]iter_text/iter_json loop until the client disconnects and swallow the disconnect exception for you. Use WebSocketRoute in routes=; the websocket_route decorator was removed in 1.0.
Respond first, work afterbackground-tasks
from starlette.background import BackgroundTask
from starlette.responses import JSONResponse
async def signup(request):
data = await request.json()
task = BackgroundTask(send_welcome_email, to=data["email"])
return JSONResponse({"ok": True}, background=task)The task runs in-process after the response is sent: fine for emails and cache warming, wrong for anything that must survive a crash or restart; that still needs a real queue.
Stream a responsestreaming-response
from starlette.responses import StreamingResponse
async def stream(request):
async def gen():
async for row in query_rows():
yield row.to_json() + "\n"
return StreamingResponse(gen(), media_type="application/x-ndjson")The generator is consumed as the client reads, so memory stays flat on huge exports. If the client disconnects mid-stream, the generator gets cancelled; use try/finally for cleanup.
Test without a servertest-client
# pip install httpx (or httpx2, supported since 1.2)
from starlette.testclient import TestClient
def test_homepage():
with TestClient(app) as client: # 'with' runs lifespan
r = client.get("/")
assert r.status_code == 200
assert r.json() == {"hello": "world"}TestClient is sync on the outside (pytest-friendly) and runs your async app inside. Using it as a context manager is what triggers lifespan; without 'with', startup code never runs.
Jinja2 templates, current signaturetemplates
# pip install jinja2
from starlette.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
async def page(request):
return templates.TemplateResponse(
request, "index.html", {"title": "Home"}
)The request-first signature is mandatory since 1.0; the old TemplateResponse('index.html', {'request': request}) form was removed. Passing a preconfigured jinja2.Environment via env= replaced **env_options.
Custom error handlersexception-handlers
from starlette.exceptions import HTTPException
from starlette.responses import JSONResponse
async def not_found(request, exc):
return JSONResponse({"error": "not found"}, status_code=404)
async def boom(request, exc):
return JSONResponse({"error": "internal"}, status_code=500)
app = Starlette(routes=routes, exception_handlers={
404: not_found,
Exception: boom,
})Keys are status codes or exception classes; the Exception catch-all replaces default 500 pages. The @app.exception_handler decorator went away in 1.0, another silent tutorial-breaker.
Handle forms and file uploadsform-upload
# pip install python-multipart
async def upload(request):
async with request.form(max_files=5, max_fields=10) as form:
upload = form["file"] # starlette.datastructures.UploadFile
contents = await upload.read()
return JSONResponse({"filename": upload.filename, "size": len(contents)})request.form() raises without python-multipart installed. The max_files/max_fields/max_part_size limits matter for abuse resistance and got stricter enforcement in 1.3.1.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| fastapi | PyPI | You want Starlette's engine plus pydantic validation, dependency injection, and automatic OpenAPI docs; it is the default choice for JSON APIs. |
| litestar | PyPI | You want batteries (DI, ORM integration, msgspec speed) with a framework that is not built on Starlette and has a team-based governance model. |
| quart | PyPI | You have a Flask codebase or Flask muscle memory and want the same API in async form. |
| aiohttp | PyPI | You want client and server in one mature library and do not care about the ASGI ecosystem. |