Intermediate

You need a lightweight REST API in Python — fast, async, and without the learning curve of a full framework. FastAPI is popular but it adds complexity you may not need for smaller services. Starlette is the ASGI toolkit that powers FastAPI, and it is remarkably capable on its own: routing, middleware, WebSockets, background tasks, and a built-in test client — all in a few hundred lines of framework code.

Install Starlette and an ASGI server with pip install starlette uvicorn. You also need pip install httpx to use the built-in test client. Everything else in this article uses Starlette’s standard library.

In this article you will learn how to define routes, handle path and query parameters, parse JSON request bodies, add middleware, use background tasks, and test your API with Starlette’s TestClient — all without FastAPI.

Starlette REST API: Quick Example

Here is the smallest complete Starlette API — one route that returns JSON:

# hello_api.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
import uvicorn

async def homepage(request):
    return JSONResponse({"message": "Hello from Starlette!", "version": "1.0"})

app = Starlette(routes=[
    Route("/", homepage),
])

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)

Run it:

python hello_api.py

Test with curl:

curl http://127.0.0.1:8000/
{"message": "Hello from Starlette!", "version": "1.0"}

Every Starlette route is an async function that receives a Request object and returns a Response. The Route object binds a URL path to a handler. The Starlette application wires it all together into an ASGI-compatible callable that uvicorn serves.

What Is Starlette and Why Use It?

Starlette is a lightweight ASGI framework — ASGI stands for Asynchronous Server Gateway Interface, Python’s async equivalent of WSGI. Where Flask uses WSGI (synchronous), Starlette uses ASGI, which means your handlers run as coroutines and can await database queries, HTTP calls, and file I/O without blocking other requests.

FrameworkInterfaceValidationSizeBest For
FlaskWSGI (sync)ManualSmallSimple sync APIs, prototypes
Django RESTWSGI/ASGISerializersLargeFull-stack Django apps
FastAPIASGI (async)Pydantic autoMediumAPIs with auto-generated docs
StarletteASGI (async)ManualTinyMinimal async APIs, library base

Choose Starlette when you want full async support with minimal overhead and maximum control. FastAPI is built directly on Starlette — understanding Starlette makes you a better FastAPI developer too.

Routes, Path Parameters, and Query Parameters

Starlette routes support path parameters using curly-brace syntax. Query parameters are read from request.query_params. Here is a mini products API demonstrating both:

# products_api.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

PRODUCTS = {
    1: {"name": "Widget A", "price": 9.99, "category": "widgets"},
    2: {"name": "Gadget B", "price": 24.99, "category": "gadgets"},
    3: {"name": "Widget C", "price": 14.99, "category": "widgets"},
}

async def list_products(request):
    category = request.query_params.get("category")
    if category:
        filtered = {k: v for k, v in PRODUCTS.items() if v["category"] == category}
        return JSONResponse({"products": filtered, "count": len(filtered)})
    return JSONResponse({"products": PRODUCTS, "count": len(PRODUCTS)})

async def get_product(request):
    product_id = int(request.path_params["product_id"])
    product = PRODUCTS.get(product_id)
    if product is None:
        return JSONResponse({"error": "Product not found"}, status_code=404)
    return JSONResponse({"id": product_id, **product})

app = Starlette(routes=[
    Route("/products", list_products),
    Route("/products/{product_id:int}", get_product),
])

Output (GET /products?category=widgets):

{"products": {"1": {...}, "3": {...}}, "count": 2}

Output (GET /products/99):

{"error": "Product not found"} (HTTP 404)

Path parameters use converters: {product_id:int} automatically converts the segment to an integer and returns HTTP 422 if the segment is not numeric. Other converters include :str (default), :float, and :path (matches slashes too).

Parsing JSON Request Bodies

POST and PUT handlers read the request body with await request.json(). This is fully async — it does not block while waiting for the client to finish sending data.

# create_product.py  (add to products_api.py)
from starlette.routing import Route, Router

next_id = 4

async def create_product(request):
    global next_id
    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"error": "Invalid JSON"}, status_code=400)

    name = body.get("name", "").strip()
    price = body.get("price")

    if not name or price is None:
        return JSONResponse({"error": "name and price are required"}, status_code=422)

    product = {"name": name, "price": float(price), "category": body.get("category", "general")}
    PRODUCTS[next_id] = product
    result = {"id": next_id, **product}
    next_id += 1
    return JSONResponse(result, status_code=201)

Test with curl:

curl -X POST http://127.0.0.1:8000/products   -H "Content-Type: application/json"   -d '{"name": "Super Gadget", "price": 49.99, "category": "gadgets"}'

{"id": 4, "name": "Super Gadget", "price": 49.99, "category": "gadgets"}

Always validate the body before using it. body.get() returns None for missing fields rather than raising KeyError. Return HTTP 422 for semantic validation errors (wrong types, missing fields) and HTTP 400 for parse errors (malformed JSON).

Adding Middleware

Middleware in Starlette wraps every request. Use it for logging, authentication headers, CORS, and timing. Here is a simple request logger:

# middleware_example.py
import time
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Route

class TimingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        start = time.perf_counter()
        response = await call_next(request)
        elapsed_ms = (time.perf_counter() - start) * 1000
        response.headers["X-Process-Time"] = f"{elapsed_ms:.2f}ms"
        print(f"{request.method} {request.url.path} -- {response.status_code} in {elapsed_ms:.2f}ms")
        return response

async def root(request):
    return JSONResponse({"ok": True})

app = Starlette(
    routes=[Route("/", root)],
    middleware=[Middleware(TimingMiddleware)],
)

Console output on each request:

GET / -- 200 in 0.42ms

Middleware receives the request before the handler runs (call_next forwards it) and gets the response before it is sent to the client. This lets you inspect and modify both. For production, use Starlette’s built-in CORSMiddleware, SessionMiddleware, and GZipMiddleware from starlette.middleware.cors etc.

Testing with TestClient

Starlette ships with a synchronous test client built on httpx. You can write standard pytest tests without starting a server:

# test_products.py
from starlette.testclient import TestClient
# assumes products_api.py with app defined
from products_api import app

client = TestClient(app)

def test_list_all_products():
    resp = client.get("/products")
    assert resp.status_code == 200
    data = resp.json()
    assert data["count"] == 3

def test_filter_by_category():
    resp = client.get("/products?category=widgets")
    assert resp.status_code == 200
    assert resp.json()["count"] == 2

def test_get_product_not_found():
    resp = client.get("/products/999")
    assert resp.status_code == 404
    assert "error" in resp.json()

Run tests:

pytest test_products.py -v

Output:

test_products.py::test_list_all_products PASSED
test_products.py::test_filter_by_category PASSED
test_products.py::test_get_product_not_found PASSED

The TestClient handles the event loop internally, so you can use it in regular synchronous test functions. For async test functions, use pytest-anyio or pytest-asyncio with AsyncClient from httpx.

Real-Life Example: Task Manager API

Here is a complete CRUD API for a task manager — list, create, update, and delete tasks — with validation, proper status codes, and a test suite.

# tasks_api.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

tasks = {}
_next_id = 1

async def list_tasks(request):
    status = request.query_params.get("status")
    items = list(tasks.values())
    if status:
        items = [t for t in items if t["status"] == status]
    return JSONResponse({"tasks": items, "count": len(items)})

async def create_task(request):
    global _next_id
    body = await request.json()
    title = (body.get("title") or "").strip()
    if not title:
        return JSONResponse({"error": "title is required"}, status_code=422)
    task = {"id": _next_id, "title": title, "status": "pending"}
    tasks[_next_id] = task
    _next_id += 1
    return JSONResponse(task, status_code=201)

async def update_task(request):
    task_id = int(request.path_params["task_id"])
    if task_id not in tasks:
        return JSONResponse({"error": "Task not found"}, status_code=404)
    body = await request.json()
    if "status" in body:
        tasks[task_id]["status"] = body["status"]
    if "title" in body:
        tasks[task_id]["title"] = body["title"].strip()
    return JSONResponse(tasks[task_id])

async def delete_task(request):
    task_id = int(request.path_params["task_id"])
    if task_id not in tasks:
        return JSONResponse({"error": "Task not found"}, status_code=404)
    deleted = tasks.pop(task_id)
    return JSONResponse({"deleted": deleted})

app = Starlette(routes=[
    Route("/tasks", list_tasks, methods=["GET"]),
    Route("/tasks", create_task, methods=["POST"]),
    Route("/tasks/{task_id:int}", update_task, methods=["PUT"]),
    Route("/tasks/{task_id:int}", delete_task, methods=["DELETE"]),
])

This covers the four REST operations in about 40 lines. The same pattern scales to database-backed APIs — replace the in-memory tasks dict with await db.fetch_all() calls using databases or SQLAlchemy async.

Frequently Asked Questions

Should I use Starlette or FastAPI?

Use FastAPI if you want automatic request validation with Pydantic, auto-generated OpenAPI documentation, and dependency injection. Use Starlette if you want the smallest possible dependency footprint, full control over request handling, or you are building a library rather than an application. FastAPI adds roughly 200 lines of code on top of Starlette, so the trade-off is clear: FastAPI saves boilerplate at the cost of one more dependency.

What is uvicorn and do I need it?

Uvicorn is the ASGI server that actually listens on the TCP port and passes requests to your Starlette app. It is not part of Starlette itself — ASGI separates the server from the framework. For production, use gunicorn -k uvicorn.workers.UvicornWorker to run multiple uvicorn workers with automatic restart on crash.

How do I add global error handling?

Pass an exception_handlers dict to Starlette(): exception_handlers={404: not_found_handler, 500: server_error_handler}. Each handler is an async function with the same signature as a route handler. You can also use HTTPException from starlette.exceptions inside route handlers and catch it globally.

Can Starlette serve static files?

Yes. Use Mount("/static", StaticFiles(directory="static"), name="static") in your routes. Mount mounts any ASGI app (including StaticFiles) at a URL prefix. This is how you serve CSS, JavaScript, and image files alongside your API routes.

Does Starlette support WebSockets?

Yes, and it is first-class. Use WebSocketRoute("/ws", ws_handler) in your routes. The handler receives a WebSocket object; call await ws.accept(), then await ws.send_text() and await ws.receive_text() in a loop. Background tasks are also built in via BackgroundTask from starlette.background.

Conclusion

Starlette gives you a complete async web toolkit in under 1,000 lines of framework code. You covered routing with path and query parameters, async JSON body parsing, middleware for logging and timing, and the TestClient for fast integration tests. The task manager API ties it all together in a realistic CRUD design.

Extend the task manager by adding a databases connection (async PostgreSQL or SQLite) and replacing the in-memory dict — the route handlers do not need to change, only the data access layer. That separation is Starlette’s design philosophy: give you the primitives, let you build the rest.

For the full API reference, see the Starlette documentation.