Reading a ValidationError

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

Most hand-written validation gives up at the first thing it does not like. The caller fixes it, resubmits, and meets the next one. Four mistakes take four round trips.

Pydantic checks every field and raises once, carrying the complete list. For a signup form that is the difference between a form which highlights all four broken inputs immediately and one which reveals them slowly, like a bad quiz.

e.error_count() tells you how many. str(e) gives a readable summary, which 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

Every field checked, every 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
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
Output

e.errors() returns a list of dictionaries, and this is what you build anything real on top of. Each entry has four parts, and they do different jobs.

loc is where the problem is. It is a tuple, not a string, and it describes 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 actually 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
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
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
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
Output

loc, in detail

Reading loc well is most of debugging a large payload.

Integers 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 something you can paste into a search or show to a colleague.

For a union you will sometimes see the union member's name in the path too, because Pydantic reports which branch it was attempting when it failed. That looks noisy at first and is genuinely useful when a discriminated union picks the wrong branch.

The important 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 every error appears to be about the top-level container.

Match on type, not on message

This is the rule that saves the most future pain.

Messages are prose. They get reworded, clarified and occasionally translated between releases. Any code that does if "should be a valid integer" 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 dictionary 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 useful default is to fall back to err["msg"] when a type is not in your table. You get good copy for the cases you have thought about, and something serviceable for the ones you have not.

The shape an API wants

Most front ends want errors grouped by field, so 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 several 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 are using FastAPI, this transformation already happens for you: a ValidationError on a request body becomes a 422 whose body is essentially e.errors(). Knowing the shape means knowing what your own clients receive.

Your own errors, in the same report

Custom rules do not need a separate channel. A validator 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 validation alike, and a caller cannot tell — or need to care — which kind of rule they broke.

Raise ValueError, not ValidationError. Constructing a ValidationError by hand is awkward and unnecessary; Pydantic wraps yours correctly and adds the location for you. An AssertionError also works, but is a poor choice because assertions vanish when Python is run with -O.

What input tells you

Do not skip input. It is often the entire diagnosis.

A caller insists they are sending a number, and input shows "12" with quotes — they are sending a string, and 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 something in the chain stringified a null.

None of that is visible in the message. All of it is 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 documentation page for that error type. It is the https://errors.pydantic.dev/... line you see at the end of a printed error. It is genuinely useful while learning and noise in a log, so it can be turned off: e.errors(include_url=False).

ctx holds the parameters 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 difference 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 produces the same error entries with one addition: when the JSON itself is malformed, you get a json_invalid error whose context includes the position in the document.

That matters for large payloads. A missing comma four hundred lines into a config file produces an error that names the line, which is considerably more use than "invalid JSON".

It is also why validating JSON directly is better than json.loads followed by model_validate. Go through json.loads and a syntax error is a JSONDecodeError from a different library, which you have to catch separately and which knows nothing about your model. Validate the JSON directly and malformed documents 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, so 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 explicitly. Grouping code should fall back to a key like "_" or "__root__" for empty locations, and the front end should have somewhere to display an error that is not attached to an input.

Testing that validation fails

Validation logic deserves tests, and the useful 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 something, which teaches your team to distrust the suite. Asserting on loc and type tests the thing you actually care about: that the right rule fired on the right field.

It is also worth testing the positive case explicitly — that a valid payload produces the values you expect, coercions included. A test that minutes="9" becomes the integer 9 documents an intention that is otherwise invisible.

Making errors useful to a human

A last thought, because this is where validation meets the person using your software.

The default messages are written for developers. "Input should be a valid integer, unable to parse string as an integer" is precise and it is not what you want beside a form field. A user does not care about parsing; they care that they typed "ten" in a box that wanted a number.

The translation layer is small — a dictionary from type to a sentence — and it is worth building once, early, for the twenty or so error types your application can actually produce. 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 have the input value that settles it.

A complete handler you can lift

Putting the pieces together, this is a small function that covers everything 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 every error the application can produce. New error types degrade to Pydantic's own wording rather than to a blank space, so nothing is ever invisible, and adding a nicer message later is one dictionary entry.

Why the design is the way it is

It is worth noticing what this error model is optimised for, because it explains several choices that look odd in isolation.

It reports everything at once because the expensive part of validation is the round trip to the user, not the checking.

It uses stable codes rather than messages because the consumer is often another program, and programs need identifiers that do not move.

It carries input because the most common question after a rejection is "what did they actually send", and the alternative is asking them.

And it makes loc a path rather than a name because real payloads nest, and an error that cannot say *where* in a hundred-line document the problem is has told you almost nothing.

Every one of those is a decision made for the person on the other end of the failure. Reading errors well is mostly a matter of noticing that the information you want is already in there.

One more habit

Log the full error, show the friendly one.

These are different audiences with different needs, and collapsing 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 reproduce the failure without asking anyone anything.

Doing both costs one extra line at the point where you catch the exception, and it is the difference between a support conversation that starts with "can you tell me exactly what you typed" and one that starts with "I can see what happened". The information was in the exception the whole time; the only question is whether you kept it.

Next

The next module goes the other way: not reading the errors Pydantic produces, but writing the rules that produce them. Field constraints let you say more about a value than its type — a minimum, a length, a pattern — and every one of them comes back through exactly the machinery described here.

Check yourself

0 of 4

Answer without scrolling back up.

  1. A payload has four invalid fields. How many exceptions does Pydantic raise?

  2. What does `loc` of `('lessons', 1, 'minutes')` mean?

  3. Why match on `type` rather than `msg`?

  4. What should a custom validator raise to join the same error report?

Cheat sheet

Reading a ValidationError

Most hand-written validation gives up at the first thing it does not like. The caller fixes it, resubmits, and meets the next one. Four mistakes take four round trips.

PYDANTIC · vizlearn.in/pydantic/reading_a_validation_error.html

About the author

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.