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:

CodeMeaningWhen
200OKDefault success
201CreatedSuccessful POST creating a resource
400Bad RequestMalformed request outside Pydantic’s scope
401UnauthorizedMissing or invalid auth
403ForbiddenAuthenticated but not allowed
404Not FoundResource doesn’t exist
422Unprocessable EntityPydantic validation failure, automatic
500Internal Server ErrorUnexpected 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 routeUse
In-memory work, no I/Odef
Sync DB driver (standard SQLAlchemy)def
Async DB driver (async SQLAlchemy, databases)async def
Async HTTP client (httpx.AsyncClient)async def
CPU-bound computationdef (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.