Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Pydantic Request Models for Creating and Updating Incidents

Hello! Last lesson, you gave incident routes typed path and query parameters. FastAPI could reject an invalid ID or limit before the handler ran, but clients still had no way to send incident data.

Today you’ll define Pydantic request models for two different jobs: creating an incident and proposing changes to one. You’ll connect them to POST and PATCH routes and inspect how FastAPI validates JSON bodies. The routes will demonstrate the input contract; they will not yet store or modify incidents.


A JSON body needs a different kind of contract

Path and query parameters work well for an ID or a small request option. An incident creation request has several related fields, so the client sends a JSON object in the request body. A Pydantic class describes the object FastAPI should accept.

Read the relevant parts of FastAPI’s Request Body guide first. Pay particular attention to the distinction between a field’s type and whether it has a default—it is the same distinction you met with query parameters.

Request Body - FastAPI

FastAPI’s Request Body guide shows how a Pydantic model becomes a validated JSON input and an OpenAPI schema.

In “Create your data model,” read model fields, including the example between those sentences. Then, under “Declare it as a parameter” and “Results,” follow the endpoint example and read request processing. Focus on what FastAPI does before handing the model instance to the function.

The model is more than a type hint for your editor. FastAPI recognizes a handler parameter typed as a Pydantic model, reads the JSON body, validates it, and either supplies an instance of that model to your handler or returns a 422 error. It also publishes the model’s shape in /docs.

For a concise demonstration of BaseModel and field bounds, watch the schema-building portion of Corey Schafer’s Python FastAPI Tutorial (Part 4): Pydantic Schemas - Request and Response Validation.

Python FastAPI Tutorial (Part 4): Pydantic Schemas - Request and Response Validation

Corey Schafer defines a Pydantic schema with Field constraints, then creates a distinct input model. Watch for how a field without a default becomes required.

Watch model and constraints, followed by creation input. The video’s shared base class suits its example; we’ll write separate create and update models because those operations have different required fields.


Define what a client may submit

Create src/incident_api/schemas.py. This example includes a severity enum so the code stands on its own. If your earlier domain code already defines incident severity, import that enum instead of maintaining a second copy, and use its actual values when trying the requests below.

from enum import Enum

from pydantic import BaseModel, ConfigDict, Field


class IncidentSeverity(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"


class IncidentCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    title: str = Field(min_length=1, max_length=120)
    description: str | None = Field(default=None, max_length=2000)
    severity: IncidentSeverity

IncidentCreate answers a precise question: What must a client provide to request a new incident? title and severity have no defaults, so both are required. description defaults to None, so it can be omitted; its type also allows an explicit JSON null. A supplied description string must satisfy its length bound. A supplied severity must be one of the enum values.

The length bounds are useful baseline checks, not a complete business rule. For example, min_length=1 does not reject a title containing only spaces. You’ll add more expressive field and cross-field validation in the next lesson.

Notice what is absent: clients cannot provide an id, a creation timestamp, or a status through this model. An ID and timestamp should come from the application’s creation process; status changes will need their own rules. extra="forbid" makes this boundary explicit: an unrecognized JSON property produces a validation error instead of being silently ignored. This is an input schema, not a domain dataclass or a database model.


Updates need a different shape

Creation requires enough information to make a new incident. A partial update should let a client send just the fields it intends to change. Watch the focused portion of Corey Schafer’s Python FastAPI Tutorial (Part 6): Completing CRUD - Update and Delete (PUT, PATCH, DELETE) for that difference.

Python FastAPI Tutorial (Part 6): Completing CRUD - Update and Delete (PUT, PATCH, DELETE)

Corey Schafer introduces a separate partial-update schema and demonstrates how model_dump(exclude_unset=True) retains only fields supplied in a PATCH request.

Watch update schema, then supplied fields. Focus on the schema and exclude_unset=True; do not copy the video’s database lookup or dynamic attribute-setting code into this input-only demonstration.

Add a second model to schemas.py:

class IncidentUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    title: str | None = Field(default=None, min_length=1, max_length=120)
    description: str | None = Field(default=None, max_length=2000)
    severity: IncidentSeverity | None = None

Every field can be omitted in IncidentUpdate. When a non-null title or description is supplied, its bounds still apply; a supplied severity must still be an enum value. A PATCH body such as {"title": "Deployment fails"} therefore does not require a description or severity.

There is an important limitation to this simple model: making title and severity optional with | None also permits clients to submit them as explicit null. That does not mean a persisted incident should have a null title or severity. For now, these routes only inspect proposed input; do not apply such a value to an incident. A later lesson on PATCH semantics will distinguish omission from explicit null and define what each field permits.

The models are deliberately separate rather than making IncidentUpdate inherit from IncidentCreate. Inheritance would also inherit creation’s required fields, which is the wrong contract for a partial update. If you later choose to support full replacement with PUT, decide its required fields separately rather than using the PATCH model by default.


Attach the models to the existing routes

Keep the GET routes and parameter definitions from the previous lesson. In src/incident_api/main.py, import the new models:

from incident_api.schemas import IncidentCreate, IncidentUpdate

Then add these handlers alongside your existing ones. The PATCH handler uses the Annotated and Path imports already used by your GET route.

@app.post("/incidents")
def validate_incident_create(
    incident: IncidentCreate,
) -> dict[str, object]:
    return {"validated_input": incident.model_dump(mode="json")}


@app.patch("/incidents/{incident_id}")
def validate_incident_update(
    incident_id: Annotated[int, Path(gt=0)],
    changes: IncidentUpdate,
) -> dict[str, object]:
    return {
        "validated_for_id": incident_id,
        "proposed_changes": changes.model_dump(
            exclude_unset=True,
            mode="json",
        ),
    }

FastAPI takes incident_id from the URL path, just as it did last lesson. It takes incident and changes from JSON bodies because their types are Pydantic models. model_dump(mode="json") gives us JSON-compatible data for the demonstration response.

For PATCH, exclude_unset=True matters. Without it, a request containing only title would dump defaults for the other fields too. With it, the proposed changes contain only fields the client supplied:

PATCH body proposed_changes
{"title": "Deployment fails"} {"title": "Deployment fails"}
{"description": null} {"description": null}
{} {}

Thus, omitted and explicitly null remain distinguishable in the resulting dictionary. That distinction is useful, but the handlers above do not yet decide whether an empty update or a particular null value should be allowed as an operation.

Neither handler creates, retrieves, nor updates an incident. They return validation demonstrations, so they do not claim a 201 Created response, check whether an ID exists, or make any persistence promise. Later, application logic and response models will give these routes their full behavior.


Inspect the HTTP contract

Run the development server as before and open /docs. Expand POST /incidents and PATCH /incidents/{incident_id}. The request schemas should show required creation fields but no required update fields. Try these requests as well:

curl -i -X POST 'http://127.0.0.1:8000/incidents' \
  -H 'Content-Type: application/json' \
  -d '{"title":"Deployment fails","severity":"high"}'

curl -i -X POST 'http://127.0.0.1:8000/incidents' \
  -H 'Content-Type: application/json' \
  -d '{"title":"Deployment fails"}'

curl -i -X POST 'http://127.0.0.1:8000/incidents' \
  -H 'Content-Type: application/json' \
  -d '{"title":"Deployment fails","severity":"high","id":42}'

curl -i -X PATCH 'http://127.0.0.1:8000/incidents/42' \
  -H 'Content-Type: application/json' \
  -d '{"description":null}'

The first request passes validation. The next two receive 422: one omits a required field; the other supplies a forbidden extra field. Inspect detail and its loc entries to identify the offending body field. The PATCH request returns a proposed change containing description: null, not an updated incident. Its positive path ID passes validation, but incident 42 has not been looked up.

This is the same boundary-first approach you used for URL inputs: define what can reach a handler before adding domain decisions or storage.


Takeaways

IncidentCreate and IncidentUpdate express different request contracts. Pydantic uses annotations, defaults, enums, and Field constraints to validate their JSON bodies; FastAPI exposes those contracts in OpenAPI and rejects malformed input before the handler runs. For partial updates, model_dump(exclude_unset=True) preserves which fields the client actually supplied, including an explicit null.

Next, you’ll add field and model validation for incident rules that basic types and length bounds cannot express.

Can't find a good explanation? Sign up and we'll make it for you

Sign up