Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson, you defined separate request models for creating an incident and proposing a partial update. Pydantic now checks types, enum values, and length limits before a handler runs. Those checks still allow a title made entirely of spaces, and they cannot express a rule involving both severity and description.
In this lesson, you’ll add field validators for meaningful text and a model validator for a cross-field creation rule: a high-severity incident must have a description. You’ll also see why that same rule cannot be applied to a PATCH request without knowing the incident’s current state. Allow about 40 minutes, including the short video segments and request checks.
Give each rule the right scope
A useful way to choose a validation tool is to ask what information the rule needs. A length bound such as Field(max_length=120) needs only one value and no custom code. Checking whether a title contains something besides whitespace needs custom logic, but still only one field. Checking whether a high-severity incident has a description needs the validated values of two fields.
Watch the focused portions of Corey Schafer’s Python Pydantic Tutorial: Complete Data Validation Course (Used by FastAPI). The examples use users rather than incidents; concentrate on when each validator runs and what it must return.
Python Pydantic Tutorial: Complete Data Validation Course (Used by FastAPI)
Corey Schafer demonstrates Pydantic v2 field and model validators, including normalization, validation failures, and rules involving two fields.
Watch field validators for the pattern of checking a value, raising ValueError, and returning the accepted value. Then skip the mode="before" URL example and watch model validators for the comparison of two fields on a validated model.
We’ll use after validators: Pydantic first checks the declared type and existing Field constraints, then passes the value—or, for a model validator, the validated instance—to our code.
Reject blank text at the field boundary
Continue editing src/incident_api/schemas.py. Keep the severity enum and both models from the previous lesson. Add field_validator to your Pydantic imports, and place this small helper below the imports or enum:
def _nonblank(value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("must contain non-whitespace characters")
return cleaned
This does two deliberate things. It rejects whitespace-only text, which min_length=1 does not catch, and it returns trimmed text so the handler receives a consistent value. The existing Field length bounds still apply before this after validator runs. Thus, text that exceeds the maximum length before trimming remains invalid; we are not using trimming to bypass the input limit.
Inside IncidentCreate, after its field declarations, add:
@field_validator("title")
@classmethod
def validate_title(cls, value: str) -> str:
return _nonblank(value)
@field_validator("description")
@classmethod
def validate_description(cls, value: str | None) -> str | None:
if value is None:
return None
return _nonblank(value)
The description validator preserves the contract you already chose: for creation, description may be omitted or explicitly null. But if a string is supplied, it cannot be blank. Each validator returns a value on success; a raised ValueError tells Pydantic that the input failed validation. The @classmethod pattern is for field validators—the later after model validator will instead receive the model instance.
For example, creating an incident with title " Deployment fails " produces a validated title of "Deployment fails". A title of " " fails even though its original length is greater than zero.
Check a rule across fields
Now add model_validator to the Pydantic imports and import Self from typing if your project uses Python 3.11 or later. For an earlier Python version, import Self from typing_extensions instead.
Read the relevant parts of Pydantic’s Validators documentation before adding the rule. The official example compares two fields; our comparison will involve severity and description.
Read the Pydantic documentation to confirm when an after model validator runs, why it returns the instance, and how to report an invalid value.
Under “Model validators,” start with the explanation of whole-model validation. Read the after rule and the password-comparison example immediately below it; stop before “Before validators.” Then, under “Raising validation errors,” read error choices through the AssertionError bullet. For this lesson, focus on ValueError; the custom-error variant is not needed.
Inside IncidentCreate, after its field validators, add:
@model_validator(mode="after")
def require_high_severity_description(self) -> Self:
if (
self.severity is IncidentSeverity.HIGH
and self.description is None
):
raise ValueError(
"high severity incidents require a description"
)
return self
If your project already has a severity enum, use its existing high-severity member rather than introducing another enum. The after model validator receives self only after Pydantic has validated the fields, so it can compare self.severity with self.description. It must return self when the rule passes.
Consider how the two layers cooperate:
| Creation input | Result |
|---|---|
"severity": "low", description omitted |
Valid |
"severity": "high", description omitted |
Invalid cross-field rule |
"severity": "high", "description": " " |
Invalid description field |
"severity": "high", "description": " Alerts firing " |
Valid; description is trimmed |
The whitespace-only case fails at the field validator. Do not rely on the model validator to run when a field has already failed validation. Likewise, the model rule is not a replacement for checking that a supplied description is meaningful.
Keep partial-update validation honest
Apply the text rule to IncidentUpdate too. Its fields are all omittable, and its title and description currently accept explicit null; this validator should not silently change either part of that contract. Add this method inside IncidentUpdate, after its fields:
@field_validator("title", "description")
@classmethod
def validate_supplied_text(
cls, value: str | None
) -> str | None:
if value is None:
return None
return _nonblank(value)
This single validator covers two fields because they need the same rule when their value is a string. An omitted field has no supplied value to clean. An explicit null remains None; the previous lesson deliberately left the meaning of null in PATCH for a later decision.
Do not copy the creation model validator directly onto IncidentUpdate. Suppose a client sends only {"severity": "high"}. That could be a valid change if the stored incident already has a description. The PATCH request model sees only the proposed changes, not the stored incident, so rejecting this body as “missing description” would be incorrect. Conversely, accepting its input shape does not prove the eventual updated incident satisfies the rule. Once the application retrieves an incident and applies changes, it must check rules about the resulting state. That work belongs beyond this request-only demonstration.
The same boundary matters for status transitions: request validation can establish that a submitted status has a permitted value, but deciding whether a transition is allowed requires the incident’s current status.
Observe the FastAPI behavior
Keep the demonstration POST and PATCH handlers from the previous lesson. Restart the development server if necessary, then try representative requests:
curl -i -X POST 'http://127.0.0.1:8000/incidents' \
-H 'Content-Type: application/json' \
-d '{"title":" ","severity":"low"}'
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 ","severity":"high","description":" Alerts firing "}'
curl -i -X PATCH 'http://127.0.0.1:8000/incidents/42' \
-H 'Content-Type: application/json' \
-d '{"severity":"high"}'
The first two POST requests should receive 422 responses. Inspect detail: the blank title points to a body field, while the rule involving the entire creation model may point to the body as a whole. The third POST passes and returns trimmed text in validated_input. The PATCH body passes request validation and returns proposed changes; it still neither looks up incident 42 nor guarantees that an update would be valid.
You may notice that /docs shows the types and length bounds more directly than the “high severity requires description” rule. Custom Python validation runs at request time, but clients also need that conditional rule explained in API documentation. For now, verifying its actual HTTP behavior is more informative than assuming the generated schema tells the whole story.
Takeaways
Use Field for straightforward bounds, an after field validator for a rule or normalization involving one supplied value, and an after model validator when a rule needs multiple validated fields. Return the accepted value or model; raise ValueError with a useful message when it is invalid.
Your creation model can now reject blank text and require a description for high-severity incidents. Your update model rejects supplied blank strings without pretending it knows the incident’s stored state. Next, you’ll define response models so successful API responses expose only the fields clients should see.