Pydantic Data Validation and OpenAPI

In the previous chapter, we learned how to create a simple GET route. However, in real-world business applications, we most often deal with JSON data sent from clients (e.g., registration forms, order creation, or sending text to AI for processing).

This is where HTTP POST requests come into play.

In the past, handling POST requests in Flask required manually extracting fields from request.json and writing lengthy if / else checks to validate formats: "Did they fill in the name?", "Is the email format correct?", "Is the age greater than 0?".

In FastAPI, all these pains are solved by Pydantic.

1. What is Pydantic?

Pydantic is a data validation library for Python. Its core concept is: Using Python's native type hints to declare data structures.

You only need to define a "Model," and FastAPI will handle everything behind the scenes:

  1. Automatic Validation: If the incoming data doesn't match the model, it automatically returns a 422 error (with detailed error messages).
  2. Automatic Conversion: If the incoming data is a string "123" but the model specifies an int, it will automatically convert it.
  3. Automatic Documentation: Syncs this model to Swagger UI, so frontend developers know what format to send.

2. Creating Your First Pydantic Model

Let's create an "Add Member" API.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

# 1. Declare a Pydantic model
class UserCreate(BaseModel):
    username: str
    email: str
    age: int
    is_active: bool = True  # Provide a default value

# 2. Use this model as a parameter in the route
@app.post("/users/")
async def create_user(user: UserCreate):
    # At this point, the incoming user is already a strictly validated object!
    print(f"New user received: {user.username}, email: {user.email}")
    
    # Simulate writing to a database
    user_dict = user.model_dump() # Convert the model to a dictionary
    user_dict["user_id"] = 9527
    
    return {"message": "Member created successfully", "data": user_dict}

Testing This API

Now, open your http://127.0.0.1:8000/docs. You'll notice:

  1. A new POST /users/ route appears.
  2. Clicking on it clearly shows the Request Body requires username, email, age, and is_active.
  3. Click "Try it out." If you intentionally omit age or set age to a string like "twenty", the API will immediately block you and return a highly detailed JSON error message, telling you exactly which field is wrong.

This is why FastAPI development is so fast! You no longer need to write defensive logic!

3. Advanced Validation: Using Field()

Sometimes, simply declaring a type (e.g., str or int) isn't enough. We might want passwords to be at least 8 characters long, ages to be between 18 and 100, or product prices to be non-negative.

This is where Field comes in for deep validation:

from pydantic import BaseModel, Field, EmailStr

class ProductCreate(BaseModel):
    name: str = Field(..., min_length=2, max_length=50, description="Product name")
    price: float = Field(..., gt=0, description="Product price, must be greater than 0")
    discount: float | None = Field(default=None, ge=0, le=1, description="Discount rate (0.0 ~ 1.0)")
    tags: list[str] = Field(default_factory=list, max_items=5)

Breakdown:

  • Field(...): The ... indicates this field is required.
  • gt=0: Greater Than, must be greater than 0.
  • ge=0, le=1: Greater or Equal to 0, Less or Equal to 1 (for 0% to 100% discounts).
  • description: This description is directly rendered in Swagger API docs, making it crystal clear for frontend developers!

[!TIP] Email Validation Made Easy If you need to validate email formats, you don't need to write complex regex. Just install the email-validator package (pip install email-validator), then declare the type as EmailStr. Pydantic will automatically check if the format is correct!

4. Nested Models

In real-world APIs, JSON often has multiple layers. Pydantic handles this effortlesslyโ€”just nest models within models:

from pydantic import BaseModel

class Address(BaseModel):
    city: str
    zip_code: str

class UserProfile(BaseModel):
    name: str
    address: Address  # Use another model directly as the type

@app.post("/profile/")
async def update_profile(profile: UserProfile):
    return {"city": profile.address.city}

5. Summary

Pydantic acts as the "ultimate gatekeeper" for your backend API. All dirty, incorrect, or malicious data is blocked by Pydantic before it reaches your business logic.

Once data purity is ensured, the next step is to actually "write it to disk." In the next chapter, we'll introduce Python's most powerful ORM systemโ€”SQLAlchemyโ€”and teach you how to seamlessly integrate with relational databases like PostgreSQL!

Field Types and Validation

Pydantic supports a wide range of field types with built-in validation.

Basic Field Types

from pydantic import BaseModel
from typing import Optional
from datetime import datetime

class UserProfile(BaseModel):
    id: int
    username: str
    email: str
    age: Optional[int] = None
    is_active: bool = True
    score: float = 0.0
    created_at: datetime
    tags: list[str] = []

String Constraints

from pydantic import BaseModel, Field

class Product(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    description: str = Field("", max_length=1000)
    sku: str = Field(..., pattern=r"^[A-Z]{3}-\d{4}$")
    
# Valid: "ABC-1234"
# Invalid: "abc-123" (wrong format)

Numeric Constraints

class OrderItem(BaseModel):
    quantity: int = Field(..., ge=1, le=100)     # 1-100
    price: float = Field(..., gt=0, le=9999.99)  # > 0, max 9999.99
    discount: float = Field(0.0, ge=0, le=1.0)    # 0% - 100%

Custom Validators

Use @field_validator for custom validation logic.

Single Field Validation

from pydantic import BaseModel, field_validator
import re

class UserCreate(BaseModel):
    username: str
    password: str
    confirm_password: str

    @field_validator("username")
    @classmethod
    def validate_username(cls, v: str) -> str:
        if len(v) < 3:
            raise ValueError("Username must be at least 3 characters")
        if not re.match(r"^[a-zA-Z0-9_]+$", v):
            raise ValueError("Username can only contain letters, numbers, underscore")
        return v

    @field_validator("password")
    @classmethod
    def validate_password(cls, v: str) -> str:
        if len(v) < 8:
            raise ValueError("Password must be at least 8 characters")
        if not re.search(r"[A-Z]", v):
            raise ValueError("Password must contain an uppercase letter")
        if not re.search(r"[0-9]", v):
            raise ValueError("Password must contain a number")
        return v

Model-Level Validation

from pydantic import BaseModel, model_validator

class UserCreate(BaseModel):
    password: str
    confirm_password: str

    @model_validator(mode="after")
    def check_passwords_match(self):
        if self.password != self.confirm_password:
            raise ValueError("Passwords do not match")
        return self

Nested Models

Models can contain other models for complex data structures.

from pydantic import BaseModel
from typing import list

class Address(BaseModel):
    street: str
    city: str
    country: str
    postal_code: str

class OrderItem(BaseModel):
    product_id: int
    quantity: int

class Order(BaseModel):
    id: int
    customer: str
    shipping_address: Address
    items: list[OrderItem]
    total: float

# API request body
app.post("/orders")
async def create_order(order: Order):
    # order.shipping_address.city
    # order.items[0].product_id
    return order

Error Handling

from fastapi import FastAPI, status
from pydantic import BaseModel
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

app = FastAPI()

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
    errors = []
    for error in exc.errors():
        errors.append({
            "field": " โ†’ ".join(str(loc) for loc in error["loc"]),
            "message": error["msg"],
            "type": error["type"]
        })
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content={"errors": errors}
    )

Summary

Pydantic provides automatic request validation in FastAPI. It converts incoming JSON to typed Python objects and rejects invalid data with clear error messages.

Key takeaways:

  • Pydantic models define the shape of request/response data |
  • Field constraints: min_length, max_length, ge, le, pattern regex |
  • @field_validator validates individual fields with custom logic |
  • @model_validator validates relationships between fields |
  • Nested models handle complex, hierarchical data structures |
  • FastAPI automatically returns 422 for validation failures |
  • Custom exception handlers improve API error responses |
  • Type hints eliminate manual data parsing and checking |

What's Next: SQLAlchemy Database

The next chapter covers SQLAlchemy integration for database operations.

Unlock Full Tutorial

This chapter is paid content. Join the project to unlock over 5000 words of deep analysis, including 10+ god-tier Prompts and real Source Code examples!