Free Ebook cover FastAPI for Beginners: Build a Production-Ready REST API

FastAPI for Beginners: Build a Production-Ready REST API

New course

14 pages

Validation Rules and Data Parsing for API Inputs

CapĂ­tulo 4

Estimated reading time: 7 minutes

+ Exercise

Why validation and parsing matter

In a REST API, inputs arrive as untrusted strings (JSON bodies, query parameters, path parameters, headers). Robust validation ensures you reject malformed data early, return consistent error details, and prevent downstream bugs (database errors, security issues, unexpected business logic). FastAPI relies on Pydantic to both parse raw inputs into Python types and validate them against constraints. When validation fails, FastAPI automatically returns a 422 Unprocessable Entity response with structured error information.

Field constraints with Pydantic

Pydantic lets you express common rules directly on fields: length limits, numeric bounds, regex patterns, and more. These constraints are enforced at runtime and also appear in the generated OpenAPI schema.

String constraints: min/max length and regex

from pydantic import BaseModel, Field

class UserCreate(BaseModel):
    username: str = Field(
        ..., min_length=3, max_length=20, pattern=r"^[a-zA-Z0-9_]+$",
        description="3-20 chars, letters/numbers/underscore only"
    )
    display_name: str | None = Field(None, max_length=50)
  • min_length/max_length enforce size constraints.
  • pattern (regex) enforces format. Keep patterns simple and test them.
  • description improves OpenAPI docs for consumers.

Numeric constraints: bounds and multiples

from pydantic import BaseModel, Field

class ProductCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=80)
    price: float = Field(..., gt=0, description="Must be greater than 0")
    stock: int = Field(0, ge=0, le=100_000)
    discount_percent: int | None = Field(None, ge=0, le=90)
  • gt/ge mean greater-than / greater-or-equal.
  • lt/le mean less-than / less-or-equal.
  • Use integer types for counts and identifiers when appropriate to avoid float rounding issues.

Collection constraints

from pydantic import BaseModel, Field

class BulkTagUpdate(BaseModel):
    tags: list[str] = Field(..., min_length=1, max_length=10)

Here, min_length/max_length apply to the list itself (number of items). If you need per-item rules, combine with strict typing or custom validators.

Strict typing to avoid surprising coercions

By default, Pydantic may coerce some inputs (for example, turning "123" into 123). In many APIs, you want to reject wrong types instead of silently converting them.

Using strict types

from pydantic import BaseModel, Field, StrictInt, StrictStr

class InventoryAdjust(BaseModel):
    product_id: StrictInt
    reason: StrictStr = Field(..., min_length=3, max_length=100)
    delta: StrictInt = Field(..., ge=-1000, le=1000)

With StrictInt and StrictStr, values like "5" (string) will be rejected for an integer field, producing a validation error.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Custom validators for business rules

Field constraints cover many cases, but business rules often require custom logic: cross-field checks, normalization, or conditional requirements.

Normalize and validate a field

from pydantic import BaseModel, Field, field_validator

class UserCreate(BaseModel):
    username: str = Field(..., min_length=3, max_length=20, pattern=r"^[a-zA-Z0-9_]+$")

    @field_validator("username")
    @classmethod
    def normalize_username(cls, v: str) -> str:
        v = v.strip()
        return v.lower()

This validator trims whitespace and lowercases the username. If you want to reject leading/trailing spaces instead of normalizing, raise a ValueError with a clear message.

Cross-field validation

from datetime import datetime
from pydantic import BaseModel, Field, model_validator

class PromotionCreate(BaseModel):
    starts_at: datetime
    ends_at: datetime
    percent_off: int = Field(..., ge=1, le=90)

    @model_validator(mode="after")
    def check_dates(self):
        if self.ends_at <= self.starts_at:
            raise ValueError("ends_at must be after starts_at")
        return self

model_validator is useful when validation depends on multiple fields.

Parsing common input types (datetime, UUID, email)

FastAPI automatically parses many common types when you annotate them in your models or endpoint parameters. This reduces manual parsing and centralizes validation.

Datetime parsing

When a field is annotated as datetime, Pydantic accepts ISO 8601 strings and converts them into datetime objects.

from datetime import datetime
from pydantic import BaseModel

class EventCreate(BaseModel):
    title: str
    starts_at: datetime

Example JSON body:

{
  "title": "Team sync",
  "starts_at": "2026-01-16T09:30:00Z"
}

UUID parsing

from uuid import UUID
from pydantic import BaseModel

class OrderRead(BaseModel):
    id: UUID

Inputs like "550e8400-e29b-41d4-a716-446655440000" are parsed into UUID objects. Invalid UUID strings produce a 422 error.

Email validation

Pydantic provides an email type that validates format. You may need the optional dependency email-validator installed in your environment for full validation support.

from pydantic import BaseModel
from pydantic import EmailStr

class NewsletterSignup(BaseModel):
    email: EmailStr

How validation errors look in FastAPI responses

When validation fails, FastAPI returns a 422 response with a predictable structure. This is helpful for frontend clients because they can map errors to specific fields.

Example: invalid input response

If a client sends username that is too short and an invalid email:

{
  "username": "ab",
  "email": "not-an-email"
}

FastAPI returns something like:

{
  "detail": [
    {
      "type": "string_too_short",
      "loc": ["body", "username"],
      "msg": "String should have at least 3 characters",
      "input": "ab",
      "ctx": {"min_length": 3}
    },
    {
      "type": "value_error",
      "loc": ["body", "email"],
      "msg": "value is not a valid email address",
      "input": "not-an-email"
    }
  ]
}
  • loc tells you where the error occurred (body/query/path) and which field.
  • msg is a human-readable message.
  • type and ctx help programmatic handling.

Query parameter validation

Query parameters are strings by default, but FastAPI will parse them into the annotated types and validate constraints. For query constraints, use Query (and similarly Path, Header, Cookie).

Validating pagination and filters

from fastapi import FastAPI, Query

app = FastAPI()

@app.get("/products")
def list_products(
    q: str | None = Query(None, min_length=2, max_length=50, description="Search term"),
    page: int = Query(1, ge=1, description="1-based page number"),
    page_size: int = Query(20, ge=1, le=100, description="Items per page"),
    min_price: float | None = Query(None, ge=0),
    max_price: float | None = Query(None, ge=0),
):
    return {
        "q": q,
        "page": page,
        "page_size": page_size,
        "min_price": min_price,
        "max_price": max_price,
    }

Try calling:

  • /products?page=0 (fails because ge=1)
  • /products?page_size=500 (fails because le=100)
  • /products?q=a (fails because min_length=2)

Query parsing for UUID and datetime

from datetime import datetime
from uuid import UUID
from fastapi import Query

@app.get("/orders")
def list_orders(
    customer_id: UUID = Query(..., description="Customer UUID"),
    created_after: datetime | None = Query(None, description="ISO 8601 timestamp"),
):
    return {"customer_id": str(customer_id), "created_after": created_after}

If customer_id is not a valid UUID, FastAPI returns a 422 with loc set to ["query", "customer_id"].

Automatic OpenAPI documentation of constraints

Constraints you declare via Field and Query are exported into OpenAPI automatically. This means the interactive docs show:

  • Minimum/maximum values for numbers (minimum/maximum).
  • Minimum/maximum length for strings (minLength/maxLength).
  • Regex patterns (pattern).
  • Descriptions and examples (if provided).

Adding examples to improve docs

from pydantic import BaseModel, Field

class UserCreate(BaseModel):
    username: str = Field(
        ..., min_length=3, max_length=20,
        examples=["sam_dev", "api_user_01"]
    )

For query parameters:

from fastapi import Query

@app.get("/users")
def list_users(role: str | None = Query(None, examples=["admin", "member"])):
    return {"role": role}

Refining earlier endpoints to reject invalid input with meaningful details

Assume you already have endpoints that create resources (for example, users and products). The refinement is to tighten models and parameters so invalid input is rejected before it reaches your service layer.

Step 1: Strengthen the request model with constraints and strict types

from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field, EmailStr, StrictStr, field_validator

class UserCreate(BaseModel):
    email: EmailStr
    username: StrictStr = Field(..., min_length=3, max_length=20, pattern=r"^[a-z0-9_]+$")
    registered_at: datetime | None = None
    referral_code: StrictStr | None = Field(None, min_length=6, max_length=12, pattern=r"^[A-Z0-9]+$")

    @field_validator("username")
    @classmethod
    def username_must_be_lower(cls, v: str) -> str:
        if v != v.lower():
            raise ValueError("username must be lowercase")
        return v

Step 2: Validate query parameters for listing/search endpoints

from fastapi import Query

@app.get("/users")
def list_users(
    limit: int = Query(20, ge=1, le=100),
    offset: int = Query(0, ge=0),
    created_after: datetime | None = Query(None),
):
    return {"limit": limit, "offset": offset, "created_after": created_after}

Step 3: Validate path parameters with type annotations

from uuid import UUID

@app.get("/users/{user_id}")
def get_user(user_id: UUID):
    return {"user_id": str(user_id)}

If a client calls /users/not-a-uuid, FastAPI responds with a 422 error pointing to ["path", "user_id"].

Step 4: Observe and use the error details

When a request fails validation, you get consistent error payloads without writing manual checks. Use these details to:

  • Show field-level messages in a UI.
  • Log invalid request patterns (for example, repeated malformed UUIDs).
  • Guide API consumers by improving description, examples, and constraints in your schema.

Now answer the exercise about the content:

A client calls /users/not-a-uuid for an endpoint that annotates user_id as UUID in the path. What is the expected FastAPI behavior?

You are right! Congratulations, now go to the next page

You missed! Try again.

FastAPI uses Pydantic to parse and validate annotated inputs. An invalid UUID in a path parameter triggers a 422 response with a structured detail list, including loc pointing to ["path", "user_id"].

Next chapter

Error Handling and Consistent API Responses in FastAPI

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.