Request bodies: a pydantic model instead of a hand-checked dict#

← Back to FastAPI

Compare this to Week 2's create_note#

solutions/notes_api.py reads the body by hand:

body = request.get_json()
next_id = max((note["id"] for note in notes), default=0) + 1
new_note = {"id": next_id, "text": body.get("text")}

body.get("text") never checks that "text" was actually sent; the solution file's own comment admits it: a request missing "text" entirely just produces a note with text: None, and "a more thorough app might validate this and return a 400 Bad Request instead." That validation is now the text: str hint on NoteIn. Nothing else changed, next_id-style logic still lives in your own code, but the one check nobody wrote by hand in Week 2 is now automatic, and it happens before create_note is even called.

What automatic validation doesn't cover#

Not every error is a shape problem. GET /notes/999 for an id that doesn't exist is a perfectly well-formed request, an integer where an integer is expected, so there's nothing for FastAPI to reject automatically; whether that id is real is a lookup your own code still has to do, the same job find_note did in Week 2. The equivalent of Flask's return jsonify({"error": ...}), 404 is raising fastapi.HTTPException:

from fastapi import HTTPException

@app.get("/notes/{note_id}")
def get_note(note_id: int):
    note = find_note(note_id)
    if note is None:
        raise HTTPException(status_code=404, detail=f"No note with id {note_id}")
    return note

(note_id: int here is a path parameter, the next note covers exactly how those work.) The distinction matters: pydantic validation is for "is this data shaped the way I said it would be," and HTTPException is for everything else your own logic decides counts as an error, 404, or any other status you choose to raise on purpose.

What to watch for#

  • Defining a model but forgetting to use it as the parameter's type hint. The class definition alone does nothing; the type hint on the function parameter is the actual mechanism that triggers parsing and validation.
  • Assuming validation replaces all error handling. It replaces shape checking specifically. "Is this id real" is still on you, every time.
  • Reaching for try/except json.JSONDecodeError out of habit. There's no manual JSON parsing step left to wrap; FastAPI does it before your function is ever called, and a malformed body never reaches your try block at all.

TL;DR#

A FastAPI path operation can take a pydantic model as a parameter, and that one type hint replaces Flask's request.get_json() plus every manual check you'd write after it.

  • Define the request body's shape as a pydantic BaseModel, exactly the way ../../project/README.md's support ticket model did: one type-hinted attribute per field.
  • Add that model as a type-hinted parameter on a path operation function. FastAPI reads the hint, parses the incoming JSON body, and validates it against the model before your function body runs at all.
  • A request that doesn't match (a missing field, a pages that's a string instead of an int) never reaches your code. FastAPI returns 422 Unprocessable Entity automatically, with a JSON body naming exactly which field failed and why.
  • Inside the function, the parameter is already a real instance of your model: note.text, not request.get_json().get("text").
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class NoteIn(BaseModel):
    text: str


@app.post("/notes")
def create_note(note: NoteIn):
    new_note = {"id": next_id(), "text": note.text}
    notes.append(new_note)
    return new_note