Every field checked, every failure reported at once - and how to turn that into a message somebody can act on.
Overview
One raise, every problem
When data fails validation, Pydantic raises a ValidationError. It does not stop at the first problem. It checks every field and collects all the failures into one error, so you can see everything that is wrong in a single pass.
That error is more useful than it first looks. Each problem has a location, a type, a message, and the input that caused it. The printed version is made for a person reading a terminal. The structured version is made for code, and that is the one you want when you turn an error into a response for an API client.
This article shows how to read the error, and then how to use it. You will learn what the errors method returns and what each key means. You will see how the location path points to a nested field, and why you should match on the error type rather than the message text. The last part shapes the errors into a clean format that a client can act on. If you ever write code that handles bad input, this is the article to read.
Good error handling is what separates a model that merely works from one people can use. A clear error saves a support request. A vague one costs an afternoon, so it pays to learn the format well and early.
Most hand-written check gives up at the first thing it doesn't like. The caller fixes it, resubmits. Meets the next one. Four mistakes take four round trips.
Pydantic checks each field and raises once, carrying the full list. For a signup form that is the gap between a form which highlights all four broken inputs at once and one which reveals them slowly, like a bad quiz.
e.error_count() tells you how many. str(e) gives a readable summary. This Is what you saw printed in the first editor above. Both are for humans.
Worth knowing
One exception carries every failure. e.error_count() is how many, e.errors() is the list.
Each entry has loc (where), type (which rule), msg (prose) and input (what arrived).
loc is a tuple describing a path. Integers are list indices, so ('lessons', 1, 'minutes') means the second lesson's duration.
Match on type, never on msg. Types are stable identifiers; messages are prose and get reworded.
input is usually the fastest diagnosis available — it shows what the caller actually sent, which is often not what they believed they sent.
A ValueError raised inside your own validator arrives in the same list with type value_error, so custom rules and built-in ones are handled by one piece of code.
Reading a ValidationError, and Turning It Into a Message
each field checked, each failure in one report - and what each part of an entry is for.
It does not stop at the first problem
Every field is checked and one exception is raised carrying all of them. That is what lets a form highlight four broken inputs at once instead of revealing them one submission at a time.
example_01.pyPydantic
from pydantic import BaseModel, ValidationError
class Module(BaseModel):
title: str
track: str
minutes: int
published: bool
try:
Module(title=None, track=42, minutes="soon", published="perhaps")
except ValidationError as e:
print("count:", e.error_count())
print()
print(e)
Output
errors() is the version for code
Printing the exception gives prose for a human. errors() gives a list of dicts — the version you build an API response or a translated message from.
example_02.pyPydantic
from pydantic import BaseModel, ValidationError
class Module(BaseModel):
title: str
minutes: int
try:
Module(title=None, minutes="soon")
except ValidationError as e:
for err in e.errors():
print("loc :", err["loc"])
print("type :", err["type"])
print("msg :", err["msg"])
print("input :", repr(err["input"]))
print("-" * 40)
Output
e.errors() returns a list of dicts. This is what you build anything real on top of. Each entry has four parts, and they do other jobs.
**loc** is where the problem is. It's a tuple, not a string. It shows a path rather than naming a field. For a flat model it has one element. For nested data it has as many as it needs to reach the value.
**type** is which rule failed, as a short stable identifier: missing, int_parsing, string_too_short, greater_than, value_error.
**msg** is the human sentence, in English, written by Pydantic.
**input** is the value that really arrived.
loc is a path, not a name
For a flat model it is just the field. For anything nested it is the route to the value: a list index is an integer, a nested field is a string.
example_03.pyPydantic
from typing import List
from pydantic import BaseModel, ValidationError
class Lesson(BaseModel):
name: str
minutes: int
class Track(BaseModel):
title: str
lessons: List[Lesson]
bad = {
"title": "Maths",
"lessons": [
{"name": "Vectors", "minutes": 8},
{"name": "Norms", "minutes": "later"}, # index 1
{"name": None, "minutes": 5}, # index 2
],
}
try:
Track.model_validate(bad)
except ValidationError as e:
for err in e.errors():
path = ".".join(str(p) for p in err["loc"])
print("%-22s %s" % (path, err["msg"]))
Output
type is the stable handle
The message is prose and may be reworded between releases. The type is a machine code that will not move — match on it, not on the sentence.
example_04.pyPydantic
from pydantic import BaseModel, Field, ValidationError
class Module(BaseModel):
title: str = Field(min_length=3)
minutes: int = Field(gt=0)
FRIENDLY = {
"string_too_short": "That needs to be a bit longer.",
"greater_than": "That has to be more than zero.",
"int_parsing": "Please enter a whole number.",
"missing": "This one is required.",
}
for payload in [{"title": "Hi", "minutes": 5},
{"title": "Vectors", "minutes": -3},
{"title": "Vectors", "minutes": "ten"},
{"title": "Vectors"}]:
try:
Module.model_validate(payload)
print("%-38s ok" % str(payload))
except ValidationError as e:
err = e.errors()[0]
nice = FRIENDLY.get(err["type"], err["msg"])
print("%-38s %s -> %s" % (str(payload), err["loc"][0], nice))
Output
Turning errors into a response
The shape most APIs want: a mapping of field name to a list of messages, ready to render beside the inputs that produced them.
example_05.pyPydantic
import json
from pydantic import BaseModel, Field, ValidationError
class Signup(BaseModel):
name: str = Field(min_length=2)
email: str = Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")
modules_done: int = Field(ge=0)
def validate(payload):
try:
return Signup.model_validate(payload), None
except ValidationError as e:
problems = {}
for err in e.errors():
field = ".".join(str(p) for p in err["loc"]) or "_"
problems.setdefault(field, []).append(err["msg"])
return None, problems
model, problems = validate({"name": "A", "email": "nope", "modules_done": -1})
print("problems:")
print(json.dumps(problems, indent=2))
model, problems = validate({"name": "Ada", "email": "ada@vizlearn.in",
"modules_done": 12})
print()
print("accepted:", model)
Output
Errors you raise yourself
A validator that raises ValueError is folded into the same report, with the type value_error. Your rules and the built-in ones come back together.
example_06.pyPydantic
from pydantic import BaseModel, field_validator, ValidationError
class Module(BaseModel):
title: str
track: str
@field_validator("track")
@classmethod
def known_track(cls, v: str) -> str:
allowed = {"maths", "python", "dsa", "ml"}
if v not in allowed:
raise ValueError("unknown track %r (try one of %s)"
% (v, ", ".join(sorted(allowed))))
return v
print(Module(title="Vectors", track="maths"))
try:
Module(title=None, track="astrology")
except ValidationError as e:
print()
print("both problems, one report:")
for err in e.errors():
print(" %-8s %-12s %s" % (err["loc"][0], err["type"], err["msg"]))
Output
loc, in detail
Reading loc well is most of debugging a large payload.
ints in the tuple are sequence indices. Strings are field or key names. So ("lessons", 1, "minutes") reads as. Inside lessons, the item at index 1, its minutes field. Joining it with dots. lessons.1.minutes. Gives you a thing you can paste into a search or show to a colleague.
For a union you'll at times see the union member's name in the path too. Pydantic reports which branch it was attempting when it failed. That looks noisy at first and is truly handy when a discriminated union picks the wrong branch.
The key habit is to render the path rather than the field name. Code that does err["loc"][0] works fine until the first nested model arrives, at which point each error appears to be about the top-level holder.
Match on type, not on message
This is the rule that saves the most future pain.
Messages are prose. They get reworded, clarified and at times translated between releases. Any code that does if "should be a valid int" in err["msg"] is one minor upgrade away from silently failing.
Types are identifiers. int_parsing will still be int_parsing. Mapping them to your own copy is a dict lookup:
FRIENDLY = {
"string_too_short": "That needs to be a bit longer.",
"missing": "This one is required.",
}
This is also how you localise. The type is the key. Your translation table holds the sentences.
A handy default is to fall back to err["msg"] when a type isn't in your table. You get good copy for the cases you've thought about. A thing serviceable for the ones you haven't.
The shape an API wants
Most front ends want errors grouped by field. Each message can render beside the input that caused it:
problems = {}
for err in e.errors():
field = ".".join(str(p) for p in err["loc"]) or "_"
problems.setdefault(field, []).append(err["msg"])
A list per field, because one field can fail a few rules at once. The or "_" catches model-level errors, whose loc is empty because they belong to the whole object rather than any single field. A cross-field rule like "end date must be after start date" has nowhere else to go.
If you're using FastAPI, this change already happens for you. A ValidationError on a request body turns a 422 whose body is e.errors(). Knowing the shape means knowing what your own clients receive.
Your own errors, in the same report
Custom rules don't need a split channel. A checker that raises ValueError is caught and folded into the same list, with the type value_error and your message:
@field_validator("track")
@classmethod
def known_track(cls, v: str) -> str:
if v not in {"maths", "python", "dsa", "ml"}:
raise ValueError("unknown track %r" % v)
return v
This is a good thing to notice early. It means one piece of error-handling code covers built-in and custom check alike. A caller can't tell. Or need to care. Which kind of rule they broke.
Raise ValueError, not ValidationError. Making a ValidationError by hand is awkward and not needed. Pydantic wraps yours right and adds the location for you. An AssertionError also works. Is a poor choice because assertions vanish when Python is run with -O.
What input tells you
Don't skip input. It's often the entire diagnosis.
A caller insists they're sending a number. input shows "12" with quotes. They're sending a string. Their JSON serialiser is the culprit. Or it shows None for a field they believe they set, and their own code has a missing key. Or it shows "null", the four-character string, and a thing in the chain stringified a null.
None of that is visible in the message. All of it's visible in one line of the report.
The parts of an entry you have not used yet
Beyond loc, type, msg and input, an error entry carries two more things.
url is a link to the docs page for that error type. It's the https://errors.pydantic.dev/... line you see at the end of a printed error. It's truly handy while learning and noise in a log. It can be turned off: e.errors(include_url=False).
ctx holds the params of the rule that failed, when there are any. A greater_than error carries {"gt": 0}. A string_too_short carries {"min_length": 3}. This is what lets you write one message template per type and fill in the actual limit:
TEMPLATES = {
"greater_than": "Must be more than {gt}.",
"string_too_short": "Needs at least {min_length} characters.",
}
msg = TEMPLATES[err["type"]].format(**err.get("ctx", {}))
That is the gap between "that is too short" and "needs at least 3 characters", without hard-coding the 3 in two places.
Errors from a JSON string
model_validate_json makes the same error entries with one addition. When the JSON itself is malformed, you get a json_invalid error whose context includes the spot in the doc.
That matters for large payloads. A missing comma four hundred lines into a config file makes an error that names the line. This Is a lot more use than "invalid JSON".
It's also why checking JSON right is better than json.loads followed by model_validate. Go through json.loads and a syntax error is a JSONDecodeError from a other library. This You've to catch apart and which knows nothing about your model. Validate the JSON right and malformed docs and invalid data arrive through one exception type, handled in one place.
Model-level errors have no field
A rule that spans fields belongs to the object, not to any one field. Its loc is empty:
@model_validator(mode="after")
def check_window(self):
if self.ends_at <= self.starts_at:
raise ValueError("ends_at must be after starts_at")
return self
The resulting entry has loc: (). Any code that assumes loc[0] exists will raise an IndexError on it. Which is a bug that appears the first time somebody adds a cross-field rule, long after the error handling was written.
Handle it by name. Grouping code should fall back to a key like "_" or "__root__" for empty locations. The front end should have one spot to display an error that isn't attached to an input.
Testing that validation fails
check logic deserves tests, and the handy ones assert on type and loc rather than on prose.
import pytest
from pydantic import ValidationError
def test_minutes_must_be_positive():
with pytest.raises(ValidationError) as exc:
Module(title="Vectors", minutes=-1)
errors = exc.value.errors()
assert len(errors) == 1
assert errors[0]["loc"] == ("minutes",)
assert errors[0]["type"] == "greater_than"
Asserting on the message makes the test fail when Pydantic rewords a thing. This Teaches your team to distrust the suite. Asserting on loc and type tests the thing you really care about. That the right rule fired on the right field.
It's also worth testing the plus case by name. That a valid payload makes the values you expect, coercions included. A test that minutes="9" turns the int 9 docs an intention that is else invisible.
Making errors useful to a human
A last thought, because this is where check meets the person using your software.
The default messages are written for developers. "Input should be a valid int, unable to parse string as an int" is precise and it isn't what you want beside a form field. A user doesn't care about parsing. They care that they typed "ten" in a box that wanted a number.
The translation layer is small. A dict from type to a sentence — and it's worth building once, early, for the twenty or so error types your use can really give. Fall back to msg for anything unmapped so nothing ever renders blank.
And keep the developer version too. Logging the full errors() while showing the friendly version means that when a user says "it told me my email was wrong and it wasn't", you've the input value that settles it.
A complete handler you can lift
Putting the pieces together, this is a small function that covers all above. Grouping by field, using ctx for the limits, falling back gracefully, and handling model-level errors:
TEMPLATES = {
"missing": "This field is required.",
"int_parsing": "Please enter a whole number.",
"greater_than": "Must be more than {gt}.",
"string_too_short": "Needs at least {min_length} characters.",
}
def friendly(exc: ValidationError) -> dict:
out = {}
for err in exc.errors(include_url=False):
field = ".".join(str(p) for p in err["loc"]) or "_form"
template = TEMPLATES.get(err["type"])
if template:
message = template.format(**err.get("ctx", {}))
else:
message = err["msg"]
out.setdefault(field, []).append(message)
return out
Twelve lines, and it covers each error the use can give. New error types degrade to Pydantic's own wording rather than to a blank space. Nothing is ever invisible, and adding a nicer message later is one dict entry.
Why the design is the way it is
It's worth noticing what this error model is optimised for. It shows a few choices that look odd in alone.
It reports all at once because the costly part of check is the round trip to the user, not the checking.
It uses stable codes rather than messages because the consumer is often another program. Programs need identifiers that don't move.
It carries input because the most common question after a rejection is "what did they really send". The option is asking them.
And it makes loc a path rather than a name because real payloads nest. An error that can't say *where* in a hundred-line doc the problem is has told you almost nothing.
each one of those is a choice made for the person on the other end of the failure. Reading errors well is mostly a matter of noticing that the data you want is already in there.
One more habit
Log the full error, show the friendly one.
These are other audiences with other needs. Merging them serves neither. The user needs a sentence they can act on, in their language, next to the input that caused it. You need loc, type, input and enough context to copy the failure without asking anyone anything.
Doing both costs one extra line at the point where you catch the exception. It's the gap between a support conversation that starts with "can you tell me just what you typed" and one that starts with "I can see what happened". The data was in the exception the whole time. The only question is whether you kept it.
Questions people ask about Reading a ValidationError
Does Pydantic stop at the first error? No. It collects every problem in one ValidationError, so you can report them all at once.
What do the keys in an error entry mean?loc is the path to the bad value, msg is the human message, type is a stable code, and input is what was received.
Should I check msg or type in code? Check type. Messages can change between versions, while error type codes are stable.
How do I turn an error into JSON for an API? Call e.errors() for a list of dicts or e.json() for a string. In v2.x some entries contain exception objects under ctx, so use e.json() when you need guaranteed serialisable output.
What does an integer inside loc mean? It is a list index. ('lessons', 2, 'title') means the third lesson has a bad title.
How do I test that validation fails? Use pytest.raises(ValidationError) and then inspect exc.errors() for the type and loc you expect.
Where to go after Reading a ValidationError
The next module goes the other way: not reading the errors Pydantic makes. Writing the rules that give them. Field constraints let you say more about a value than its type. A min, a length, a pattern. And each one of them comes back through just the machinery given here.
Check yourself
0 of 4
Answer without scrolling back up.
A payload has four invalid fields. How many exceptions does Pydantic raise?
Every field is checked and a single `ValidationError` carries the complete list, so a caller can fix everything in one pass.
What does `loc` of `('lessons', 1, 'minutes')` mean?
`loc` is a path. Integers are sequence indices and strings are field names, so this locates one value inside a nested structure.
Why match on `type` rather than `msg`?
Types are stable identifiers; messages are human sentences that may be clarified or translated. Matching on prose breaks quietly on upgrade.
What should a custom validator raise to join the same error report?
Pydantic catches `ValueError`, wraps it with the field's location and gives it the type `value_error`. Building a `ValidationError` by hand is unnecessary.
Cheat sheet
Reading a ValidationError
Read a Pydantic ValidationError line by line, understand loc, msg and type, and turn the report into a message your caller can use.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.