Chapter 3 — From Reading Data to Creating Data: Request Bodies & Pydantic Models

Welcome to Chapter 3

In the previous chapter, our Book Catalog API learned how to receive information through URLs.

We could do things like:

GET /books/2

or:

GET /books?category=software&limit=10

We learned:

  • routes
  • path parameters
  • query parameters
  • optional parameters
  • default values
  • filtering
  • pagination
  • basic API design

But there is a major limitation.

Our application can read books.

It cannot create one.

Imagine that a user opens a bookstore application and fills out a form:

Title: FastAPI in Practice
Author: Jane Developer
Category: Software
Year: 2026
Price: 49.99

The frontend needs to send that information to our API.

How?

The client might send:

POST /books

with a request body:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99
}

Now we have a completely new concept:

Request Body

And this chapter introduces one of the most important concepts in FastAPI development:

Pydantic Models

FastAPI uses Pydantic models to declare structured request bodies and automatically perform parsing, validation, editor support, JSON Schema generation, and OpenAPI documentation.

By the end of this chapter, our Book Catalog API will be able to:

CREATE
READ

That means we’re moving from:

"API that displays data"

toward:

"API that manages data"

And we’re only getting started.


1. What You Will Learn

By the end of this chapter, you will understand:

  • request bodies
  • JSON request data
  • POST requests
  • Pydantic
  • BaseModel
  • model fields
  • required fields
  • optional fields
  • default values
  • nested data
  • request validation
  • automatic validation errors
  • combining path parameters and request bodies
  • combining query parameters and request bodies
  • Field
  • field constraints
  • response models
  • response_model
  • HTTP 201 Created
  • status
  • model serialization
  • model_dump()
  • input vs output models
  • why request and response models should eventually be separated

Most importantly, you will build:

📚 Book Catalog API v2

Our project will now support:

GET  /books
GET  /books/{book_id}
POST /books

2. What Is a Request Body?

A request body is data sent by the client to the server as part of an HTTP request.

For example:

POST /books

The client can send:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99
}

The server receives that data.

Conceptually:

Client
   |
   | POST /books
   |
   | JSON Body
   v
FastAPI
   |
   | Validate
   v
Application Logic
   |
   v
Response

FastAPI’s documentation recommends Pydantic models for declaring request bodies.


3. Request Body vs Path Parameter vs Query Parameter

We now have three different places where information can arrive.

Consider:

POST /books/25?notify=true

with:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer"
}

We can conceptually divide the request into:

Path
----
25

Query
-----
notify=true

Body
----
{
    "title": "...",
    "author": "..."
}

FastAPI can distinguish these automatically.

The official documentation explains that:

  • parameters appearing in the path are path parameters
  • simple singular types such as int, str, and bool are interpreted as query parameters
  • Pydantic model parameters are interpreted as request bodies.

This gives us a very powerful request-handling system.


4. Why Not Just Use Dictionaries?

A beginner might ask:

Why don’t we simply accept a dictionary?

We could.

But consider this request:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "year": "hello",
    "price": "expensive"
}

Our application expects:

year → integer
price → number

A dictionary doesn’t give us a clean declaration of the expected structure.

We want to define a contract.

Something like:

BookCreate

title     → string
author    → string
category  → string
year      → integer
price     → number

This is exactly where a Pydantic model becomes valuable.


5. What Is Pydantic?

Pydantic is a data validation and parsing library.

FastAPI integrates with it extensively.

A Pydantic model lets us describe the expected shape of data.

Conceptually:

Incoming JSON
      |
      v
Pydantic Model
      |
      +---- Valid ----> Application
      |
      +---- Invalid --> Validation Error

This means validation happens at the application boundary.

That’s a very important architectural principle.


6. Your First Pydantic Model

Let’s create a simple model.

from pydantic import BaseModel


class Book(BaseModel):
    title: str
    author: str
    category: str
    year: int
    price: float

We have declared:

Book
 |
 +-- title     → string
 +-- author    → string
 +-- category  → string
 +-- year      → integer
 +-- price     → number

This is our data contract.


7. Understanding BaseModel

This line:

class Book(BaseModel):

creates a Pydantic model.

BaseModel provides the foundation for:

  • validation
  • parsing
  • serialization
  • schema generation
  • editor support

The official FastAPI request-body documentation uses BaseModel as the foundation for request models.


8. Required Fields

Consider:

class Book(BaseModel):
    title: str
    author: str
    year: int

All three fields are required.

This is valid:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "year": 2026
}

But this is missing author:

{
    "title": "FastAPI in Practice",
    "year": 2026
}

FastAPI/Pydantic will reject it.

We don’t need to manually write:

if "author" not in data:

The model declaration expresses the requirement.


9. Optional Fields

Suppose our book description is optional.

We can write:

from pydantic import BaseModel


class Book(BaseModel):
    title: str
    author: str
    description: str | None = None
    year: int
    price: float

Now:

description

can be omitted.

For example:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "year": 2026,
    "price": 49.99
}

is valid.

The default is:

None

10. Optional Does Not Mean “Anything Goes”

Consider:

description: str | None = None

This means:

description
    |
    +---- string
    |
    +---- None

It does not mean:

integer
object
list
boolean

can all be accepted.

The type declaration still defines the expected data.


11. Program 1 — Create Your First POST Endpoint

Program Objective

Build a small API that accepts a book through a POST request and returns the validated book data.

We want to learn:

  • Pydantic models
  • request bodies
  • POST requests
  • automatic validation
  • Swagger UI request-body testing

12. Step-by-Step Instructions

Step 1 — Create a New Project

Create:

uv init request-body-demo

Move into it:

cd request-body-demo

Add FastAPI:

uv add "fastapi[standard]"

Create:

app/main.py

Project structure:

request-body-demo/
├── app/
│   └── main.py
├── pyproject.toml
└── uv.lock

13. Step 2 — Create the Model

Open:

app/main.py

Add:

from fastapi import FastAPI
from pydantic import BaseModel


class Book(BaseModel):
    title: str
    author: str
    category: str
    year: int
    price: float


app = FastAPI()


@app.post("/books")
async def create_book(book: Book):
    return book

14. Step 3 — Start the Application

Run:

uv run fastapi dev app/main.py

Open:

http://127.0.0.1:8000/docs

You should see:

POST /books

Expand it.

Click:

Try it out

Swagger UI should display a request body similar to:

{
    "title": "string",
    "author": "string",
    "category": "string",
    "year": 0,
    "price": 0
}

FastAPI generated this documentation from our Pydantic model and included the model schema in the OpenAPI documentation.


15. Step 4 — Send a Valid Request

Use:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99
}

Click:

Execute

The API should return the validated data.

Conceptually:

JSON
 ↓
Book Model
 ↓
Validation
 ↓
create_book()
 ↓
Response

16. Step 5 — Send Invalid Data

Try:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": "hello",
    "price": 49.99
}

The request should fail validation because:

year

is expected to be an integer.

Try removing:

author

The request should also fail because author is required.

This is automatic request validation.


17. Source Code Explanation

Let’s examine:

class Book(BaseModel):

This declares the expected structure.

Then:

title: str

means:

title must be a string

Likewise:

year: int

means:

year must be an integer

and:

price: float

means:

price must be numeric

18. The Most Important Line

This line:

async def create_book(book: Book):

does a lot of work.

FastAPI sees:

Book

and understands that this parameter should come from the request body.

The incoming JSON is parsed and validated against the model before our endpoint function receives it.

Conceptually:

Request Body
     |
     v
{
    "title": "...",
    "author": "...",
    "year": 2026
}
     |
     v
Book
     |
     v
Validated Object
     |
     v
create_book(book)

19. Why This Is Better Than Manual Validation

Without a model, we’d potentially need to manually check:

Does title exist?
Is title a string?
Does author exist?
Is author a string?
Does year exist?
Is year numeric?
Is price present?
Is price numeric?

With a model:

class Book(BaseModel):
    title: str
    author: str
    year: int
    price: float

the declaration communicates the contract.

This is one of the central ideas behind FastAPI.


20. Program 2 — Create a Better Book Model

Our first model works, but a real application needs more useful fields.

Let’s create:

BookCreate

instead of simply:

Book

Why?

Because later we’ll have different representations of a book.

For example:

BookCreate
BookUpdate
BookResponse
BookInDatabase

They may contain different fields.

This is our first introduction to a professional API architecture principle:

Don’t assume one model should represent every stage of your data lifecycle.


21. Program Objective

Create a Book Catalog API that accepts new books using a dedicated request model.

The request should contain:

title
author
category
year
price
description

We will also introduce:

  • optional fields
  • default values
  • model serialization
  • HTTP 201 Created

FastAPI allows the response status code to be declared directly on the path operation using status_code.


22. Step-by-Step Instructions

Step 1 — Create the Project

uv init book-create-api

Then:

cd book-create-api

Add FastAPI:

uv add "fastapi[standard]"

Create:

app/main.py

23. Step 2 — Define the Request Model

Add:

from pydantic import BaseModel


class BookCreate(BaseModel):
    title: str
    author: str
    category: str
    year: int
    price: float
    description: str | None = None

Notice:

description: str | None = None

The description is optional.


24. Step 3 — Create the Endpoint

Add:

from fastapi import FastAPI, status
from pydantic import BaseModel


class BookCreate(BaseModel):
    title: str
    author: str
    category: str
    year: int
    price: float
    description: str | None = None


app = FastAPI()


@app.post("/books", status_code=status.HTTP_201_CREATED)
async def create_book(book: BookCreate):
    return book

25. Step 4 — Run the Application

uv run fastapi dev app/main.py

Open:

http://127.0.0.1:8000/docs

26. Step 5 — Create a Book

Send:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99,
    "description": "A practical FastAPI learning book."
}

The response status should be:

201 Created

Why?

Because we explicitly declared:

status_code=status.HTTP_201_CREATED

The FastAPI documentation recommends using 201 Created for successful resource creation.


27. Why Use status.HTTP_201_CREATED?

We could write:

status_code=201

But:

status_code=status.HTTP_201_CREATED

is often more readable.

Compare:

201

with:

HTTP_201_CREATED

The second version communicates the meaning directly.

This becomes especially useful when a codebase contains many different response statuses.


28. model_dump()

A Pydantic model isn’t simply a raw dictionary.

We can convert its data into a dictionary using:

book.model_dump()

For example:

from fastapi import FastAPI
from pydantic import BaseModel


class BookCreate(BaseModel):
    title: str
    author: str
    category: str
    year: int
    price: float


app = FastAPI()


@app.post("/books")
async def create_book(book: BookCreate):
    book_data = book.model_dump()

    return {
        "message": "Book received",
        "book": book_data
    }

A request such as:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99
}

produces a response containing the book data.


29. Why Do We Need model_dump()?

Because later, our application will need to pass model data to other layers.

For example:

Request
   ↓
Pydantic Model
   ↓
Service Layer
   ↓
Repository
   ↓
Database

The model may need to be converted into a dictionary or another representation before persistence.

We’ll use this technique extensively once we introduce a database.


30. Combining Path Parameters and Request Bodies

Now let’s make our API more realistic.

Suppose we have:

PUT /books/25

and the body:

{
    "title": "Updated Book Title",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 59.99
}

There are two different types of information:

Path
----
book_id = 25

Body
----
Book information

FastAPI can handle both at the same time. The official documentation explicitly demonstrates combining request bodies with path parameters.


31. Program 3 — Update a Book

Program Objective

Create an endpoint that receives:

  • a book ID through the URL
  • book information through the request body

32. Step-by-Step Instructions

Create:

from fastapi import FastAPI
from pydantic import BaseModel


class BookUpdate(BaseModel):
    title: str
    author: str
    category: str
    year: int
    price: float


app = FastAPI()


@app.put("/books/{book_id}")
async def update_book(book_id: int, book: BookUpdate):
    return {
        "book_id": book_id,
        "book": book.model_dump()
    }

Run:

uv run fastapi dev app/main.py

Open:

http://127.0.0.1:8000/docs

Test:

PUT /books/25

with:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 59.99
}

Response:

{
    "book_id": 25,
    "book": {
        "title": "FastAPI in Practice",
        "author": "Jane Developer",
        "category": "software",
        "year": 2026,
        "price": 59.99
    }
}

33. How FastAPI Knows Where Each Value Comes From

Look at:

async def update_book(book_id: int, book: BookUpdate):

FastAPI sees:

book_id: int

and notices that:

book_id

appears in:

/books/{book_id}

Therefore:

book_id

comes from the path.

Then it sees:

book: BookUpdate

and recognizes the Pydantic model as a request body.

So:

PUT /books/25

plus:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 59.99
}

becomes:

book_id = 25

book = BookUpdate(...)

This automatic classification is a major FastAPI feature.


34. Combining Path + Query + Body

We can go one step further.

Suppose:

PUT /books/25?notify=true

with:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 59.99
}

We can define:

from fastapi import FastAPI
from pydantic import BaseModel


class BookUpdate(BaseModel):
    title: str
    author: str
    category: str
    year: int
    price: float


app = FastAPI()


@app.put("/books/{book_id}")
async def update_book(
    book_id: int,
    book: BookUpdate,
    notify: bool = False
):
    return {
        "book_id": book_id,
        "notify": notify,
        "book": book.model_dump()
    }

FastAPI understands:

book_id
   ↓
Path

notify
   ↓
Query

book
   ↓
Request Body

This exact combination is supported by FastAPI’s request-body system.


35. Why This Separation Is So Useful

Consider this request:

PUT /books/25?notify=true

The three locations have different meanings.

Path
----
Which resource?

Query
-----
What additional behavior?

Body
----
What data should be submitted?

This is a very useful mental model.


36. Adding Validation with Field

So far we’ve declared:

price: float

But is every number valid?

No.

These values don’t make sense:

price = -100
price = 0

Suppose we want:

price > 0

We can use Pydantic’s Field.

The FastAPI documentation describes Field as the way to add validation and metadata to Pydantic model fields.


37. Program 4 — Validate Book Data

Program Objective

Improve the Book model by adding validation constraints.

We want:

title
    at least 2 characters

author
    at least 2 characters

year
    between 1000 and 2100

price
    greater than 0

description
    maximum 500 characters

38. Step-by-Step Instructions

Use:

from fastapi import FastAPI
from pydantic import BaseModel, Field


class BookCreate(BaseModel):
    title: str = Field(min_length=2, max_length=200)
    author: str = Field(min_length=2, max_length=100)
    category: str = Field(min_length=2, max_length=50)
    year: int = Field(ge=1000, le=2100)
    price: float = Field(gt=0)
    description: str | None = Field(
        default=None,
        max_length=500
    )


app = FastAPI()


@app.post("/books")
async def create_book(book: BookCreate):
    return book

39. Understanding the Constraints

This:

Field(min_length=2, max_length=200)

means:

minimum length = 2
maximum length = 200

This:

Field(ge=1000, le=2100)

means:

greater than or equal to 1000
less than or equal to 2100

And:

Field(gt=0)

means:

greater than 0

These constraints are validated automatically.


40. Test Invalid Data

Try:

{
    "title": "A",
    "author": "J",
    "category": "x",
    "year": 500,
    "price": -10,
    "description": "A valid description"
}

Several validation rules are violated.

FastAPI will produce a structured validation response identifying the invalid fields.

This is much better than silently accepting bad data.


41. Why Validation Belongs at the API Boundary

Think about the architecture:

External Client
      |
      v
--------------------
API Boundary
--------------------
      |
      v
Validation
      |
      v
Application Logic
      |
      v
Database

We don’t want invalid data entering deeper layers.

For example:

Client
  |
  | year = "hello"
  v
API
  |
  X
Rejected

rather than:

Client
  |
  v
Service
  |
  v
Repository
  |
  v
Database
  |
  X
Unexpected error

Early validation makes systems easier to reason about.


42. Validation Is Also Documentation

The following:

year: int = Field(ge=1000, le=2100)

doesn’t just validate input.

It also tells API consumers:

year
Type: integer
Minimum: 1000
Maximum: 2100

FastAPI incorporates these model constraints into the generated OpenAPI schema and interactive documentation.

This means:

One declaration
      ↓
Validation
      +
Documentation
      +
Editor support
      +
Schema

That’s a powerful design pattern.


43. Response Models

We’ve focused on request bodies.

Now let’s look at the other side:

Response Models

Suppose our endpoint returns:

{
    "id": 1,
    "title": "Clean Code",
    "author": "Robert C. Martin",
    "price": 45.0
}

We want to define what the client is allowed to receive.

We can create:

class BookResponse(BaseModel):
    id: int
    title: str
    author: str
    price: float

Then:

@app.get("/books/{book_id}", response_model=BookResponse)

tells FastAPI that the response should conform to this model.

FastAPI uses response models for documentation, validation, serialization, and filtering output fields. The output filtering is especially important when sensitive internal data must not be exposed.


44. Why Response Models Matter

Imagine our internal book data contains:

id
title
author
price
internal_cost
supplier_id
admin_notes

We don’t want the client to receive:

internal_cost
supplier_id
admin_notes

We can define:

class BookResponse(BaseModel):
    id: int
    title: str
    author: str
    price: float

and use:

response_model=BookResponse

Now our API explicitly controls the public representation.

This is a major step toward production-quality API design.


45. Program 5 — Request Model + Response Model

Program Objective

Create separate models for:

Input
Output

and use response_model to control what clients receive.


46. Step-by-Step Instructions

Use:

from fastapi import FastAPI
from pydantic import BaseModel, Field


class BookCreate(BaseModel):
    title: str = Field(min_length=2, max_length=200)
    author: str = Field(min_length=2, max_length=100)
    category: str = Field(min_length=2, max_length=50)
    year: int = Field(ge=1000, le=2100)
    price: float = Field(gt=0)


class BookResponse(BaseModel):
    id: int
    title: str
    author: str
    category: str
    year: int
    price: float


app = FastAPI()


@app.post(
    "/books",
    response_model=BookResponse
)
async def create_book(book: BookCreate):
    return {
        "id": 1,
        **book.model_dump()
    }

47. Test the Endpoint

Send:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99
}

The response should be:

{
    "id": 1,
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99
}

The response model tells FastAPI what the output contract should look like.


48. Input Model vs Output Model

This distinction is extremely important.

We have:

BookCreate

for incoming data.

And:

BookResponse

for outgoing data.

Conceptually:

Client
   |
   | BookCreate
   v
FastAPI
   |
   v
Application
   |
   v
BookResponse
   |
   v
Client

This separation gives us flexibility.


49. Why Not Use One Book Model for Everything?

You might ask:

Why not create one model called Book?

We could for simple examples.

But real applications often have different representations.

For example:

Creation

BookCreate

might contain:

title
author
category
year
price

Database representation

BookRecord

might contain:

id
title
author
category
year
price
created_at
updated_at

Public response

BookResponse

might contain:

id
title
author
category
year
price

Update

BookUpdate

might contain optional fields.

These models serve different purposes.

This separation becomes extremely valuable when our application grows.


50. A Security Lesson

Imagine our internal model contains:

password_hash

If we accidentally return the internal model directly, we could expose sensitive information.

Response models help establish a boundary:

Internal Data
     |
     | Filter
     v
Public Response Model
     |
     v
Client

FastAPI’s response model system specifically filters output according to the declared response shape, which is an important security benefit.

This is one reason response models are not merely documentation decoration.


51. Program 6 — Build Book Catalog API v2

Now we’re ready to combine everything we’ve learned.

Program Objective

Upgrade our Book Catalog API so that it can:

  • list books
  • retrieve one book
  • create a book
  • validate incoming data
  • return structured responses
  • use correct HTTP status codes
  • separate request and response models
  • support query filtering
  • support pagination

We will still use in-memory data.

The database comes later.


52. Step 1 — Create the Project

Create:

uv init book-catalog-api-v2

Move into it:

cd book-catalog-api-v2

Add FastAPI:

uv add "fastapi[standard]"

Create:

app/main.py

53. Step 2 — Define the Models

Start with:

from fastapi import FastAPI, status
from pydantic import BaseModel, Field


class BookCreate(BaseModel):
    title: str = Field(min_length=2, max_length=200)
    author: str = Field(min_length=2, max_length=100)
    category: str = Field(min_length=2, max_length=50)
    year: int = Field(ge=1000, le=2100)
    price: float = Field(gt=0)
    description: str | None = Field(
        default=None,
        max_length=500
    )


class BookResponse(BaseModel):
    id: int
    title: str
    author: str
    category: str
    year: int
    price: float
    description: str | None = None

We now have:

BookCreate
    ↓
Client → API

BookResponse
    ↓
API → Client

54. Step 3 — Create the Application

Add:

app = FastAPI()

55. Step 4 — Create Sample Data

Add:

books = [
    {
        "id": 1,
        "title": "Clean Code",
        "author": "Robert C. Martin",
        "category": "software",
        "year": 2008,
        "price": 45.00,
        "description": "A practical guide to writing readable and maintainable code."
    },
    {
        "id": 2,
        "title": "Clean Architecture",
        "author": "Robert C. Martin",
        "category": "software",
        "year": 2017,
        "price": 49.00,
        "description": "A guide to designing maintainable software systems."
    },
    {
        "id": 3,
        "title": "Designing Data-Intensive Applications",
        "author": "Martin Kleppmann",
        "category": "database",
        "year": 2017,
        "price": 55.00,
        "description": "A detailed guide to data systems and distributed applications."
    }
]

56. Step 5 — Add the Root Endpoint

@app.get("/")
async def root():
    return {
        "message": "Welcome to the Book Catalog API"
    }

57. Step 6 — Add the List Endpoint

Use a response model:

@app.get(
    "/books",
    response_model=list[BookResponse]
)
async def list_books():
    return books

This means the API promises to return:

list[BookResponse]

The generated documentation will also reflect that response schema.


58. Step 7 — Add Filtering and Pagination

Now expand the endpoint:

@app.get(
    "/books",
    response_model=list[BookResponse]
)
async def list_books(
    category: str | None = None,
    skip: int = 0,
    limit: int = 10
):
    filtered_books = books

    if category is not None:
        filtered_books = [
            book
            for book in books
            if book["category"].lower() == category.lower()
        ]

    return filtered_books[skip:skip + limit]

Now we have:

GET /books
GET /books?category=software
GET /books?skip=1&limit=2

59. Step 8 — Add the Single-Book Endpoint

Add:

@app.get(
    "/books/{book_id}",
    response_model=BookResponse
)
async def get_book(book_id: int):
    for book in books:
        if book["id"] == book_id:
            return book

    return {
        "id": 0,
        "title": "Not Found",
        "author": "Unknown",
        "category": "unknown",
        "year": 2000,
        "price": 0,
        "description": None
    }

This works as a demonstration, but there is a problem.

We’re returning fake data when the book doesn’t exist.

We will correct this in the next chapter when we introduce proper exception handling.

For now, the focus is request and response models.


60. Step 9 — Add the Create Endpoint

Now comes the most important endpoint in this chapter.

@app.post(
    "/books",
    response_model=BookResponse,
    status_code=status.HTTP_201_CREATED
)
async def create_book(book: BookCreate):
    new_book = {
        "id": len(books) + 1,
        **book.model_dump()
    }

    books.append(new_book)

    return new_book

This is our first actual create operation.


61. The Complete Book Catalog API v2

Our complete application now looks like:

from fastapi import FastAPI, status
from pydantic import BaseModel, Field


class BookCreate(BaseModel):
    title: str = Field(min_length=2, max_length=200)
    author: str = Field(min_length=2, max_length=100)
    category: str = Field(min_length=2, max_length=50)
    year: int = Field(ge=1000, le=2100)
    price: float = Field(gt=0)
    description: str | None = Field(
        default=None,
        max_length=500
    )


class BookResponse(BaseModel):
    id: int
    title: str
    author: str
    category: str
    year: int
    price: float
    description: str | None = None


app = FastAPI()


books = [
    {
        "id": 1,
        "title": "Clean Code",
        "author": "Robert C. Martin",
        "category": "software",
        "year": 2008,
        "price": 45.00,
        "description": "A practical guide to writing readable and maintainable code."
    },
    {
        "id": 2,
        "title": "Clean Architecture",
        "author": "Robert C. Martin",
        "category": "software",
        "year": 2017,
        "price": 49.00,
        "description": "A guide to designing maintainable software systems."
    },
    {
        "id": 3,
        "title": "Designing Data-Intensive Applications",
        "author": "Martin Kleppmann",
        "category": "database",
        "year": 2017,
        "price": 55.00,
        "description": "A detailed guide to data systems and distributed applications."
    }
]


@app.get("/")
async def root():
    return {
        "message": "Welcome to the Book Catalog API"
    }


@app.get(
    "/books",
    response_model=list[BookResponse]
)
async def list_books(
    category: str | None = None,
    skip: int = 0,
    limit: int = 10
):
    filtered_books = books

    if category is not None:
        filtered_books = [
            book
            for book in books
            if book["category"].lower() == category.lower()
        ]

    return filtered_books[skip:skip + limit]


@app.get(
    "/books/{book_id}",
    response_model=BookResponse
)
async def get_book(book_id: int):
    for book in books:
        if book["id"] == book_id:
            return book

    return {
        "id": 0,
        "title": "Not Found",
        "author": "Unknown",
        "category": "unknown",
        "year": 2000,
        "price": 0,
        "description": None
    }


@app.post(
    "/books",
    response_model=BookResponse,
    status_code=status.HTTP_201_CREATED
)
async def create_book(book: BookCreate):
    new_book = {
        "id": len(books) + 1,
        **book.model_dump()
    }

    books.append(new_book)

    return new_book

62. Run the Application

Start it:

uv run fastapi dev app/main.py

Open:

http://127.0.0.1:8000/docs

You should now see:

GET  /
GET  /books
GET  /books/{book_id}
POST /books

We have just crossed an important milestone.

Our API can now:

READ
+
CREATE

63. Test GET /books

Open:

GET /books

Execute it.

You should receive a list similar to:

[
    {
        "id": 1,
        "title": "Clean Code",
        "author": "Robert C. Martin",
        "category": "software",
        "year": 2008,
        "price": 45.0,
        "description": "A practical guide to writing readable and maintainable code."
    }
]

64. Test POST /books

Use:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99,
    "description": "A practical guide to building FastAPI applications."
}

The API should respond with:

201 Created

and return the newly created book with its generated ID.


65. Test Validation

Try:

{
    "title": "A",
    "author": "J",
    "category": "x",
    "year": 500,
    "price": -10
}

This should fail validation.

Why?

Because our model declares:

title → minimum 2 characters
author → minimum 2 characters
category → minimum 2 characters
year → 1000 through 2100
price → greater than 0

The model is enforcing our API contract.


66. Test Missing Required Fields

Try:

{
    "title": "FastAPI in Practice",
    "year": 2026
}

The request is missing:

author
category
price

The validation layer rejects the request before the endpoint logic processes it.

This is exactly what we want.


67. Test Optional Fields

Now try:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99
}

Notice that:

description

is missing.

That’s valid because:

description: str | None = None

declares a default value.


68. What Happens Inside the POST Request?

Let’s visualize the complete flow.

Client
   |
   | POST /books
   |
   | JSON
   v
FastAPI
   |
   v
BookCreate
   |
   v
Validation
   |
   +---- Invalid
   |       |
   |       v
   |    Error Response
   |
   +---- Valid
           |
           v
       create_book()
           |
           v
       model_dump()
           |
           v
       Store Data
           |
           v
       BookResponse
           |
           v
       201 Created

This is a foundational API architecture pattern.


69. Request Model vs Response Model in Our Project

We now have:

BookCreate

and:

BookResponse

Think of them as two contracts.

Incoming contract

BookCreate

says:

This is what the client is allowed to send.

Outgoing contract

BookResponse

says:

This is what the API promises to return.

This distinction becomes even more important when we add authentication, database models, internal fields, and role-specific responses.


70. A Production-Oriented Architecture Principle

We are beginning to establish boundaries.

Eventually, our application will look something like:

HTTP Request
     |
     v
Router
     |
     v
Request Schema
     |
     v
Service
     |
     v
Repository
     |
     v
Database
     |
     v
Response Schema
     |
     v
HTTP Response

Right now we have:

HTTP Request
     |
     v
Endpoint
     |
     v
Pydantic Model
     |
     v
In-Memory List
     |
     v
Response Model

That’s okay.

We’re building the architecture gradually.


71. Why We Are Not Adding a Database Yet

You might be wondering:

Why are we still storing books in a list?

Because this chapter is about:

Request Bodies
+
Pydantic
+
Validation
+
Response Models

If we introduce:

Database
ORM
Migrations
Transactions
Sessions
Repositories

right now, we would mix several major concepts together.

Instead:

Chapter 3
Request/Response Models
       ↓
Chapter 4
Better Validation
       ↓
Chapter 5
Error Handling
       ↓
Chapter 6
Routers + Project Architecture
       ↓
Later
Database

This keeps the learning curve manageable.


72. A Real Problem With Our ID Generation

Our current code uses:

id = len(books) + 1

This works for a demonstration.

But it is not production-safe.

Imagine:

Book 1
Book 2
Book 3

Delete book 2.

Now:

Book 1
Book 3

If we calculate:

len(books) + 1

we might generate an ID that already exists.

This is another reason a real database will eventually become necessary.

A database can provide reliable identity generation.

We are intentionally postponing that complexity.


73. Another Production Concern: Data Persistence

Our list exists only while the application is running.

If the application stops:

Server stops
     ↓
Memory disappears
     ↓
New books disappear

This means:

POST /books

works temporarily.

But it isn’t persistent storage.

Later we’ll replace:

books = [...]

with:

Database

Then:

POST /books
     ↓
Database INSERT
     ↓
Persistent record

That will be a major milestone in the project.


74. Another Important Concept: Serialization

When we write:

book.model_dump()

we convert model data into a dictionary-like representation.

When FastAPI sends a response, it serializes the data into JSON.

The overall concept is:

Client JSON
     ↓
Validation / Parsing
     ↓
Pydantic Model
     ↓
Application Logic
     ↓
Response Model
     ↓
JSON
     ↓
Client

The request and response travel in different directions, but models provide structure at both boundaries.


75. What Does response_model Actually Do?

Consider:

@app.get(
    "/books",
    response_model=list[BookResponse]
)

This tells FastAPI:

The endpoint’s public response should conform to a list of BookResponse objects.

FastAPI uses the response model to:

  • document the response
  • validate returned data
  • serialize the result
  • filter fields
  • generate OpenAPI schema

These behaviors are documented in the official FastAPI response-model guide.


76. Response Filtering Example

Suppose the endpoint accidentally returns:

{
    "id": 1,
    "title": "Clean Code",
    "author": "Robert C. Martin",
    "price": 45,
    "internal_cost": 12
}

But our response model only declares:

class BookResponse(BaseModel):
    id: int
    title: str
    author: str
    price: float

The response model establishes the public output shape and can filter fields not included in that model.

This is extremely valuable for protecting internal fields.


77. Field Is More Than Validation

Consider:

price: float = Field(gt=0)

The Field declaration can also carry metadata that becomes part of the generated schema.

For example, we could provide descriptions:

price: float = Field(
    gt=0,
    description="The selling price of the book."
)

That information can appear in the generated API documentation.

This allows us to define:

Data Contract
+
Validation
+
Documentation

in one place.


78. When Should We Use POST?

A common API pattern is:

POST /books

for creating a new resource.

The body contains the new resource information.

For example:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99
}

FastAPI’s request-body documentation identifies POST as the most common HTTP method for sending data to an API, while PUT, PATCH, and DELETE can also have request bodies when appropriate.


79. Why We Don’t Use GET for Creating Data

A GET request should normally retrieve information.

For example:

GET /books

means:

Give me books.

A POST request:

POST /books

means:

Create a new book using this submitted data.

This separation helps clients understand what an endpoint is intended to do.


80. PUT vs POST — A Preview

We used:

POST /books

to create.

Later we’ll use:

PUT /books/25

to replace an existing resource.

Conceptually:

POST /books
     ↓
Create a new book

and:

PUT /books/25
     ↓
Replace book 25

FastAPI does not impose these semantics itself; the API design determines how the HTTP methods are used. Its documentation demonstrates PUT for replacing data and PATCH for partial updates.

We’ll study this properly when we implement full CRUD.


81. PATCH — A Preview

Suppose the book is:

{
    "title": "FastAPI in Practice",
    "author": "Jane Developer",
    "category": "software",
    "year": 2026,
    "price": 49.99
}

We only want to change the price.

A partial update could use:

PATCH /books/25

with:

{
    "price": 54.99
}

That means:

Change only this field.

We’ll explore this later.

The official FastAPI body-updates documentation demonstrates using model_dump(exclude_unset=True) for partial updates and model_copy(update=...) to construct the updated model.


82. The Book Catalog Project So Far

Our project has evolved significantly.

Chapter 1

We had:

FastAPI application

Chapter 2

We added:

Routes
Path Parameters
Query Parameters
Filtering
Pagination

Chapter 3

We now have:

Request Bodies
Pydantic Models
Validation
POST
201 Created
Response Models

The project is becoming a real API.


83. Current API Contract

At the end of this chapter, we have:

GET /

Application welcome message.

GET /books

List books.

GET /books/{book_id}

Retrieve a specific book.

POST /books

Create a new book.

Query parameters:

category
skip
limit

Request model:

BookCreate

Response model:

BookResponse

84. Current Architecture

Our current application is still deliberately simple:

book-catalog-api-v2/
│
├── app/
│   └── main.py
│
├── pyproject.toml
└── uv.lock

Inside main.py:

Models
   ↓
Application
   ↓
Routes
   ↓
In-memory data

This won’t remain like this forever.

Soon, we will separate responsibilities.


85. The Architecture We Are Moving Toward

Eventually:

book-catalog-api/
│
├── app/
│   ├── main.py
│   │
│   ├── api/
│   │   └── routes/
│   │
│   ├── schemas/
│   │
│   ├── models/
│   │
│   ├── services/
│   │
│   ├── repositories/
│   │
│   ├── core/
│   │
│   └── database/
│
├── tests/
│
├── pyproject.toml
├── uv.lock
└── README.md

Don’t create all these directories yet.

We’ll introduce them when the application actually needs them.

This is important:

Good architecture grows with complexity; it shouldn’t be introduced merely for decoration.


86. Common Beginner Mistakes

Mistake 1 — Forgetting BaseModel

This:

class Book:

is not a Pydantic model.

We need:

class Book(BaseModel):

Mistake 2 — Forgetting the Type

This:

price

doesn’t describe the expected data.

This:

price: float

does.


Mistake 3 — Assuming None Makes a Field Optional

Consider:

description: str | None

This expresses that the value can be a string or None.

But if you want the field to be optional in the request, give it a default:

description: str | None = None

The default value is what makes it not required in the request model.


Mistake 4 — Using One Model Everywhere

For a tiny application, one model may be enough.

For a larger application, consider:

BookCreate
BookUpdate
BookResponse

instead of forcing one model to serve every purpose.


Mistake 5 — Returning Internal Data

Don’t assume:

database object

should automatically become:

API response

Define the public response contract explicitly.


Mistake 6 — Forgetting 201 Created

A successful resource creation is commonly represented by:

201 Created

rather than simply:

200 OK

FastAPI allows us to declare this directly using status_code.


Mistake 7 — Treating Validation as Optional

Don’t rely entirely on business logic to discover malformed data.

Validate incoming data at the API boundary.


87. Industry-Oriented Principle: Trust Boundaries

A professional API should treat external input as untrusted.

The client could send:

wrong type
missing fields
unexpected values
invalid numbers
malformed structures

Therefore:

External Request
       |
       v
=======================
 TRUST BOUNDARY
=======================
       |
       v
Validation
       |
       v
Application

Pydantic models help us establish that boundary.


88. Industry-Oriented Principle: Explicit Contracts

A good API should make it clear:

What can I send?
What will I receive?
What fields are required?
What values are valid?
What status code should I expect?

Our models provide much of that contract.

For example:

BookCreate

communicates the input.

And:

BookResponse

communicates the output.

The generated OpenAPI documentation exposes these contracts to API consumers.


89. Chapter 3 Practice Assignment

Now it’s time to build something independently.

🎬 Project: Movie Catalog API v2

In Chapter 2, you created a Movie Catalog API.

Now build a new version that accepts structured data.

Do not simply copy the Book Catalog code and rename the fields.

Design the models yourself.


90. Assignment Objective

Build an API that allows clients to create and retrieve fictional movies.

The API must support:

GET
POST

and must use:

Pydantic models

for request validation.


91. Required Movie Model

Create a request model containing at least:

title
director
genre
year
duration
rating
description

Choose appropriate types.

For example:

title → string
year → integer
duration → integer
rating → number

Decide which fields should be required and which should be optional.


92. Required Endpoints

Implement:

GET /

Return a welcome message.


List Movies

GET /movies

Return a list of movies.


Get a Movie

GET /movies/{movie_id}

The ID must be an integer.


Create a Movie

POST /movies

The request body must use your Pydantic model.

Return:

201 Created

93. Response Model Requirement

Create a separate response model.

For example:

MovieCreate
MovieResponse

The response should contain an ID.

The request should not require the client to provide the ID.

Think carefully about why.


94. Validation Requirements

Add validation rules.

At minimum:

title
→ minimum length

year
→ sensible range

duration
→ greater than 0

rating
→ reasonable range

Use Field.

Don’t manually validate these values inside the endpoint.


95. Example Request

Your API should be able to accept something similar to:

{
    "title": "The Future of APIs",
    "director": "Alex Developer",
    "genre": "technology",
    "year": 2026,
    "duration": 120,
    "rating": 8.5,
    "description": "A fictional story about developers building modern APIs."
}

You can create your own fictional movies.


96. Assignment Challenge 1 — Optional Fields

Make:

description

optional.

Then verify that this request works:

{
    "title": "API Revolution",
    "director": "Sam Developer",
    "genre": "technology",
    "year": 2026,
    "duration": 110,
    "rating": 8.0
}

97. Assignment Challenge 2 — Query Parameters

Extend:

GET /movies

to support:

genre
skip
limit

For example:

GET /movies?genre=technology&skip=0&limit=5

98. Assignment Challenge 3 — Path + Query + Body

Create:

PUT /movies/{movie_id}?notify=true

The request body should contain movie information.

Your endpoint should return:

movie_id
notify
movie

This challenge tests whether you understand the difference between:

Path
Query
Body

99. Assignment Challenge 4 — Response Protection

Add an internal field to your stored movie data:

internal_notes

But do not include it in your response model.

Verify that the client cannot see it in the API response.

This is your first practical exercise in response data protection.


100. Assignment Challenge 5 — Validation Testing

Try intentionally invalid requests.

Invalid year

{
    "year": 500
}

Invalid rating

{
    "rating": 50
}

Invalid duration

{
    "duration": -20
}

Missing required field

Remove:

title

Observe the validation responses.


101. Architecture Challenge

Keep your project organized as:

movie-catalog-api/
├── app/
│   └── main.py
├── pyproject.toml
└── uv.lock

Don’t introduce service layers or repositories yet.

We will refactor the project when the application becomes complex enough to justify them.


102. Chapter 3 Knowledge Check

Before moving to Chapter 4, make sure you can answer:

  1. What is a request body?
  2. Why is POST commonly used for creating resources?
  3. What is Pydantic?
  4. What does BaseModel provide?
  5. How does FastAPI know a parameter is a request body?
  6. What makes a model field required?
  7. How do you make a field optional?
  8. What does Field do?
  9. What does Field(gt=0) mean?
  10. What does Field(ge=1000, le=2100) mean?
  11. What is model_dump() used for?
  12. What is response_model?
  13. Why should input and output models often be separate?
  14. Why is 201 Created appropriate for successful creation?
  15. What happens when invalid request data reaches a Pydantic model?
  16. Why should validation happen near the API boundary?
  17. Why shouldn’t internal database data automatically become an API response?
  18. Why are we still using an in-memory collection instead of a database?

If you can answer these questions and complete the Movie Catalog assignment, you’re ready for the next level.

Scroll to Top