Starlette vs FastAPI: what FastAPI actually adds
FastAPI has become one of the most popular web frameworks among Python developers. It's so popular that it often overshadows the technologies it's built on. FastAPI is built on Starlette and Pydantic. Starlette handles the HTTP layer (routing, middleware, WebSockets, the ASGI plumbing) and Pydantic handles data validation. FastAPI is the layer on top that ties them together with type-driven parameter parsing, dependency injection, and automatic OpenAPI documentation.
That overshadowing is why the Starlette vs FastAPI choice is often framed as choosing between direct competitors, as if you have to pick sides. You don't. The real question is what FastAPI adds and when you might skip it. In this article, I'll walk through the parts of a FastAPI application that come straight from Starlette, the parts FastAPI adds on top, and a few cases where dropping down to raw Starlette is the better call.
What Starlette and FastAPI actually are
Starlette is a lightweight ASGI toolkit. ASGI is the asynchronous successor to WSGI, and it's the spec that lets Python web servers like Uvicorn and Hypercorn talk to async applications. Starlette consists of a routing system, request and response objects, a middleware pipeline, WebSocket support, background tasks, sessions, a test client, and a small set of built-in middlewares for things like CORS and GZip. That's everything you need to build an async web service against the spec, small and unopinionated by design.
Pydantic is the other half of the foundation. It's a general-purpose data validation library: you declare a class with typed fields, and Pydantic handles parsing, validation, serialization, and JSON Schema generation for free. FastAPI happens to use it. So does anything else that needs typed data shapes.
FastAPI is a thin layer that hooks into Starlette and Pydantic through your function signatures; when you write def create_user(user: User), FastAPI sees the Pydantic model in the type hint and wires up Starlette's request body parsing to it. That one mechanism is the foundation of everything FastAPI does: parameter parsing, validation, and OpenAPI schema generation are all derivatives of reading your type hints. The docs and dependency injection are conveniences built on top.

The diagram above is the mental model worth keeping. Whenever you write a FastAPI app, you're really writing code that sits on FastAPI, which sits on Starlette and Pydantic, which talk to an ASGI server underneath.
Starlette vs FastAPI: core differences
The two frameworks make fundamentally different tradeoffs. Starlette gives you low-level HTTP building blocks: routing, middleware, and WebSockets, without prescribing structure. FastAPI adds a declarative layer on top: automatic validation, serialization, interactive docs, and dependency injection, all driven by standard Python type hints.
With Starlette, you wire up validation and injection yourself or skip them. With FastAPI, you declare what you need in the function signature and get validation and failure responses for free.
Both share the same async runtime. The difference is how much boilerplate you need to write.

Performance and async capabilities
FastAPI gets its async def support from Starlette; there's no separate event loop or async runtime. A FastAPI async def endpoint uses Starlette's ASGI integration the same way a raw Starlette endpoint does.
A Starlette async def endpoint looks like this:
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)])
A FastAPI async def endpoint looks like this:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def homepage():
return {"hello": "world"}
Both run on the same ASGI app and event loop. The async I/O behavior is identical. FastAPI's decorator adds route registration, serialization, and OpenAPI schema generation. Starlette gives you the route and response object.
However, FastAPI handles sync functions differently. Declare a path operation with def instead of async def, and FastAPI runs it in a threadpool—so a slow call doesn't stall the event loop. Starlette does the same, but FastAPI extends this to dependencies too, which matters once you use its injection system.
The threadpool isn't infinite. Both frameworks use AnyIO, which by default caps the pool at 40 worker threads. A few slow sync handlers under load can saturate it, causing the threadpool to queue requests. Mixing def and async def without considering the thread pool is a common reason a FastAPI app feels fast in testing but slow under load. The fix is usually to push the slow work into a background worker, not to bump the threadpool limit.

Middleware: same stack, same tools
Middleware is one of the clearest examples of FastAPI sitting on Starlette without modification. The middleware pipeline, the base classes, and the bundled middlewares all come from Starlette. FastAPI just re-exports them.
A custom middleware in Starlette looks like this:
from starlette.middleware.base import BaseHTTPMiddleware
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
import time
start = time.perf_counter()
response = await call_next(request)
response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.4f}"
return response
The same class drops straight into a FastAPI app:
from fastapi import FastAPI
app = FastAPI()
app.add_middleware(TimingMiddleware)
This works because FastAPI implements the ASGI spec through Starlette, so any ASGI middleware works in either framework. The built-in CORSMiddleware, GZipMiddleware, TrustedHostMiddleware, and SessionMiddleware you reach for in a FastAPI app all live in the starlette.middleware package.
Two Starlette quirks apply unchanged in FastAPI. First, middleware is registered in LIFO order, so the last add_middleware call runs first on the way in and last on the way out.
Second, BaseHTTPMiddleware doesn't propagate contextvars changes to the rest of the request. If you're doing distributed tracing or request-scoped logging, write a raw ASGI middleware instead.
For production, you need middleware that catches errors. A raw 500 doesn't tell you much. A thin wrapper is the cleanest place to capture the stack trace, URL, and request context before sending the response. Here's an example that reports unhandled errors to Honeybadger, which notifies your team immediately:
from starlette.middleware.base import BaseHTTPMiddleware
from honeybadger import honeybadger
class HoneybadgerMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
try:
return await call_next(request)
except Exception as exc:
honeybadger.notify(exc, context={
"path": request.url.path,
"method": request.method,
})
raise
app.add_middleware(HoneybadgerMiddleware)
Because the middleware contract is Starlette's, the same code works in both frameworks without modification. If you ever migrate a service from FastAPI to bare Starlette (or vice versa), the middleware remains unchanged.
If you're using the Honeybadger Python SDK, you don't need to write this yourself. The SDK ships with a built-in Starlette middleware that catches exceptions, attaches request context, and sends everything to Honeybadger.
WebSocket support
WebSockets are another part of FastAPI that's almost entirely Starlette underneath. The WebSocket object, the connection lifecycle (accept, receive_text, send_json, close), and the WebSocketDisconnect exception all come from starlette.websockets.
FastAPI re-exports them and adds the same dependency injection and parameter parsing it adds to HTTP routes.
A FastAPI WebSocket endpoint:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
try:
async for message in websocket.iter_text():
await websocket.send_text(f"Echo: {message}")
except WebSocketDisconnect:
pass
Apart from the decorator, every line is Starlette code. The Starlette version uses WebSocketRoute instead.
What FastAPI adds is dependency injection: you can pull a database session, authenticated user, or query parameter into a WebSocket handler the same way you would in an HTTP route.
If your service is mostly WebSocket-driven and you don't need OpenAPI docs for HTTP routes, a Starlette application gets you the same capabilities with less to install.

Data validation and serialization
This is where the two frameworks feel different. Starlette doesn't know anything about Pydantic. You can absolutely use them together, but you do the wiring by hand. FastAPI's defining feature is that it handles the wiring for you, using your function signature and standard Python type hints.
Here's the same "create a user" endpoint written both ways. With Starlette and Pydantic, you're responsible for parsing the request data, calling Pydantic, and converting validation errors into the right failure response for incoming requests:
from pydantic import BaseModel, ValidationError
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
class User(BaseModel):
name: str
email: str
age: int
async def create_user(request):
payload = await request.json()
try:
user = User(**payload)
except ValidationError as exc:
return JSONResponse({"errors": exc.errors()}, status_code=422)
return JSONResponse(user.model_dump(), status_code=201)
app = Starlette(routes=[Route("/users", create_user, methods=["POST"])])
The FastAPI version is shorter because the framework infers all of the above from the type hint:
from fastapi import FastAPI
from pydantic import BaseModel
class User(BaseModel):
name: str
email: str
age: int
app = FastAPI()
@app.post("/users", status_code=201)
async def create_user(user: User):
return user
FastAPI sees the User parameter, parses the incoming JSON data, validates it against the Pydantic data model, returns a 422 response with structured error details if validation fails, and serializes the return value back to JSON.
It works in reverse, too. With a response_model, FastAPI validates and filters your return value before sending it, preventing database columns from leaking to API clients without requiring a custom serialization layer. Starlette leaves output filtering to you.
Both frameworks handle nested Pydantic models. The difference is who does the wiring.
That's "FastAPI built on Starlette and Pydantic" in practice. Starlette provides the HTTP objects, Pydantic provides validation, and FastAPI connects your function signature to both.
The cost is mostly conceptual: more magic between the request and your function. For most APIs, that's a good trade. When you need full control over request parsing, Starlette is cleaner.

Automatic documentation generation
For many teams, this is the one feature that settles it. Starlette doesn't generate API docs. You can wire up swagger-ui or apispec yourself, but nothing is built in.
FastAPI generates an OpenAPI 3.1 schema automatically from your path operations and Pydantic models, and serves two interactive doc UIs out of the box:
- Swagger UI at
/docs - ReDoc at
/redoc
It helps to be clear about who does what. Pydantic generates a JSON Schema for each data model. FastAPI assembles those schemas into a full OpenAPI document per the OpenAPI standard, along with path info from your decorators (URLs, methods, status codes, tags), and serves the result via Swagger UI and ReDoc. Without Pydantic, FastAPI would have no schemas to embed. Without FastAPI, you'd have schemas but nothing tying them to URLs.
The schema includes parameter types, request and response data models, validation constraints, status codes, and any descriptions you add. There's no separate file to maintain and no annotations to keep in sync. Because the schema is derived from the same type hints that drive validation, it can't drift out of date with your endpoints.
For internal services, this turns the API itself into the docs. For public APIs, the OpenAPI schema can be used to generate client code, contract testing tools, and API gateways. Replicating this automatic documentation in a Starlette application is doable but noticeable work.

When to use Starlette directly
Most projects are well served by FastAPI. I'd reach for Starlette directly only where the FastAPI layer would mostly sit unused.
- Webhook receivers and lightweight proxies. If your service does little more than accept a payload, validate a signature, and forward it somewhere, the OpenAPI schema and Pydantic-driven validation aren't earning their keep. A Starlette application with a couple of routes is smaller, starts faster, and has fewer moving parts.
- WebSocket-heavy services. If your application is mostly WebSocket connections with little or no REST surface, FastAPI's HTTP-focused additions don't apply to most of your code. Starlette's WebSocket primitives are exactly what FastAPI uses anyway.
- Custom request handling. If you need to stream a request body, parse a non-standard content type, or short-circuit before the body is fully read, FastAPI's parameter parsing can fight you. Starlette's lower-level request object gives you direct control.
- Maximum minimalism. Smaller dependency footprint, fewer abstractions, faster cold starts. This can make a real difference for serverless functions or container images you ship frequently.
For everything else — typical CRUD APIs, internal services, anything where automatic docs and validation pull their weight — FastAPI's layer is worth the trade.
How the layers fit together
Here's where each layer takes over during a typical request:

Uvicorn receives bytes off the wire and translates them into ASGI events. Starlette runs the middleware stack and matches the route. FastAPI's layer parses data from the request, validates it with Pydantic, calls your function, and serializes the return data. Then control hands back to Starlette to send the response, and to Uvicorn to write it to the socket.
Almost every line of that flow is Starlette's, with FastAPI bracketed in the middle to handle the type-driven work.
Starlette vs FastAPI: where the layers end
"FastAPI built on Starlette and Pydantic" is more than a tagline. It's an architectural decision that shows up everywhere in the Starlette vs FastAPI stack. The async support, middleware, WebSockets, routing, and request and response objects all come from Starlette unchanged. Pydantic does the validation. FastAPI wires your function signatures to both, plus dependency injection and OpenAPI on top.
Once you know where the seams are, a lot of things get easier: debugging middleware, picking a framework for a small service, reading stack traces, and understanding what's running underneath your code.
Whichever side of the Starlette vs FastAPI stack you end up on, you'll still need to know when things break in production. Honeybadger catches unhandled exceptions in your FastAPI or Starlette app, groups duplicates, and notifies you with the request context attached. Sign up for a free developer account and start monitoring your apps.
Written by
Farhan Hasin ChowdhurySoftware developer with a knack for learning new things and writing about them.