Skip to content

Validation errors

One 422 contract across every request location, reporting every failure at once. Includes the complete Pydantic error-type catalog and how to customize messages and responses.

A validation failure returns 422 Unprocessable Entity with every problem found, across every location, in a single response:

{
"detail": [
{"loc": ["query", "page"], "msg": "Input should be greater than or equal to 1",
"type": "greater_than_equal", "input": "0"},
{"loc": ["body", "email"], "msg": "Field required", "type": "missing"}
]
}

Every entry has the same four keys:

KeyMeaning
locPath to the offending value, starting with the request location
msgHuman-readable description, suitable for showing a developer
typeMachine-readable identifier, stable across Pydantic releases
inputThe value that was rejected, when available

Build client-side handling on type, never on msg — messages are wording and may be improved; types are a contract.

The first element names the location that failed, so a client can tell a malformed query string from a malformed body without guessing:

loc[0]Source
queryURL query string
headerRequest header
cookieCookie
pathURL path segment
bodyJSON body
formForm field or upload
responseThe handler’s own output

Remaining elements are the field path, which nests for nested models and indexes into collections:

{"loc": ["body", "address", "postcode"]}
{"loc": ["body", "items", 0, "sku"]}
{"loc": ["query", "ids", 2]}

The name reported is the wire name. If a parameter called page_num has alias="page", errors say page — what the client actually sent, not your internal identifier.

A request with a bad query parameter and a malformed body reports both. Clients fix everything in one round trip rather than discovering problems one at a time:

{"detail": [
{"loc": ["path", "team_id"], "msg": "Input should be a valid integer", "type": "int_parsing"},
{"loc": ["query", "page"], "msg": "Input should be a valid integer", "type": "int_parsing"},
{"loc": ["header", "X-Count"], "msg": "Field required", "type": "missing"}
]}

The one exception is forms: if a Form field fails, missing-file checks for that request are not also reported.

These are the type values you will actually encounter. Knowing them makes client-side handling straightforward.

typeCause
missingA required field was absent
extra_forbiddenAn unknown key, when the model sets extra="forbid"
json_invalidThe body was not parseable JSON
model_typeA non-object body where an object was declared
model_attributes_typeThe value could not be read as a model or object
typeCause
int_parsingA string that is not an integer
int_typeA value of the wrong type entirely for int
int_from_floatA float with a fractional part given to an int
float_parsingA string that is not a number
bool_parsingNot one of the accepted boolean spellings
string_typeA non-string given to str — note that numbers are not stringified
bytes_typeWrong type for bytes
decimal_parsingNot a valid decimal
uuid_parsingMalformed UUID
datetime_parsing / date_parsing / time_parsingMalformed date or time
enumNot one of the enum’s members
literal_errorNot one of the Literal values
typeConstraint
greater_thangt
greater_than_equalge
less_thanlt
less_than_equalle
multiple_ofmultiple_of
string_too_shortmin_length on a string
string_too_longmax_length on a string
string_pattern_mismatchpattern
too_short / too_longlength constraints on a collection
typeCause
value_errorA ValueError raised in your validator
assertion_errorA failed assert in your validator

Raising ValueError in a validator puts your text in msg, prefixed with Value error,:

from pydantic import BaseModel, field_validator
class UserCreate(BaseModel):
username: str
@field_validator("username")
@classmethod
def no_reserved(cls, v: str) -> str:
if v.lower() in {"admin", "root"}:
raise ValueError("that username is reserved")
return v
{"loc": ["body", "username"], "msg": "Value error, that username is reserved",
"type": "value_error"}

For a fully custom type as well as a message, raise PydanticCustomError:

from pydantic_core import PydanticCustomError
@field_validator("username")
@classmethod
def no_reserved(cls, v: str) -> str:
if v.lower() in {"admin", "root"}:
raise PydanticCustomError(
"reserved_username",
"The username '{name}' is reserved",
{"name": v},
)
return v
{"loc": ["body", "username"], "msg": "The username 'admin' is reserved",
"type": "reserved_username"}

A stable custom type is what lets a client show a translated message of its own.

Register a handler for RequestValidationError:

from sillo import silloApp, RequestValidationError
app = silloApp()
async def my_validation_handler(request, response, exc):
return response.json(
{"ok": False, "errors": exc.errors},
status_code=400,
)
app.add_exception_handler(RequestValidationError, my_validation_handler)

The exception carries:

  • exc.errors — the list of error dicts, already location-prefixed
  • exc.body — the raw payload that failed, when available

Many frontends expect errors keyed by form field:

async def flat_errors(request, response, exc):
out = {}
for err in exc.errors:
field = ".".join(str(p) for p in err["loc"][1:]) or err["loc"][0]
out.setdefault(field, []).append(err["msg"])
return response.json({"errors": out}, status_code=422)
{"errors": {"email": ["Field required"],
"address.postcode": ["String should match pattern '^[0-9]{5}$'"]}}

input echoes the rejected value, which is convenient in development and undesirable when the field is a password:

SENSITIVE = {"password", "token", "secret", "card_number"}
async def redacted(request, response, exc):
errors = []
for err in exc.errors:
err = dict(err)
if any(s in str(err["loc"][-1]).lower() for s in SENSITIVE):
err.pop("input", None)
errors.append(err)
return response.json({"detail": errors}, status_code=422)

Declaring such fields as SecretStr is the more thorough fix, since it protects logs and tracebacks too.

When a handler’s return value violates its response_model, sillo raises ResponseValidationError and returns 500 — the caller did nothing wrong. The offending value is logged and never echoed to the client. Customize it the same way:

from sillo import ResponseValidationError
async def on_response_invalid(request, response, exc):
alert_oncall(path=request.url.path, errors=exc.errors)
return response.json({"error": "Internal Server Error"}, status_code=500)
app.add_exception_handler(ResponseValidationError, on_response_invalid)

These are worth alerting on rather than merely logging. Each one means your API is documented as returning something it did not return.

For a rule that cannot live in a model — a uniqueness check against the database, say — raise the same error so the client sees one consistent shape:

from sillo import RequestValidationError
@app.post("/users", request_model=UserCreate)
async def create_user(request, response, user):
if await User.exists(email=user.email):
raise RequestValidationError([{
"loc": ["body", "email"],
"msg": "That email is already registered",
"type": "value_error.unique",
}])
...

A pydantic.ValidationError from your own code

Section titled “A pydantic.ValidationError from your own code”

If you validate a model by hand and let the error escape, sillo catches it and returns a nested object:

{"error": "Validation Error",
"errors": {"username": "Field required",
"address": {"city": "Field required"}}}

This shape predates the unified contract and applies only to models you validate yourself. Override it by registering a handler for pydantic.ValidationError.

Bodies declared with request_model= return a bare list of Pydantic errors for the same historical reason. Enable strict_validation=True to move them onto the unified envelope:

app = silloApp(strict_validation=True)
{"detail": [{"loc": ["body", "age"], "msg": "Field required", "type": "missing"}]}
SituationStatusWhy
Bad parameter, body, or form422The client sent something the schema rejects
Malformed JSON422Still a client mistake
Missing required input422
Handler violates its response_model500A server-side contract breach
Route not matched404Resolved before validation runs
Authentication failed401Resolved before validation runs

Validation runs after routing and authentication, so a 404 or 401 is never masked by a 422 — and an unauthenticated caller never learns which of your fields are required.

A 422 body is an API surface. Clients parse it, display it, and depend on its shape — which means changing it later is a breaking change, and getting it right early is worth the ten minutes.

Three properties make a validation error usable.

It is machine-readable. loc and type are stable identifiers a client can branch on. A message is for humans and may be reworded; a type of greater_than_equal will not change under you.

It reports everything at once. A form with four bad fields should produce four errors in one response, not four round trips. sillo’s validator collects across every location before responding, which is what makes single-pass form rendering possible.

It maps to the client’s own field names. loc uses the alias when a field has one, so a client sending user_name sees user_name in the error rather than the internal username. That is the behaviour you want; it means aliases are part of the contract, not an implementation detail.

turning a 422 into form errors
def to_field_errors(detail: list[dict]) -> dict[str, str]:
"""Map a sillo 422 body into {field: message} for a form renderer."""
errors = {}
for item in detail:
# loc is like ["body", "address", "postcode"] — take the last segment
field = ".".join(str(p) for p in item["loc"][1:]) or item["loc"][0]
errors.setdefault(field, item["msg"])
return errors

Taking loc[1:] drops the location prefix, and setdefault keeps the first error per field — showing four messages under one input is worse than showing one.

A validation failure is a client mistake, not a server fault, so logging every one at ERROR will bury real problems. Log them at INFO or DEBUG, and alert on the rate rather than on individual events: a sudden spike in 422s usually means a client deployed a change, and that is worth knowing.

Never log the rejected value. A 422 on a password field means the log line contains a password; a 422 on a card_number field means it contains a card number. Log the loc and the type, which identify what was wrong without reproducing it.

safe validation logging
@app.add_exception_handler(RequestValidationError)
async def on_validation_error(request, response, exc):
logger.info(
"validation failed on %s: %s",
request.url.path,
[(e["loc"], e["type"]) for e in exc.errors], # no values
)
return response.json({"detail": exc.errors}, status_code=422)

The same rule applies to the response body. Pydantic’s default messages sometimes embed the input — Input should be a valid integer, unable to parse string as an integer is safe, but a custom message that interpolates the value is not. Check any message you write yourself.

The status code tells a client what kind of fix is needed, and three codes get confused.

422 — the request was well-formed and understood, but a value is unacceptable. A string where an integer belongs, a missing required field, a number out of range. The client should fix the value and retry.

400 — the request itself is malformed: unparseable JSON, a broken multipart boundary, a header that cannot be decoded. The client should fix how it constructs the request.

409 — the request is entirely valid and conflicts with current state. An email that is already registered, a version that has moved on. Nothing about the request is wrong; the world changed. Retrying unchanged will fail again.

The one people reach for wrongly is 422 for uniqueness. “Email already taken” is not a validation error — the value is a perfectly good email — and returning 422 tells a client to fix the format of something that is correctly formatted. Use 409, and let the database constraint be what detects it.