โ† Back to DevBytes

Starlette from Scratch: Everything You Need to Know About

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:

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:

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:

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.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles