Home / Courses / FastAPI for Beginners / II — Hands-On Practice
II — Hands-On Practice
Article 4 of 6

Data Validation, Response Models & Error Handling with Pydantic

Tightening field validation with `Field` and `Enum`, separating request schemas from response schemas, and telling apart FastAPI's two layers of error handling — automatic validation vs. `HTTPException`.

July 10, 2026

Our app from the previous article already runs, but its validation is still very basic: title is only checked to be a str, with no length limit, and we still can't update or delete a task. In this article we go deeper on three things: stricter field validation, separating request schemas from response schemas, and handling errors consistently.

Field Constraints — More Than Just a Data Type

A plain type hint (str, int, bool) only validates type. For additional rules — minimum length, numeric ranges, and the like — Pydantic provides Field:

from pydantic import BaseModel, Field


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)

Now, if title is sent as an empty string (""), FastAPI automatically rejects it with 422, complete with the message "String should have at least 1 character". We didn't write if len(title) < 1: raise ... by hand — this rule lives in the model definition, in one place, applied consistently across every endpoint that accepts TaskCreate.

A few other common constraints you'll see often:

class Example(BaseModel):
    quantity: int = Field(ge=1, le=100)       # ge = greater/equal, le = less/equal
    email: str = Field(pattern=r"^[^@]+@[^@]+\.[^@]+___CODE_BLOCK_PLACEHOLDER___1___CODE_BLOCK_PLACEHOLDER___quot;)

Enum — Restricting a Value to a Set of Valid Choices

For a field whose value must come from a fixed list — like task priority — use Python's standard Enum, combined with str so the value stays a plain string in JSON:

from enum import Enum


class Priority(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    priority: Priority = Priority.medium

Once Priority is used as a field type, FastAPI automatically: rejects any value outside low/medium/high with 422, AND shows a dropdown of the valid choices on the /docs page — not just an empty text field. API consumers no longer have to guess what values are accepted.

Separating the Request Model From the Response Model

So far we've only had one model, TaskCreate, used to accept data. But the model for receiving data and the model for returning data often need to be different. A concrete example: the id field doesn't make sense on a task-creation request (there's no ID yet — the server generates it), but it's REQUIRED on the response.

class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    priority: Priority = Priority.medium


class TaskUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=200)
    done: bool | None = None
    priority: Priority | None = None


class TaskRead(BaseModel):
    id: int
    title: str
    done: bool
    priority: Priority

Notice TaskUpdate: every field is optional (| None, default None). This is the common pattern for PATCH endpoints — the API consumer only sends the fields they want to change, not the whole object.

Now attach TaskRead as the response_model on the endpoint:

@app.post("/api/tasks", response_model=TaskRead, status_code=201)
def create_task(payload: TaskCreate):
    global next_id
    task = {"id": next_id, "title": payload.title, "done": False, "priority": payload.priority}
    tasks.append(task)
    next_id += 1
    return task

With response_model=TaskRead, FastAPI does two things automatically: it re-validates the data we return to make sure it matches the TaskRead schema (a safety net in case there's a bug in our code), and it filters fields — if our internal object ever gains a sensitive field (an internal note, a debug flag), that field will never get exposed in the response as long as it's not defined on TaskRead.

The PATCH and DELETE Endpoints

Now let's round out the CRUD:

from fastapi import HTTPException, status


@app.patch("/api/tasks/{task_id}", response_model=TaskRead)
def update_task(task_id: int, payload: TaskUpdate):
    task = next((t for t in tasks if t["id"] == task_id), None)
    if task is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")

    updates = payload.model_dump(exclude_unset=True)
    task.update(updates)
    return task


@app.delete("/api/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int):
    task = next((t for t in tasks if t["id"] == task_id), None)
    if task is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
    tasks.remove(task)

One important detail: payload.model_dump(exclude_unset=True). This method returns a dict that only contains fields that were actually sent in the request, not every TaskUpdate field (which default to None). Without exclude_unset=True, sending just {"done": true} could accidentally overwrite an existing title with None — a bug that's easy to let slip through if you're not careful.

Also notice delete_task doesn't return anything — that matches the 204 No Content convention, which by definition must not have a response body.

HTTPException vs. Automatic Validation — Two Different Layers of Error

By this point we've run into two kinds of errors that FastAPI handles differently:

  • Automatic validation (422 Unprocessable Entity) — happens BEFORE our path operation function ever runs, when the data sent doesn't match the Pydantic schema (wrong type, missing required field, violates a Field constraint). We don't write any code for this — FastAPI and Pydantic handle it.
  • HTTPException (manual code) — we raise this ourselves inside the path operation function, for conditions that are valid in terms of data type but can't be processed in terms of business logic — for example, a task_id with the right format (a number) but where the task simply doesn't exist (404).
  • Telling these two layers apart matters: don't bother manually checking "is title a string" inside our function — that's already Pydantic's job. Reserve HTTPException for rules that need context beyond the data schema alone (data not found, no permission, conflicts with other data, and the like).

    Summary

  • Field(...) for constraints beyond the data type (length, numeric ranges).
  • Enum to restrict a field to a list of valid values, which also shows up as a dropdown on /docs.
  • Separate the request model (TaskCreate, TaskUpdate) from the response model (TaskRead) — different needs, different schemas.
  • response_model re-validates and filters the data leaving an endpoint.
  • Pydantic validation automatically handles data-shape errors; HTTPException is for business-logic errors we raise manually.
  • Topics

    FastAPIPydantic