mrkeyoor.com_
Wed 05 Aug 23:09 UTC
PyPIWeb Backendupdated 05 Aug 2026

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.

Verdict

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.

API stability4/51.0 (March 2026) formalized years of de facto stability, but it did so by deleting the deprecated event and decorator APIs, so old code needs real edits; since 1.0 the line has been clean and semver-honest.
Docs4/5starlette.dev is concise, accurate, and example-driven with detailed release notes; it assumes ASGI literacy and offers little hand-holding for beginners coming from Flask.
Maintenance5/5Sponsored full-time-ish maintenance by Kludex with releases days apart (1.4.0 and 1.4.1 both shipped August 5, 2026), 100% coverage, and a disciplined deprecation policy.
Ecosystem5/5As the base of FastAPI and a dependency of the Python MCP SDK it sees 154M weekly downloads, and virtually all ASGI middleware is written against its interfaces.

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
Skip it if

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:app

Routes 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

PackageRegistryPick it when
fastapiPyPIYou want Starlette's engine plus pydantic validation, dependency injection, and automatic OpenAPI docs; it is the default choice for JSON APIs.
litestarPyPIYou want batteries (DI, ORM integration, msgspec speed) with a framework that is not built on Starlette and has a team-based governance model.
quartPyPIYou have a Flask codebase or Flask muscle memory and want the same API in async form.
aiohttpPyPIYou want client and server in one mature library and do not care about the ASGI ecosystem.