Pydantic schemas enable comprehensive data validation for API requests and responses. This chpapter covers creating schema files with request and response models, implementing field validations with constraints, updating GET endpoints to use response models, building a POST endpoint for creating resources, and understanding why persistent storage is necessary for real applications.

What is Pydantic?

Pydantic is a data validation library that uses Python type hints. Unlike normal Python code where type hints serve primarily as documentation, Pydantic enforces type hints at runtime and provides detailed error messages when data doesn’t match expected types. Pydantic comes bundled with FastAPI—no separate installation is required.

Why Schemas Before Database?

FastAPI’s primary strength lies in its integration with Pydantic. Schemas define what data the API accepts from clients and what data it returns. The database (covered in the next chapter) defines what gets stored. This maintains clean separation of concerns.

For developers coming from Flask, validation typically involves WTForms or manual checks. Pydantic offers a more Pythonic approach using type hints, provides automatic API documentation, and delivers better IDE support with autocomplete.

Current Documentation State

Before implementing schemas, the API documentation at /docs shows two endpoints: one returning all posts and one returning a single post by ID.

Expanding these endpoints reveals “Successful response” with no details about response structure. The API also lacks any endpoint for creating posts. Adding response models and Pydantic schemas will display exact fields, types, and validation rules in the documentation.

Creating the Schemas File

Create schemas.py as a separate file from main.py:

from pydantic import BaseModel, ConfigDict, Field

BaseModel is the base class all Pydantic models inherit from. Field enables adding constraints like minimum and maximum length. ConfigDict is the modern Pydantic v2 method for configuring models.

Base Schema with Shared Fields

Following the DRY principle (Don’t Repeat Yourself), create a base schema containing fields shared between creating and returning posts:

class PostBase(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    content: str = Field(min_length=1)
    author: str = Field(min_length=1, max_length=50)

If you’ve used data classes, this syntax looks familiar, but Pydantic uses these types to validate data at runtime. Currently these fields accept any string, including empty strings. The Field() constraints enforce requirements: titles must be 1-100 characters, content must have at least 1 character (no maximum), and author names must be 1-50 characters.

Critical detail: These fields have constraints but no default values. Without defaults, all fields are required.

Create Schema

Define what the API accepts when creating new posts:

class PostCreate(PostBase):
    pass

This is currently identical to PostBase. Creating a separate class demonstrates intent and provides flexibility. For example, when authentication is added later, the author field might not be needed here because it will come from the logged-in user. By inheriting from PostBase and passing, this schema specifies that creating a post requires title, content, and author.

Response Schema

Define what the API returns:

class PostResponse(PostBase):
    model_config = ConfigDict(from_attributes=True)
    
    id: int
    date_posted: str

PostResponse inherits title, content, and author from PostBase, then adds id and date_posted—fields generated by the system, not provided by clients.

Note on the id field: While naming fields id should generally be avoided since it’s a Python built-in, for database models and API responses, using id is standard convention. Scoped to the class, it won’t conflict with the global function or trigger linter warnings. This is ubiquitous in real-world APIs.

Understanding model_config: In Pydantic v2, models are configured with ConfigDict instead of the old Config class. Setting from_attributes=True tells Pydantic it can read data from objects with attributes, not just dictionaries. This becomes essential when adding a database in the next tutorial. Currently, posts are dictionaries accessed with bracket notation (post["title"]). When a database is added, data will be accessed through dot notation (post.title). By default, Pydantic reads from dictionaries. Setting from_attributes=True teaches it to also read from objects using dot notation. Without this configuration, reading from the database through the API would fail.

Legacy code note: Older codebases may use orm_mode—the Pydantic v1 name for the same functionality.

Note on date_posted: This is currently a string because the in-memory data uses strings for dates. When the database is added, this will change to an actual datetime type.

Integrating Schemas with Routes

Import the schemas in main.py:

from schemas import PostCreate, PostResponse

PostBase isn’t imported—it’s only a base class for inheritance. Only schemas directly used for creating posts (PostCreate) and returning posts (PostResponse) are needed.

Updating GET Endpoints

Add response models to existing endpoints. For the route returning all posts:

@app.get("/api/posts", response_model=list[PostResponse])
def get_posts():
    return posts

Simply adding response_model=list[PostResponse] provides multiple benefits: FastAPI validates each post matches the PostResponse schema, the documentation automatically shows the exact structure, and the response model acts as a safeguard—extra fields not in the schema are filtered out, and missing required fields trigger errors. This helps prevent accidentally exposing data that shouldn’t be public.

For the single post endpoint:

@app.get("/api/posts/{post_id}", response_model=PostResponse)
def get_post(post_id: int):
    for post in posts:
        if post.get("id") == post_id:
            return post
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="Post not found"
    )

This endpoint returns a single post, not a list, so response_model=PostResponse without the list wrapper.

Creating a POST Endpoint

This is where Pydantic truly excels:

@app.post("/api/posts", response_model=PostResponse, status_code=status.HTTP_201_CREATED)  
def create_post(post: PostResponse):  
    new_id = max(p["id"] for p in posts) + 1 if posts else 1  
    new_post = {  
        "id": new_id,  
        "author": post.author,  
        "title": post.title,  
        "content": post.content,  
        "date_posted": "April 23, 2025",  
    }  
    posts.append(new_post)  
    return new_post

Key differences from GET endpoints:

  • HTTP method: Uses @app.post() instead of @app.get(). POST requests are the standard for creating resources.
  • Status code: Returns status.HTTP_201_CREATED instead of 200. This RESTful best practice tells clients a new resource was successfully created rather than just “success.”
  • Request validation: The post: PostCreate parameter is the critical part. FastAPI sees this type hint and automatically parses the JSON request body, validates it against the schema, and returns a 422 validation error with details if anything is invalid—all before the function even runs. If validation passes, the function executes with guaranteed valid data.

The ID generation logic looks complex only because it manually handles IDs for dummy data. Don’t focus on this complexity—once the database is set up, ID generation will be automatic.

After generating the ID, a dictionary is created using the validated data (post.title, post.content, post.author), a hard-coded date is added, the new post is appended to the in-memory list, and the new post is returned.

Testing in Interactive Documentation

Reload /docs to see the improvements. The documentation now includes a “Create Post” endpoint.

Examining GET Endpoints

Expand the “Get all posts” endpoint. The response section now shows list[PostResponse]. The schema section displays all fields with their types. Clicking “Schema” reveals constraints: strings between 1-100 characters for title, minimum 1 character for content, etc.

The single post endpoint shows similar improvements with an example value and detailed schema information.

Testing POST Endpoint

Expand the “Create Post” endpoint. The request body shows it expects title (string), content (string), and author (string). The schema tab displays exact requirements. Anyone using the API can see precisely what to send.

Click “Try it out” and fill in sample values:

{
  "title": "My New Post",
  "content": "This is my content",
  "author": "Test User"
}

Execute the request. The documentation displays the curl command for reproduction, a 201 response code indicating successful creation, and the response body including the generated ID, title, content, author, and hard-coded date.

Testing Validation

Empty field test: Create a post with content and author but an empty title string. Executing returns a 422 response with the message “String should have at least one character”—exact, actionable feedback.

Missing field test: Remove the author field entirely from the request:

{
  "title": "Test Post",
  "content": "Some content"
}

This returns a 422 error stating “Field required: author.”

All of this validation is automatic. No if statements, no try-except blocks—just FastAPI and Pydantic working together based on the schema definitions.

Verifying Persistence (In-Memory)

After creating a successful post, test the “Get all posts” endpoint. The newly created post appears in the list. Checking the HTML frontend by reloading the homepage shows the new post there as well. The API and templates share the same data source, so posts created through the API appear on template pages.

Important note: Template routes work exactly as before. They use dictionaries and don’t know about Pydantic. Schemas only apply to API endpoints where response_model is set or Pydantic types appear in parameters. This is clean separation for now: API routes use Pydantic for validation and documentation; template routes simply render data.

The Persistence Problem

The schemas work perfectly, but there’s a critical flaw. After creating a new post, restart the development server. Reload the homepage—the new post is gone.

Why? Posts are stored in a Python list in memory:

posts = [
    {"id": 1, "title": "...", ...},
    {"id": 2, "title": "...", ...}
]

When the server restarts, this list is recreated with only hard-coded posts. Any dynamically created posts disappear. This is obviously impractical for real applications. Data must persist across restarts.

The solution: Use a database. This is exactly what the next chapter covers.

Summary

This chapter introduced Pydantic schemas for API validation. A schemas.py file was created with three schemas: PostBase containing shared fields (title, content, author), PostCreate defining what’s accepted when creating posts (currently identical to PostBase), and PostResponse defining what’s returned (includes PostBase fields plus system-generated id and date_posted). Field validation was implemented with constraints like min_length and max_length. GET endpoints were updated to use response_model, improving both documentation and providing built-in validation. A POST endpoint was created for resource creation, automatically validating incoming data against schemas.

The key takeaway: Pydantic schemas define the API’s contract, specifying what data goes in and what comes out. FastAPI uses these for validation, serialization, and documentation—an elegant, integrated system.

When the database is added in the next chapter, schemas will be restructured to work with database relationships, but the validation concepts learned here remain fundamental. The next tutorial covers setting up a database with SQLAlchemy, replacing the in-memory list with real persistent storage, creating database models (separate from Pydantic schemas), and connecting everything so data persists across server restarts.