CodingNic

REST APIs with FastAPI

API validation

REST APIs with FastAPI 28 min read

API validation

API validation

In Lesson 5 your API got its first request body. Pydantic parsed the JSON, confirmed each field’s type, and either passed a clean object to your handler or returned a 422 error. That was parsing. This lesson is about constraints.

Type checking is only the floor. Real APIs need rules like “the title must be between 1 and 120 characters,” “the priority must be between 1 and 5,” “the status must be one of these three exact values,” “the email must actually look like an email.” Without those rules, a client can send a 50,000-character title or a negative priority of -42 and your code happily stores nonsense in your database.

By the end of this lesson:

  • You’ll know the most useful constraints to add to a Pydantic field — length limits, numeric bounds, allowed values, email format.
  • You’ll know how to write a custom validator when the built-ins don’t fit.
  • You’ll understand what the 422 response really looks like and what each field of it means.
  • And you’ll have the right instinct: validate at the edge, not deep inside your code.

The picture

Pydantic runs four kinds of checks against every request body. Some you’ve already met:

Validation flow with constraints

  1. Required check — is the field present at all? Determined by whether the type hint has a default value.
  2. Type check — can the value be parsed (or coerced) into the declared type?
  3. Constraint check — does the value satisfy the rules attached to the field (length, range, pattern, allowed values)?
  4. Custom check — does it pass any extra Python logic you’ve written?

If all four pass, your handler runs with a validated model. If any fails, FastAPI returns a 422 listing every problem — not just the first one — and your handler never executes.

You wrote the simplest version in Lesson 5. This lesson is about checks 3 and 4.


Why “edge validation” matters

A quick principle before we touch code.

Imagine you skip Pydantic and put validation logic inside your view function:

python
@app.post("/tasks")
def create_task(task: TaskIn):
    if not task.title:
        raise HTTPException(400, "title required")
    if len(task.title) > 120:
        raise HTTPException(400, "title too long")
    if task.priority < 1 or task.priority > 5:
        raise HTTPException(400, "priority out of range")
    # ... finally do the work

That works — for one endpoint. But you’ll write the same checks in update_task, replace_task, and any other endpoint that touches a task. The rules live in three places, and the day someone tightens “title too long” from 120 to 100, three views need updating. One gets forgotten. Bug.

The Pydantic approach pushes all the rules into one place — the model itself. Every endpoint that uses TaskIn automatically inherits every rule. Change the rule once, every endpoint follows. This is the difference between a form validator and a type system: types are cheaper to keep consistent.

The general guideline:

Validate at the edge — where data enters your system. Once it’s past the model, trust it.


Adding constraints with Field(...)

Pydantic provides a Field helper that lets you attach metadata and constraints to any field. The most useful constraints, by type:

For strings:

  • min_length — shortest allowed length.
  • max_length — longest allowed length.
  • pattern — a regex the string must match.

For numbers (int, float):

  • ge — greater than or equal to.
  • le — less than or equal to.
  • gt — strictly greater than.
  • lt — strictly less than.

For everything:

  • default — the value used when the field is missing.
  • description — human-readable explanation that shows up in the OpenAPI docs.

Let’s apply these to our TaskIn model. Open the Lesson 5 demo (or start a fresh fastapi-validation/main.py) and replace the model:

python
# main.py
from typing import Literal
from fastapi import FastAPI
from pydantic import BaseModel, Field, EmailStr, field_validator

app = FastAPI(title="Validation demo")


class TaskIn(BaseModel):
    title: str = Field(
        min_length=1,
        max_length=120,
        description="Short, human-readable description of the task.",
    )
    priority: int = Field(
        default=2,
        ge=1,
        le=5,
        description="1 = highest priority, 5 = lowest.",
    )
    status: Literal["todo", "in_progress", "done"] = Field(
        default="todo",
        description="Workflow state. Only the three listed values are allowed.",
    )
    owner_email: EmailStr | None = Field(
        default=None,
        description="Optional email of whoever owns this task.",
    )

Walk through each field.

title

python
title: str = Field(min_length=1, max_length=120, description="...")
  • Required (no default= — if you provide default=... in Field(), the field becomes optional).
  • Must be a non-empty string between 1 and 120 characters.
  • The description shows up in /docs next to the field.

A subtle bit: min_length=1 is what makes empty strings ("") invalid. Without it, type-checking alone would accept "" as a valid str.

priority

python
priority: int = Field(default=2, ge=1, le=5, description="...")
  • Optional, defaults to 2.
  • Must be an integer between 1 and 5, inclusive.
  • ge=1, le=5 is the most common pattern — “from 1 to 5, including the endpoints.”
  • For exclusive bounds, use gt/lt. Most of the time ge/le is what you want.

status

python
status: Literal["todo", "in_progress", "done"] = Field(default="todo", description="...")
  • Optional, defaults to "todo".
  • Must be exactly one of the three listed values. Anything else is a 422.

Literal[...] from typing is the cleanest way to declare a “one of these values” enumeration in Pydantic. It generates an enum in the OpenAPI schema, so the docs page and any code generators will show a dropdown.

You could also use Python’s enum.Enum, but Literal is simpler and more direct for small, hard-coded sets.

owner_email

python
owner_email: EmailStr | None = Field(default=None, description="...")
  • Optional (the | None and default=None together mean “may be omitted, may be null”).
  • If provided, must be a syntactically valid email address.

EmailStr is a special Pydantic type that runs an actual email validity check. It needs the email-validator package:

bash
pip install 'pydantic[email]'

(The square-bracket syntax installs Pydantic with the optional email extra in one step.)

EmailStr only checks the syntax of the address — foo@bar.baz passes even though bar.baz isn’t a real domain. To verify the email actually exists you’d have to send a confirmation message; Pydantic isn’t trying to do that.


Custom validators with @field_validator

The built-in constraints cover most needs, but sometimes the rule is “this depends on Python logic you’d have to write yourself.” Pydantic supports this through @field_validator:

python
class TaskIn(BaseModel):
    title: str = Field(min_length=1, max_length=120)
    # ...other fields...

    @field_validator("title")
    @classmethod
    def title_must_not_be_only_whitespace(cls, v: str) -> str:
        if v.strip() == "":
            raise ValueError("title cannot be just whitespace")
        return v.strip()

Two things to notice:

  • Raising ValueError signals “validation failed.” Pydantic catches it, packages it into a 422 response, and includes your message.
  • Returning a value from the validator replaces the original value. Here, we .strip() the title — leading and trailing whitespace are quietly removed before the value reaches the handler.

This is a powerful pattern: validators can both reject input and normalise it. A title of " Buy groceries " becomes "Buy groceries" automatically.

The @classmethod decorator is required — Pydantic’s API. v is the value being validated; you receive it after the built-in checks pass (so it’s already a str by this point).

You can attach multiple validators to the same field, and they run in declaration order. For complex rules — “if priority is 1, owner_email is required” — you’d use @model_validator(mode="after"), which sees the whole object at once. We won’t go that deep here; the field-level pattern handles 90% of needs.


Run it

Save main.py, then:

bash
uvicorn main:app --reload

The first thing to check: open http://127.0.0.1:8000/docs and click POST /tasks. The request body schema now shows every constraint:

  • title — minLength 1, maxLength 120, description present.
  • priority — minimum 1, maximum 5, default 2.
  • status — enum: ["todo", "in_progress", "done"], default “todo”.
  • owner_email — format: email, nullable.

The constraints didn’t just become enforceable — they became discoverable. Anyone reading the docs knows exactly what’s allowed without reading your source code.


Try it with curl

Let’s exercise every constraint. We’ll need the rest of the demo file — the routes that use this model:

python
tasks: list[dict] = []
next_id = 1


@app.get("/tasks")
def list_tasks():
    return tasks


@app.post("/tasks", status_code=201)
def create_task(task: TaskIn):
    global next_id
    new_task = {
        "id": next_id,
        "title": task.title,
        "priority": task.priority,
        "status": task.status,
        "owner_email": task.owner_email,
    }
    tasks.append(new_task)
    next_id += 1
    return new_task

Happy path

bash
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries"}'
json
{"id":1,"title":"Buy groceries","priority":2,"status":"todo","owner_email":null}

Defaults filled in, response is clean.

Title trimming

bash
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "  Reply to Alice  "}'
json
{"id":2,"title":"Reply to Alice","priority":2,"status":"todo","owner_email":null}

The custom validator silently stripped the surrounding whitespace.

Whitespace-only title

bash
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "   "}'
json
{
  "detail": [
    {
      "type": "value_error",
      "loc": ["body", "title"],
      "msg": "Value error, title cannot be just whitespace",
      "input": "   "
    }
  ]
}

422, with our custom message included.

Out-of-range priority

bash
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "priority": 99}'
json
{
  "detail": [
    {
      "type": "less_than_equal",
      "loc": ["body", "priority"],
      "msg": "Input should be less than or equal to 5",
      "ctx": {"le": 5},
      "input": 99
    }
  ]
}

The error mentions le: 5 in ctx — clients building user-facing forms can use that to show “must be 5 or less” without parsing the message.

Invalid status

bash
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "status": "unknown"}'
json
{
  "detail": [
    {
      "type": "literal_error",
      "loc": ["body", "status"],
      "msg": "Input should be 'todo', 'in_progress' or 'done'",
      "input": "unknown",
      "ctx": {"expected": "'todo', 'in_progress' or 'done'"}
    }
  ]
}

The error lists every allowed value — beautifully helpful for the client.

Bad email

bash
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "owner_email": "not-an-email"}'
json
{
  "detail": [
    {
      "type": "value_error",
      "loc": ["body", "owner_email"],
      "msg": "value is not a valid email address: An email address must have an @-sign.",
      "input": "not-an-email"
    }
  ]
}

EmailStr’s check produces a specific, descriptive error.


Multiple errors at once

Here’s something to internalise. Send a request that violates several rules at once:

bash
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "", "priority": 99, "status": "invalid", "owner_email": "not-an-email"}'
json
{
  "detail": [
    {"type": "string_too_short", "loc": ["body", "title"], "msg": "String should have at least 1 character", ...},
    {"type": "less_than_equal",  "loc": ["body", "priority"], "msg": "Input should be less than or equal to 5", ...},
    {"type": "literal_error",    "loc": ["body", "status"], "msg": "Input should be 'todo', 'in_progress' or 'done'", ...},
    {"type": "value_error",      "loc": ["body", "owner_email"], "msg": "value is not a valid email address: ...", ...}
  ]
}

Four problems, four entries in detail, one response. The client doesn’t have to fix one error, resubmit, find the next one, fix that, resubmit, etc. They get the complete picture in a single round-trip.

A user-facing form using your API can iterate through detail, find each loc (which is always ["body", "<field_name>"] for body fields), and highlight every broken input field at once. That’s the difference between a form that says “please fix the errors below” and one that says “please fix the error above… now please fix the next error above…”.


The shape of the 422 response

Every entry in detail follows the same structure:

json
{
  "type": "less_than_equal",
  "loc": ["body", "priority"],
  "msg": "Input should be less than or equal to 5",
  "input": 99,
  "ctx": {"le": 5}
}

Let’s name each field:

  • type — a stable, machine-readable identifier for the kind of error. string_too_short, int_parsing, missing, literal_error, value_error, etc. Useful if your client wants to react differently based on error type.
  • loc — a list pointing at the offending field. ["body", "priority"] means “in the body, the priority field.” Nested fields produce deeper paths.
  • msg — a human-readable description.
  • input — the actual value that failed validation. Helpful when the client wants to remind the user what they typed.
  • ctx — optional extra context (the limit that was exceeded, the allowed values, etc.).

This is the same shape across every FastAPI endpoint, every Pydantic model, every error type. Once your client knows how to parse one validation error, it knows how to parse all of them.


Common pitfalls

  • Putting default in the type hint vs Field(default=...). Both work for optional fields: priority: int = 2 or priority: int = Field(default=2, ge=1, le=5). Use the long form when you need constraints.
  • Forgetting min_length=1 on strings you expect to be non-empty. Type-checking alone accepts "". If you want “at least one character,” say so.
  • Using Literal for large value sets. Fine for 3–5 values. For 50 statuses, use a real Enum and document them properly.
  • Custom validators that mutate the input silently in surprising ways. Trimming whitespace is fine. Replacing the title with "DEFAULT" because it was empty is not fine — that’s hiding a problem. If something should fail, raise; if it should be normalised, normalise. Don’t blur the line.
  • Trying to validate cross-field rules with @field_validator. Each field validator only sees its own field. For “if A is X, then B is required” rules, use @model_validator(mode="after") — but most of the time, redesigning the model so the constraint is local is cleaner.
  • Putting EmailStr without installing the optional dependency. You’ll get an obscure ImportError. Run pip install 'pydantic[email]'.
  • Stuffing all validation logic into the view function. Don’t. Pydantic exists to centralise this. Edge validation only.

Summary

  • The four checks in order: required, type, constraints, custom. All four must pass.
  • Field(...) attaches constraints (min_length, max_length, ge, le, pattern, etc.) and a description.
  • Literal["a", "b", "c"] declares “one of these exact values.”
  • EmailStr validates email-address syntax (install pydantic[email]).
  • @field_validator("field_name") runs custom Python after the built-in checks. Raise ValueError to fail, return a value to normalise.
  • The 422 response contains every validation error, with type, loc, msg, input, and optional ctx.
  • All validation happens at the edge — once data is past the model, trust it.

Outcome

Your API is now genuinely safe to expose to clients you don’t control. Bad input — missing fields, wrong types, out-of-range values, malformed emails — is rejected cleanly with structured errors before any of your code runs. Next lesson, we turn our attention to the other direction: shaping the responses your API sends back. Some fields you’ll always want to return; some you should never expose; sometimes the output shape should differ from the input shape entirely. That’s what response models are for.