Skip to content

Validation Errors

The 422 a client receives. The error structure, what loc means, how failures from several locations are reported together, and how to customise the message or the whole response.

When validation fails, Sillo returns 422 Unprocessable Entity with every failure listed:

{
"detail": [
{
"loc": ["body", "title"],
"msg": "String should have at least 1 character",
"type": "string_too_short",
"input": ""
},
{
"loc": ["query", "page"],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"type": "int_parsing",
"input": "abc"
}
]
}

Note that both failures are in one response. A bad query parameter and a malformed body are reported together rather than one per round trip. A client fixing a form gets every problem at once instead of discovering them in sequence.

KeyMeaning
locPath to the failure. First element is the request location.
msgHuman-readable message
typeMachine-readable error code
inputThe value that failed, when available

Pydantic’s own url key (a link to pydantic.dev) is stripped. It is noise in an HTTP API response, and it points at documentation for a library the client may not be using.

The first element names where the value came from:

body, query, path, header, cookie, form.

["body", "title"] a JSON body field
["query", "page"] a query parameter
["path", "post_id"] a path parameter
["body", "lines", 2, "quantity"] the third line's quantity
["body", "address", "postcode"] a nested field

That prefix is Sillo’s addition. Pydantic reports locations relative to the model it validated, which for a query string is a synthetic per-location model, so a failure would otherwise arrive as just ["page"] with no indication of where page came from.

Aliases are resolved. When a field has a validation alias, the path shows the wire name the client actually sent, not the Python identifier:

event_type: str = Field(alias="eventType")
{"loc": ["body", "eventType"], "msg": "Field required"}

Reporting event_type would name a key the client has never seen.

typeCause
missingA required field was absent
string_too_short / string_too_longmin_length / max_length
string_pattern_mismatchpattern
int_parsing / float_parsingNot a number
int_type / string_type / bool_typeWrong type in strict mode
greater_than / less_than_or_equalNumeric bounds
too_short / too_longCollection length
enumNot one of the permitted values
value_errorA ValueError from your own validator
extra_forbiddenAn unknown field with extra="forbid"
json_invalidThe body was not valid JSON

type is the key to branch on in a client. msg is for humans and its wording can change between Pydantic versions; type is stable.

@field_validator("slug")
@classmethod
def url_safe(cls, value: str) -> str:
if not re.fullmatch(r"[a-z0-9-]+", value):
raise ValueError("must be lowercase letters, digits and hyphens")
return value
{
"loc": ["body", "slug"],
"msg": "Value error, must be lowercase letters, digits and hyphens",
"type": "value_error"
}

Pydantic prefixes Value error, . Write the message as a continuation of that so it reads properly, and write it for whoever has to fix the request, “invalid” tells them nothing.

For a custom type as well as a message:

from pydantic_core import PydanticCustomError
@field_validator("slug")
@classmethod
def url_safe(cls, value: str) -> str:
if not re.fullmatch(r"[a-z0-9-]+", value):
raise PydanticCustomError(
"slug_format",
"must be lowercase letters, digits and hyphens",
)
return value
{"loc": ["body", "slug"], "msg": "...", "type": "slug_format"}

Worth it when a client needs to react to that specific failure rather than displaying the message.

The default shape is {"detail": [...]}. To change it (to match an existing API convention, or to add a request id) register your own handler:

from sillo.validation import RequestValidationError
from sillo import HttpContext, json
async def validation_handler(ctx: HttpContext, exc: RequestValidationError):
return json(
{
"error": "validation_failed",
"request_id": ctx.state.get("request_id"),
"fields": [
{"field": ".".join(str(p) for p in e["loc"][1:]), "message": e["msg"]}
for e in exc.errors
],
},
status_code=422,
)
app.add_exception_handler(RequestValidationError, validation_handler)

exc.errors is the flat list; exc.body is the raw payload that failed, when one was available.

When a handler returns something its response_model does not permit, that is a server-side bug. The client sent a valid request and your application produced an invalid response.

{"error": "Internal Server Error", "detail": "Response validation failed"}

Status 500. Returning 422 would blame the caller and would mislead clients that retry on 4xx.

The offending value is deliberately not echoed. It may contain exactly the data the response model existed to filter out. It is logged instead, with the method and path, so you can find it.

Outside a handler, Pydantic raises ValidationError directly:

from pydantic import ValidationError
try:
payload = PostCreate.model_validate(data)
except ValidationError as exc:
for error in exc.errors():
print(error["loc"], error["msg"])
exc.errors() # list of dicts
exc.error_count() # how many
exc.json() # as JSON

Useful in a console command or a queued job, where there is no HTTP response to produce and you want to report the failures yourself.

from sillo import json
def test_title_is_required(client):
response = client.post("/posts", json={"body": "…"})
assert response.status_code == 422
errors = json()["detail"]
assert any(e["loc"] == ["body", "title"] and e["type"] == "missing" for e in errors)

Assert on loc and type, not on msg. The message is Pydantic’s wording and can change under you on a minor upgrade; the type is the contract.