Skip to main content

Request and Response Models

If you're new to programming

Pydantic models from Ch 55 define what data the API accepts and returns. FastAPI validates input automatically. If someone sends bad data, the API rejects it with a clear error message.

If you've coded before

BaseModel for request/response schemas. Path params via {id}, query params via function defaults. response_model enforces output schema. FastAPI returns 422 with Pydantic validation errors automatically.

In Lesson 2, your routes returned hardcoded dictionaries. The health endpoint always said "healthy." The count was always 0. Now you connect routes to real data using the Pydantic models you learned in Ch 55.

The pattern: define a model for what comes IN (request body) and a model for what goes OUT (response body). FastAPI validates both directions automatically.


Response Models

Start with what the API sends back. A note response should include the title, body, word count, and tags. Define the model in smartnotes/api.py:

from pydantic import BaseModel


class NoteResponse(BaseModel):
id: int
title: str
body: str
word_count: int
tags: list[str]

This is the same BaseModel from Ch 55. The difference: instead of validating file input, you are validating API output.

Now use it in a route. For now, use a simple in-memory list as storage (the capstone will connect to your real storage module):

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app: FastAPI = FastAPI()

# Temporary in-memory storage
_notes: list[dict[str, object]] = []
_next_id: int = 1


class NoteResponse(BaseModel):
id: int
title: str
body: str
word_count: int
tags: list[str]


@app.get("/notes", response_model=list[NoteResponse])
async def list_notes() -> list[dict[str, object]]:
return _notes

Run the server and visit http://localhost:8000/notes:

Output:

[]

An empty list. That is correct because no notes exist yet. Check /docs: the route now shows the response schema. Click the Schema tab under the response to see every field, its type, and whether it is required.

The response_model=list[NoteResponse] parameter tells FastAPI two things:

  1. Validate that every item in the returned list matches NoteResponse
  2. Document the response schema in /docs

Request Models

Now define what the API accepts. A POST request to create a note needs a title and body. Tags are optional:

class NoteCreate(BaseModel):
title: str
body: str
tags: list[str] = []

Notice tags: list[str] = []. The default empty list means tags are optional. If the client sends {"title": "Test", "body": "Hello"} without tags, Pydantic fills in an empty list.

Add the POST route:

@app.post("/notes", response_model=NoteResponse, status_code=201)
async def create_note(note: NoteCreate) -> dict[str, object]:
global _next_id
new_note: dict[str, object] = {
"id": _next_id,
"title": note.title,
"body": note.body,
"word_count": len(note.body.split()),
"tags": note.tags,
}
_notes.append(new_note)
_next_id += 1
return new_note

Four things to notice:

DetailWhat it does
global _next_idTells Python to modify the module-level _next_id variable, not create a local one. Without global, assigning _next_id += 1 would create a new local variable and the module-level counter would never change. This pattern works for learning; production APIs use FastAPI's dependency injection to manage state (explored in the capstone's Try With AI section).
note: NoteCreateFastAPI reads the JSON body and validates it against NoteCreate
status_code=201Successful creation returns 201 Created (not the default 200)
response_model=NoteResponseThe response is validated against NoteResponse
Global is for learning only

The global keyword works here because this is a single-file teaching example. In real applications, shared state is managed through FastAPI's dependency injection system (Depends), which you will explore in the capstone's Try With AI section. Never use global for state management in production code.

Test it in /docs:

  1. Open http://localhost:8000/docs
  2. Expand POST /notes
  3. Click Try it out
  4. Replace the example body with: {"title": "Python Tips", "body": "FastAPI validates input automatically"}
  5. Click Execute

Response (201):

{
"id": 1,
"title": "Python Tips",
"body": "FastAPI validates input automatically",
"word_count": 4,
"tags": []
}

Now try sending invalid data. Change the body to {"title": "Test"} (missing body field) and execute again:

Response (422):

{
"detail": [
{
"type": "missing",
"loc": ["body", "body"],
"msg": "Field required",
"input": {"title": "Test"}
}
]
}

FastAPI returns a 422 with the exact validation error. You wrote zero validation code. Pydantic did it from the type annotations.


PRIMM-AI+ Practice: Test POST Behavior

Predict [AI-FREE]

Press Shift+Tab to enter Plan Mode.

Your POST route uses a NoteCreate model with title: str, body: str, and tags: list[str] = []. Before testing, predict what happens in each scenario:

  1. You send {"title": "Test", "body": "Hello"} (no tags field). What does the response look like?
  2. You send {"title": "Test"} (missing required body field). What status code do you get?
  3. You send {"title": 123, "body": "Hello"} (wrong type for title). Does FastAPI accept it or reject it?

Write down your predictions with confidence scores (1-5) for each.

Run

Press Shift+Tab to exit Plan Mode.

Open http://localhost:8000/docs, expand POST /notes, and click Try it out. Test each scenario:

  1. Send {"title": "Test", "body": "Hello"} and check the response
  2. Send {"title": "Test"} and check the error response
  3. Send {"title": 123, "body": "Hello"} and observe whether FastAPI accepts or rejects it

Compare each result to your prediction.

Investigate

Run /investigate @smartnotes/api.py in Claude Code and ask: "What is the difference between response_model=list[NoteResponse] and just using the return type annotation? When do I need response_model and when is the return type enough?"

Modify

Add a created_at field to NoteResponse that stores the current timestamp. Use from datetime import datetime and set it when creating the note in the POST route. Predict what the JSON output looks like for a datetime field before running it.

Make [Mastery Gate]

Add a GET /notes/count route that returns {"count": N} where N is the number of notes. Define a CountResponse model with a single count: int field. Use response_model=CountResponse. Verify it works by creating three notes and checking that /notes/count returns {"count": 3}.


Try With AI

Opening Claude Code

If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.

Prompt 1: Review My Models

Here are my Pydantic models for the SmartNotes API:

class NoteCreate(BaseModel):
title: str
body: str
tags: list[str] = []

class NoteResponse(BaseModel):
id: int
title: str
body: str
word_count: int
tags: list[str]

Review these models:
1. Should NoteCreate have any field validators (like
minimum title length or maximum body length)?
2. Should NoteResponse include a created_at timestamp?
3. Is there a reason to create separate models for
input and output instead of using one model for both?

What you're learning: API model design involves trade-offs between strictness and flexibility. The AI may suggest validators you had not considered (title cannot be empty, body has a maximum length). The separate input/output models question reveals a core API design principle: what the client sends is not always what the server returns.

Prompt 2: Understand Validation Errors

When I send this to my POST /notes route:

{"title": "Test"}

FastAPI returns a 422 with this error:

{"detail": [{"type": "missing", "loc": ["body", "body"],
"msg": "Field required"}]}

Explain every field in this error response. What does
"loc": ["body", "body"] mean? Why are there two "body"
entries? How would the error look if I sent
{"title": 123, "body": "text"} (wrong type for title)?

What you're learning: Understanding validation errors helps you debug API issues. The loc field shows the path to the error: the first "body" means the HTTP request body, the second "body" means the field named "body" inside that JSON. This nested location system becomes important when you have complex nested models.

Prompt 3: Separate Input and Output Models

My SmartNotes API has NoteCreate (input: title, body, tags)
and NoteResponse (output: id, title, body, word_count, tags).

The client sends 3 fields, the server returns 5.

Show me a real-world example where having separate input
and output models prevents a security bug. What could go
wrong if I used a single Note model for both directions?

What you're learning: Separate models enforce a security boundary. If you use one model for both input and output, a client could send an id field and potentially overwrite the server-assigned ID. Separate models make the contract explicit: the client controls some fields, the server controls others.


James opens /docs and sees the POST route documented with its request schema and response schema. He creates a note, then tries sending invalid data. The 422 error lists exactly which field is wrong and why. "The Pydantic models from Ch 55 are doing the validation. I did not write a single if statement for input checking."

"That is the payoff," Emma says. "Pydantic validates on the way in. response_model validates on the way out. You define the contract once and FastAPI enforces it everywhere."

She clicks the GET /notes route and sees the list. "Right now you can list all notes and create new ones. But what if someone wants to fetch a specific note? Note number 3, for instance. They need a way to put the note ID in the URL."

"Like /notes/3?"

"Exactly. That is a path parameter. And if they want to search by keyword, they need query parameters, like /notes/search?q=python. Both let you pass data through the URL, but they serve different purposes. That is the next lesson."