Starlette from Scratch: Everything You Need to Know
Starlette is a lightweight ASGI framework and toolkit for building high-performance async web applications in Python. Created by Tom Christie โ the same developer behind Django REST Framework โ Starlette has become the foundation upon which FastAPI is built. Whether you are building a small microservice or a large-scale async application, Starlette gives you the building blocks to do it cleanly and efficiently.
In this tutorial, we will walk through everything you need to know about Starlette from scratch: what it is, why it matters, how to use it, and the best practices that will keep your codebase maintainable as it grows.
What Is Starlette?
Starlette is an ASGI (Asynchronous Server Gateway Interface) framework. ASGI is the spiritual successor to WSGI, designed to support asynchronous Python and protocols like HTTP, HTTP/2, and WebSockets. Starlette is not a full-stack framework like Django; instead, it is a minimal toolkit that provides routing, middleware, request and response objects, static file serving, testing utilities, and more.
Starlette is designed to be:
- Lightweight โ minimal dependencies and a small core.
- Async-first โ built around
asynciofrom the ground up. - Composable โ you use only the pieces you need.
- Fast โ among the fastest Python frameworks available.
Why Starlette Matters
Modern web applications increasingly need to handle long-lived connections, real-time data, and high concurrency. Traditional WSGI frameworks like Flask and Django were not designed with async in mind, which makes them awkward fits for these workloads. Starlette fills this gap by providing a clean, async-native API.
Starlette also matters because it is the engine behind FastAPI. Understanding Starlette directly gives you a deeper understanding of how FastAPI works under the hood, and lets you build applications without the extra abstractions when you do not need them.
Installing Starlette
Starlette requires Python 3.8 or higher. You will also need an ASGI server to run your application. The most common choices are uvicorn and hypercorn. Install them together with Starlette:
pip install starlette uvicorn
Your First Starlette Application
A Starlette application is an instance of the Starlette class. You pass it a list of routes and optionally a list of middleware. Each route maps a URL path to an endpoint, which is an async callable that receives a Request and returns a Response.
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import JSONResponse
from starlette.requests import Request
async def homepage(request: Request) -> JSONResponse:
return JSONResponse({"message": "Hello, Starlette!"})
routes = [
Route("/", endpoint=homepage),
]
app = Starlette(routes=routes)
Save this as app.py and run it with Uvicorn:
uvicorn app:app --reload
Visiting http://127.0.0.1:8000/ will return the JSON response. That is a complete, working async web application in just a few lines of code.
Understanding Routes and Endpoints
Routes in Starlette are defined using the Route class. The endpoint argument is an async function (or a class with async methods) that handles the request. You can also restrict a route to specific HTTP methods using the methods argument.
from starlette.routing import Route
from starlette.responses import PlainTextResponse
async def get_user(request):
user_id = request.path_params["user_id"]
return PlainTextResponse(f"User {user_id}")
async def create_user(request):
data = await request.json()
return PlainTextResponse(f"Creating user: {data}")
routes = [
Route("/users/{user_id}", endpoint=get_user, methods=["GET"]),
Route("/users", endpoint=create_user, methods=["POST"]),
]
Path parameters like {user_id} are captured and made available in request.path_params. Starlette also supports WebSocketRoute for WebSocket endpoints and Mount for mounting sub-applications.
Working with Requests and Responses
The Request object gives you access to everything about the incoming HTTP request: headers, query parameters, path parameters, cookies, the body, and more. Because reading the body is an I/O operation, it is async.
async def echo(request):
# Query parameters
name = request.query_params.get("name", "world")
# Headers
user_agent = request.headers.get("user-agent")
# JSON body
body = await request.json()
# Form data
# form = await request.form()
return JSONResponse({
"name": name,
"user_agent": user_agent,
"body": body,
})
Starlette provides several response classes out of the box:
JSONResponseโ serializes a Python dict or list to JSON.PlainTextResponseโ returns plain text.HTMLResponseโ returns HTML content.RedirectResponseโ issues an HTTP redirect.FileResponseโ streams a file from disk.StreamingResponseโ streams an async generator or iterator.
Here is an example of a streaming response that yields chunks of data over time:
from starlette.responses import StreamingResponse
import asyncio
async def generate_numbers():
for i in range(10):
yield f"Number: {i}\n"
await asyncio.sleep(0.5)
async def stream_endpoint(request):
return StreamingResponse(generate_numbers(), media_type="text/plain")
Middleware in Starlette
Middleware lets you wrap your application with logic that runs before and after each request. Starlette ships with several built-in middleware classes, and you can write your own. Middleware is applied in reverse order: the last item in the list runs first on the way in.
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
from starlette.routing import Route
async def homepage(request):
from starlette.responses import JSONResponse
return JSONResponse({"hello": "world"})
middleware = [
Middleware(GZipMiddleware, minimum_size=1000),
Middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"]),
]
routes = [Route("/", homepage)]
app = Starlette(routes=routes, middleware=middleware)
You can also write custom middleware as a pure ASGI middleware class:
from starlette.types import ASGIApp, Receive, Scope, Send
class TimingMiddleware:
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
import time
start = time.perf_counter()
async def send_wrapper(message):
if message["type"] == "http.response.start":
elapsed = time.perf_counter() - start
message["headers"].append(
(b"x-response-time", str(elapsed).encode("ascii"))
)
await send(message)
await self.app(scope, receive, send_wrapper)
Register it like any other middleware:
middleware = [
Middleware(TimingMiddleware),
]
Handling WebSockets
Starlette has first-class support for WebSockets. You define a WebSocketRoute and an async endpoint that accepts the connection, then reads and sends messages.
from starlette.applications import Starlette
from starlette.routing import WebSocketRoute
from starlette.websockets import WebSocket
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
try:
while True:
message = await websocket.receive_text()
await websocket.send_text(f"Echo: {message}")
except Exception:
await websocket.close()
routes = [
WebSocketRoute("/ws", websocket_endpoint),
]
app = Starlette(routes=routes)
This creates a simple echo server. Starlette handles the connection lifecycle, and you focus on the message loop.
Mounting Sub-Applications
For larger applications, you can mount sub-applications at a URL prefix using Mount. This is useful for organizing routes or for serving static files.
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from starlette.responses import JSONResponse
async def api_info(request):
return JSONResponse({"version": "1.0"})
api_routes = [
Route("/info", api_info),
]
routes = [
Mount("/api", routes=api_routes),
Mount("/static", app=StaticFiles(directory="static")),
]
app = Starlette(routes=routes)
The StaticFiles application serves files from a directory on disk. Make sure the static directory exists before running the server.
Background Tasks
Starlette supports background tasks that run after the response is sent. This is useful for logging, sending emails, or triggering webhooks without making the client wait.
from starlette.background import BackgroundTask
from starlette.responses import JSONResponse
async def send_notification(email: str):
# Simulate sending an email
print(f"Sending notification to {email}")
async def signup(request):
data = await request.json()
email = data.get("email")
task = BackgroundTask(send_notification, email=email)
return JSONResponse(
{"status": "signed up"},
background=task,
)
The response is returned immediately, and the task runs afterward within the same request lifecycle.
Testing Starlette Applications
Starlette includes a TestClient that lets you test your application synchronously using the httpx library. This makes writing tests straightforward.
from starlette.testclient import TestClient
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.responses import JSONResponse
async def homepage(request):
return JSONResponse({"hello": "world"})
app = Starlette(routes=[Route("/", homepage)])
client = TestClient(app)
def test_homepage():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"hello": "world"}
Install httpx to use the test client:
pip install httpx
The TestClient also supports testing WebSocket connections with the websocket_connect method.
Dependency Injection and Application State
Starlette does not include a full dependency injection system like FastAPI, but it does provide an app.state object that you can use to share resources such as database connection pools across requests.
from starlette.applications import Starlette
import httpx
async def startup():
app.state.http_client = httpx.AsyncClient()
async def shutdown():
await app.state.http_client.aclose()
async def fetch_data(request):
client = request.app.state.http_client
response = await client.get("https://httpbin.org/json")
return JSONResponse(response.json())
from starlette.responses import JSONResponse
from starlette.routing import Route
routes = [Route("/data", fetch_data)]
app = Starlette(
routes=routes,
on_startup=[startup],
on_shutdown=[shutdown],
)
The on_startup and on_shutdown hooks let you manage the lifecycle of shared resources cleanly.
Best Practices
As your Starlette application grows, following a few best practices will keep it maintainable and performant:
- Keep endpoints thin. Move business logic into separate modules or service classes. Endpoints should primarily handle HTTP concerns: parsing input, calling services, and formatting responses.
- Use application state for shared resources. Database pools, HTTP clients, and caches belong on
app.state, initialized in startup hooks and cleaned up in shutdown hooks. - Prefer class-based endpoints for REST resources. Starlette supports class-based endpoints with async methods named after HTTP verbs (
get,post,put,delete). This keeps related handlers together. - Validate input explicitly. Starlette does not validate data for you. Use libraries like
pydanticto validate request bodies before processing them. - Handle errors with exception handlers. Register custom exception handlers to return consistent error responses instead of letting Starlette return default 500 responses.
- Use middleware sparingly. Every middleware adds overhead. Only include middleware you actually need, and order them thoughtfully.
- Write tests with TestClient. Test endpoints through the test client rather than calling handler functions directly, so your tests cover routing and middleware too.
- Run behind a production ASGI server. Use Uvicorn with workers or Hypercorn in production, and place a reverse proxy like Nginx in front for TLS termination and static file serving.
Here is an example of a class-based endpoint and a custom exception handler:
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.endpoints import HTTPEndpoint
from starlette.responses import JSONResponse
from starlette.requests import Request
class ItemEndpoint(HTTPEndpoint):
async def get(self, request: Request):
return JSONResponse({"items": []})
async def post(self, request: Request):
data = await request.json()
return JSONResponse({"created": data}, status_code=201)
class NotFoundError(Exception):
pass
async def not_found_handler(request: Request, exc: Exception):
return JSONResponse({"error": "Not found"}, status_code=404)
async def server_error_handler(request: Request, exc: Exception):
return JSONResponse({"error": "Internal server error"}, status_code=500)
exception_handlers = {
NotFoundError: not_found_handler,
500: server_error_handler,
}
routes = [
Route("/items", ItemEndpoint),
]
app = Starlette(routes=routes, exception_handlers=exception_handlers)
When to Use Starlette vs FastAPI
A common question is whether to use Starlette directly or FastAPI. The answer depends on your needs. FastAPI adds automatic data validation, serialization, dependency injection, and interactive API documentation on top of Starlette. If you are building a REST or GraphQL API and want those features, FastAPI is the better choice. If you want maximum control, minimal overhead, or are building something that does not fit the request-response API model โ such as a WebSocket server, a proxy, or a custom protocol handler โ Starlette alone is often the right tool.
Because FastAPI is built on Starlette, everything you learn about Starlette transfers directly. You can even mix Starlette routes and FastAPI routes in the same application.
Conclusion
Starlette is a powerful, elegant framework that gives you the essentials for building async web applications in Python without unnecessary weight. By understanding its core concepts โ routing, requests and responses, middleware, WebSockets, background tasks, application state, and testing โ you can build everything from small services to complex real-time applications. Whether you use it directly or as the foundation beneath FastAPI, mastering Starlette will make you a more effective async Python developer. Start small, keep your endpoints focused, leverage the built-in utilities, and let Starlette's composable design guide you toward clean, maintainable code.