The last post in the series. I rebuilt my Express reflexes in FastAPI, found the type hints doing the validation, the docs, and the serializing, then made it stream.
The type hints are the middleware
FastAPI, from an Express developer's point of view
This is the last post of my TypeScript-to-Python journey, and it's the one where everything from the first four finally does something. The tooling (uv), the schemas (Pydantic), the comprehensions, the async runtime, all of it collapses into a single file: a validated LLM chat endpoint that streams tokens back over SSE. That's the backend of every chat UI you have ever used.
FastAPI is the framework named in most GenAI engineer postings, so learning it was never optional. What I didn't expect was how much of my Express muscle memory it made redundant.
In Express, a validated endpoint is an assembly job. Here's the version I've written more times than I'd like to count:
Four moving parts for one endpoint: the schema, the middleware, the wiring, the hand-rolled error shape. And note the last line, because it's the part that actually bothers me. That generic is a claim, not a check. The middleware validates ChatRequest at runtime, the annotation says ChatBody at compile time, and nothing forces those two to be the same schema. Point validate() at a different one and TypeScript stays perfectly happy while the runtime shape changes underneath the handler. (Leave the generic off entirely and req.body is just any, silently.) Then I hand-write an OpenAPI YAML for Swagger, which is a third statement of the same shape that nothing checks either, and it starts drifting the week after I set it up.
To be fair to the Node ecosystem, nobody has to hand-write that YAML anymore. zod-to-openapi will generate it, ts-rest will derive the contract for you, Fastify has a type provider that does much the same job. I've used them and they work. But look at what solving it takes: you pick a library, you wire it in, and you keep it in agreement with the other two. My argument isn't that Express can't get here. It's that getting here is an assembly decision you make, and in FastAPI it's just what a function signature already means.
FastAPI's bet is that the function signature already carries every piece of that information:
That's the whole endpoint. There is no validation code, because the annotation req: ChatRequest is the validation. Send POST {"message": 42} and this comes back before your handler runs, which also means before you pay for an LLM call on garbage input:
422, not 400, and that tripped me up on day one. FastAPI reserves 400 for "your request was structurally fine but I'm rejecting it" and uses 422 for "this doesn't match the schema". The useful part is loc: a path into the payload, so ["body", "message"] points a client straight at the offending field without anyone writing error-formatting code. Nested models give you ["body", "history", 2, "role"]. My hand-rolled Express error shapes were never that good.
Three things caught me off guard.
In Express I reach into req.params, req.body, req.query, req.headers myself and coerce every value by hand. @types/express does read the route string, so /conversations/:convId gives me a typed req.params.convId, but it's a string, and it will always be a string. In FastAPI you declare what you want and the framework works out where it lives:
FastAPI makes two decisions about each parameter. The first is where to read it from, and the rules are short enough to memorise:
The second decision is what type to hand you, and this is the part I'd underrated. A URL only carries text. 12 arrives as the characters "12", and the int annotation is what turns it into a number. Send /conversations/abc/messages/search and you get a 422 instead, before the function runs. Compare that to Express, where req.params.convId is the string "abc" and stays that way until I remember to parseInt it and check the result for NaN. I have shipped that missing check more than once.
response_model validates on the way out tooThis is the one I wish I'd had years ago:
The handler hands back the whole database row and FastAPI trims it to UserOut on the way out.
In Express, the only thing keeping that hashed_password off the wire is me remembering to pick fields by hand every single time I write a res.json(). I have forgotten. It made it to production. Nobody noticed for a while, which is the worrying part, because a leak like that doesn't break anything. No test fails, no error rate moves. It just quietly serves internal fields to anyone who asks until someone happens to read a response body.
That's the difference I'd point at if I only got one. Express asks me to remember something on every single response. FastAPI asks me to declare it once, and then a forgotten field is a field that was never allowed out in the first place.
There is a catch, though, and it cost me an afternoon. The trimming is silent in the other direction too.
If your frontend needs a field and you forget to declare it on UserOut, the field simply isn't in the response. Nothing tells you. No warning in the logs, no error, no hint in /docs, because as far as FastAPI is concerned the model is the spec and it did exactly what you asked. The handler looks correct, because it is correct. The data is right there in the object it returned.
So when a field mysteriously stops arriving on the client, check the response model before you touch the handler. I now read the two models side by side whenever I add a field, which sounds tedious and takes about four seconds.
/docs exists the moment you start the serverYou get a UI with a "Try it out" button that hits your actual running API, built from the same annotations doing the validation. Nobody writes it and nobody maintains it. Add a field to ChatRequest, reload, it's in the docs, along with the 422 shape from earlier. That last bit is what I keep coming back to. My Express docs were a separate file describing what the code was supposed to do, so they were only ever as accurate as the last person who remembered to update them.

That's my actual app, and I wrote none of it. ChatRequest and ChatResponse are there because I annotated the handler with them. HTTPValidationError and ValidationError I didn't even define, they're the 422 shape from earlier, documented for free.
A /chat endpoint that returns one JSON blob after three seconds of silence is a firing offense in a real chat product. Users need tokens on screen while the model is still thinking.
The mechanism is Server-Sent Events, which sounded like a big deal until I saw what actually goes over the wire. It's an ordinary HTTP response. The only trick is that the server sets Content-Type: text/event-stream and then just doesn't finish sending it:
That's the entire format. A line saying what kind of event it is, a line with the payload, a blank line to mark the end of one event. The connection stays open and the server keeps appending as it has more to say.
Notice what's missing: there's no way for the client to send anything back. I assumed I'd need WebSockets for streaming and I was wrong, because a chat reply only ever travels one direction. The user's message is a normal POST. The tokens come back on the stream. Nothing needs a duplex connection, so I don't have to run one.
The part I loved is that my API becomes a relay. The LLM streams SSE to my server (that's what the Anthropic SDK consumes under the hood), and my server forwards each delta to the client as its own SSE event. Two streams running concurrently, and my handler is just an async pipe between them:
The shape of the handler changed. It does not return, it yields, and FastAPI turns each yielded event into the wire format from above. It also covers the chores I'd have wired by hand in Express: the Content-Type, a keep-alive ping every fifteen seconds so proxies don't time the connection out, and the X-Accel-Buffering: no header that stops Nginx from holding your tokens hostage. Same deal as the first half of this post. The framework does the plumbing, I write the logic. And the Pydantic validation still guards the door, so {"message": 42} gets its 422 before a single token is streamed or paid for.
One version note, because most SSE tutorials you'll find predate this. fastapi.sse landed in FastAPI 0.135.0. Before that everyone reached for the sse-starlette package and returned EventSourceResponse(gen()) explicitly, and if you're on an older version that's still the way to do it. The library people kept installing ended up inside the framework, which by now you'll recognise as the pattern.
One hour-saving debugging note, because it got me. My first curl test "didn't stream", everything arrived in one burst at the end. The server was fine. curl buffers its own output by default, and curl -N fixes it. That generalizes: when streaming looks broken, the buffering can be at the server, at a proxy, or at the client, so check each hop before you touch your code. (This is the same buffering ghost from the async post, just one layer further out.)
FastAPI never felt like learning a new framework. It felt like someone had taken the pile of tools I'd been bolting together in Express for years and folded the whole thing into the function signature.
I keep thinking about how much of my Express work was just keeping things in agreement. The zod schema had to match the TypeScript type, which had to match the OpenAPI file, which had to match what the handler actually returned. Nothing checked any of it. It only broke when someone changed one of the four and forgot the other three, which was usually a Friday. Writing req: ChatRequest once and getting the validation, the docs, and the response filtering out of it is not a productivity trick. It's just removing three chances to be wrong.
I should be clear about the streaming half, because it's easy to oversell. It makes nothing faster. The model takes exactly as long to finish either way, and the total bytes are identical. What changes is when the user sees the first one. Waiting twenty seconds at a blank screen feels like the app is broken, and watching text appear from second one feels like it's working, and that gap is entirely perception. It's still the difference between an app people use and one they close.
If I had one thing to tell a TypeScript developer thinking about the same jump, it's about where the time actually goes. The syntax took a few days. Indentation, colons, def instead of function, none of that survives contact with a real project. Five weeks ago I needed a 1:1 map from npm to uv just to install things, and that stopped being true almost immediately.
What took weeks was everything underneath. Learning that Python's event loop isn't ambient the way Node's is, and that nothing runs until I say so. Reaching for .map() and slowly training myself to write a comprehension instead. Working out what Pydantic actually does at the moment it validates, and when async buys me nothing at all. Plan for that ratio, because the part that looks unfamiliar is the easy part.
Next on my list is tool use, where the model calls real functions instead of just producing text, and these endpoints stop talking and start doing something.
You start it
async/await, but the event loop is not ambient anymore
async/await transfers almost 1:1 from JS, but Python's event loop is not ambient. Two gotchas from wiring up token-by-token streaming.
Stop writing JavaScript in Python
Comprehensions, a reversed join(), and KeyError
List comprehensions, a reversed join(), and KeyError instead of undefined. Small syntax, but the idioms took real unlearning.
Zod taught me Pydantic
Define the schema, parse at the boundary. Same mental model, new language.
If you have used Zod, you already understand 70 to 80% of Pydantic. The mental model transfers almost one-to-one.