July 6, 2026
Learning Python - Day 6-8: FastAPI, Pydantic, and the Bugs That Taught Me Both
By the end of day 5 I had a CLI task manager that read and wrote to a JSON file by hand, and a mental note that FastAPI was next. Days 6 through 8 turned out to be the point where that hand-built task manager stopped being a standalone exercise and started being the thing I kept mentally translating into “how would this work as an actual API.” Coming from years of Express and more recently Hono, I expected FastAPI to feel like a familiar shape with different syntax. Some of it was exactly that. Some of it, especially the parts involving Pydantic and async, worked on assumptions I didn’t have from JS at all, and I only really understood the difference after breaking things.
Day 6: Uvicorn is not hiding, it’s just visible
The first thing that felt unfamiliar wasn’t FastAPI itself, it was that FastAPI and its server are two separate packages you install and run independently.
pip install fastapi "uvicorn[standard]"
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello, FastAPI"}
uvicorn main:app --reload
In Express, the HTTP server is baked into the runtime and mostly invisible. You call app.listen(3000) and never think about what’s actually accepting the TCP connections underneath. In Python, FastAPI is the framework that defines routes and handles request/response logic, and Uvicorn is the ASGI server that actually runs it, and the two are wired together explicitly on the command line rather than inside the code. It took me a minute to realize --reload was just my nodemon, and that /docs was a fully interactive Swagger UI I got for free, generated from the route definitions I’d already written, with zero extra setup.
// Express - Node's HTTP server is implicit, invisible
const app = express();
app.listen(3000);
// FastAPI - you explicitly wire Uvicorn (server) to app (framework)
// uvicorn main:app --reload
Path parameters that actually enforce themselves
The next thing I hadn’t expected was how much weight a type hint carries in a route signature.
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
return {"task_id": task_id}
In Express, req.params.taskId is always a string, no matter what the URL looks like, and validating it is on you.
// Express - task_id is always a string, you validate manually
app.get("/tasks/:taskId", (req, res) => {
const taskId = parseInt(req.params.taskId);
if (isNaN(taskId)) {
return res.status(422).json({ error: "must be an integer" });
}
});
In FastAPI, task_id: int is the validation. Hit /tasks/abc and you get back a clean, structured 422 before your function body has run at all. No parseInt, no isNaN check, no manually shaped error response. The type hint isn’t just documentation for my editor, it’s an instruction FastAPI actually enforces at runtime.
Query parameters and a route-ordering bug I caused myself
Any parameter that isn’t part of the path string automatically becomes a query parameter, and whether it’s optional depends entirely on whether it has a default value.
@app.get("/tasks")
def list_tasks(completed: bool | None = None, limit: int = 10):
return {"completed_filter": completed, "limit": limit}
limit: int = 10 is optional and defaults to 10. completed: bool | None = None is optional and defaults to None, which was my first real exposure to Python’s union type syntax. A parameter with no default at all is required, and skipping it in the request gives you a 422 automatically, again before the function runs. FastAPI also coerces ?completed=true from a query string into an actual Python True, which I hadn’t expected to just work.
Where I actually got bitten was route ordering. I added a literal route after a parameterized one instead of before it:
# WRONG order - /tasks/recent hits the parameterized route first
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
return {"task_id": task_id}
@app.get("/tasks/recent")
def get_recent_tasks():
return {"message": "recent tasks"}
Every request to /tasks/recent was getting caught by /tasks/{task_id} first, FastAPI tried to coerce "recent" into an int, and I got a 422 instead of my actual route ever running. Moving the literal route above the parameterized one fixed it immediately, and it was a good reminder that FastAPI matches routes top to bottom the same way Express does, I’d just never hit the specific case where it mattered before.
Day 7: Pydantic is Zod, but it validates itself
Pydantic was the concept that took the most rewiring, mostly because it looks so much like something I already knew.
from pydantic import BaseModel
class Task(BaseModel):
description: str
completed: bool = False
My instinct was to treat this exactly like Zod, where you define a schema object and then explicitly call .parse() on incoming data.
// Zod - schema-first, type derived separately
const TaskSchema = z.object({
description: z.string(),
completed: z.boolean().default(false)
})
type Task = z.infer<typeof TaskSchema>
Pydantic collapses that into one step. The class itself is both the schema and the type, there’s no separate z.infer step, and validation happens automatically the moment you construct an instance, Task(...), rather than through an explicit parse call. It coerces sensible values, "true" becomes True, but rejects ambiguous ones outright, and a missing required field raises a ValidationError immediately with a precise, structured message telling you exactly which field failed and why.
Turning that into a request body, and separating input from output
Once a Pydantic model exists, using it as a request body in FastAPI is just a type hint on the route function, no req.json(), no manual parsing.
class TaskBase(BaseModel):
description: str
completed: bool = False
class TaskCreate(TaskBase):
pass # client sends this, no id
class Task(TaskBase):
id: int # server returns this, includes id
@app.post("/tasks")
def create_task(task: TaskCreate) -> Task:
return Task(id=999, description=task.description, completed=task.completed)
In Hono, the body coming off c.req.json() is untyped by default, and any validation is something you bring in yourself.
// Hono - body is untyped, validation is your responsibility
app.post("/tasks", async (c) => {
const body = await c.req.json(); // any, no guarantee of shape
// manually validate or bring in Zod yourself
});
The part that actually changed how I think about API design was splitting TaskBase into a TaskCreate for what the client sends and a Task for what the server returns. The client never sends an id, the server assigns it, and having two distinct shapes instead of one shared one made that boundary explicit instead of something I’d have enforced by convention and probably forgotten under pressure.
The other thing I learned the hard way is that FastAPI checks your return type annotation just as strictly as it checks the input. I wrote this:
# This causes a 500 - FastAPI validates your output too, not just input
@app.post("/tasks")
def create_task(task: TaskCreate) -> Task:
return {"received": task} # wrong shape, 500
and got a 500 instead of the 201 I expected, because {"received": task} doesn’t match the Task shape I’d promised in the return annotation. It was a good lesson that the -> Task isn’t just documentation either, it’s an enforced contract, and FastAPI will fail loudly rather than silently letting a mismatched shape through.
Day 8: raise, not return, and the async decision that actually matters
The bug that finally made HTTPException click
HTTPException is how you deliberately produce an error response in FastAPI, and it has to be raised, not returned. I found this out by writing the wrong version first.
# WRONG - produces a 200 with the exception object as JSON body
return HTTPException(status_code=404, detail="Task not found")
Instead of a 404, I got back a 200 with the HTTPException object itself serialized into the response body as if it were valid task data. Nothing crashed, nothing warned me, it just quietly did the wrong thing, which is exactly the kind of bug that’s hardest to notice until you’re staring at a response body that looks obviously broken. The fix was one word:
from fastapi import FastAPI, HTTPException, status
@app.get("/tasks/{task_id}")
def get_task(task_id: int) -> Task:
if task_id not in fake_tasks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found"
)
return Task(id=task_id, description=fake_tasks[task_id])
raise instead of return. Once I saw the broken version next to the fixed one, the distinction stopped being abstract, an exception is something you throw to interrupt the normal flow, not a value you hand back like any other return.
That day also settled the status code vocabulary I’d been fuzzy on:
| Code | Meaning | When |
|---|---|---|
| 200 | OK | Default success |
| 201 | Created | Successful POST creating a resource |
| 400 | Bad Request | Malformed request outside Pydantic’s scope |
| 401 | Unauthorized | Missing or invalid auth |
| 403 | Forbidden | Authenticated but not allowed |
| 404 | Not Found | Resource doesn’t exist |
| 422 | Unprocessable Entity | Pydantic validation failure, automatic |
| 500 | Internal Server Error | Unexpected bug, never raise this one manually |
def vs async def, and why it’s a real decision in Python and not just style
This was the concept that took the longest to actually land, because JS trained me to think of async as uniformly good, every Promise-based function in JS is compatible with every other one. Python doesn’t work that way. Libraries split into async-native and blocking, and mixing them wrong has real consequences, not just style implications.
# Plain def - FastAPI runs this in a thread pool
# Safe for sync/blocking work (sync DB drivers, CPU work)
@app.get("/tasks")
def list_tasks():
return fake_tasks
# async def - runs on the event loop directly
# Only correct if everything inside is genuinely awaited
@app.get("/tasks")
async def list_tasks():
result = await some_async_db_call()
return result
The trap is writing async def around something that blocks anyway:
# DANGEROUS - async def + blocking call freezes the entire event loop
# Every other request has to wait until this finishes
@app.get("/tasks")
async def list_tasks():
time.sleep(2) # blocks, no await, no thread pool, just frozen
return fake_tasks
Because there’s no await inside it, that time.sleep(2) doesn’t hand control back to the event loop the way an actual async operation would. It just blocks the entire event loop for two seconds, which means every other request being handled by that server freezes too, not just this one. Plain def routes don’t have this problem, because FastAPI automatically runs them in a thread pool, which is a safe default for anything synchronous or blocking.
I got tested on this with four scenarios before the session ended, and got all four right, though the third one took real thought rather than pattern matching:
| What’s inside your route | Use |
|---|---|
| In-memory work, no I/O | def |
| Sync DB driver (standard SQLAlchemy) | def |
Async DB driver (async SQLAlchemy, databases) | async def |
Async HTTP client (httpx.AsyncClient) | async def |
| CPU-bound computation | def (multiprocessing for genuinely heavy work) |
The one that actually required reasoning instead of a rule of thumb was the sync database driver. My first instinct was “it’s I/O, so it should be async,” but a sync driver has no await points at all, there’s nothing to hand back to the event loop, so wrapping it in async def would only recreate the exact freeze from the time.sleep example. Plain def routes it correctly into the thread pool instead, where it can block safely without taking every other request down with it. The one-line version I keep coming back to is that async def only pays off when you’re actually awaiting something, and CPU-bound work doesn’t benefit from async at all, in Python or in JS, because async is about not wasting time waiting on something external, not about making computation itself faster.
What’s next
Day 9 is SQLite and SQLAlchemy, replacing the in-memory fake_tasks dict with an actual database file, which means the def vs async def decision from day 8 stops being a quiz question and becomes something I actually have to choose and live with, depending on whether I reach for the sync or async flavor of SQLAlchemy.