Skip to content

Error model

When a value does not satisfy a schema, validate raises ValidationError. The exception is not just a message: it carries a stable, machine-readable model meant to be read by tools and agents, not only humans.

The shape

A ValidationError exposes:

  • errors — a tuple of structured items, one per failure. Each item is a plain dict with these keys:
  • code — a stable, machine-readable code (e.g. int_type, missing_key, too_short).
  • path — the location of the offending value from the root, a tuple of string keys, integer keys and integer indices (empty at the root). A string key is itself, in full, and an integer key is itself as an integer — whatever its size, and True/False with them, since d[True] and d[1] are one entry — so walking the path back down reaches the value: d[2] and d["2"] are different entries and the path says which. A key that is neither has no spelling here and appears as its repr — naming the key rather than being one a caller can index back with.
  • message — the rendered one-line human message.
  • expected — a short label of the expected set (e.g. int).
  • value — a repr-style summary of the offending value.
  • message, code, path, expected, value — scalar convenience attributes mirroring the first item. str(exc) is a summary of every failure.

Every one of the six is present on every ValidationError, however it was made. The model describes failures, so an error you construct yourself reports none and reads as empty — empty strings and empty tuples — rather than raising AttributeError for an attribute the type declares:

from valgebra import ValidationError

error = ValidationError("something went wrong")
assert str(error) == "something went wrong"
assert error.code == ""
assert error.errors == ()

Crossing a process boundary

The exception pickles, and the structured model travels with it. A worker that validates and fails delivers the failure itself — code, path and every item of errors — rather than a message it has flattened by hand:

import pickle

from valgebra import ValidationError, Validator

try:
    Validator({"a": int}).validate({"a": "x"})
except ValidationError as err:
    restored = pickle.loads(pickle.dumps(err))
    assert restored.code == "int_type"
    assert restored.path == ("a",)
    assert restored.errors == err.errors

That covers a multiprocessing worker, a process pool, a task queue, and a test runner that forwards failures from a subprocess.

Aggregation and fail-fast

By default the walk does not stop at the first failure: it collects every independent failure — each record field, each sequence or tuple element, each mapping entry — into errors, so one call reports all the problems with a value.

from valgebra import ValidationError, Validator

try:
    Validator({"a": int, "b": str, "c": int}).validate({"a": "x", "b": 1, "c": "y"})
except ValidationError as err:
    assert [e["path"] for e in err.errors] == [("a",), ("b",), ("c",)]

There is no cap on how many failures come back. A list of twenty thousand values of the wrong type reports twenty thousand entries, and str() of that exception is about a megabyte. That is deliberate: a cap would make errors a sample, and a caller counting entries or looking for a particular path would be reading a truncated list with nothing saying it was truncated. Bounding the cost is the caller's, and there are two ways to do it — pass fail_fast=True, or validate the value in pieces. Each individual value is bounded, at eighty characters of its repr followed by ....

Pass fail_fast=True to stop at the first failure instead:

from valgebra import ValidationError, Validator

try:
    Validator({"a": int, "b": str}).validate({"a": "x", "b": 1}, fail_fast=True)
except ValidationError as err:
    assert len(err.errors) == 1

A node-level type mismatch (a value that is not a dict where a record is expected) is terminal for that subtree: there is nothing to descend into.

An intersection follows the same rule across its members. Each member is checked and each failure collected, until one rejects the value itself rather than something inside it — then the rest are not run, because they would describe a value already known to be the wrong kind of thing. A class with declared attributes is built as isinstance met with an attribute record, so a value of the wrong class reports instance_type alone, not that same value's missing attributes as well. A member that fails inside the value — an element, a field, an attribute — leaves the others meaningful, and they are still collected.

from dataclasses import dataclass

from valgebra import ValidationError, Validator


@dataclass
class Point:
    x: int


try:
    Validator(Point).validate(object())
except ValidationError as err:
    assert [(e["code"], e["path"]) for e in err.errors] == [("instance_type", ())]

Unions report the closest branch

When a value matches no branch of a union, valgebra does not dump every branch's failure. It reports the closest branch — the one that descended furthest into the value before failing — and that branch's own (aggregated) errors:

from valgebra import ValidationError, union

try:
    union(int, {"a": int}).validate({"a": "x"})
except ValidationError as err:
    # The value is a dict, so the record branch is closer than `int`.
    assert err.errors[0]["path"] == ("a",)
    assert err.errors[0]["code"] == "int_type"

When no branch makes any progress past the union's own location — for example int | str against a float, where every branch is a flat type mismatch — there is no closer branch, so a single union_error is the honest report. A complement likewise reports one failure at its location.

Every branch is walked whole even under fail_fast, because which branch is closest is decided by how far each one descended and a walk stopped early has not measured that. What fail_fast decides is how much of the chosen branch is reported, and there it means what it means everywhere else: one failure, the one the aggregate leads with.

A branch the walk could not answer for keeps its own report rather than the summary. recursion_limit, recursion_loop, mutated_during_validation and predicate_error each say the walk stopped rather than that the value is outside a set, and each of them fails at the union's own location — so the progress rule would count it as no progress and the summary would drop the one sentence saying what to do about it.

The closest-branch search is a bounded, best-effort heuristic: it runs only when a value has already failed the union, and it inspects at most the first 64 branches. A union wider than that still reports correctly — the membership decision always considers every branch — but its error may fall back to the union_error summary rather than pinpointing a branch past the cap. This keeps building an error for a pathologically wide union (a large Literal[...], say) bounded; the successful path is unaffected.

JSON output

Every item is JSON-serializable (the path is a tuple of strings and ints), so the JSON output mode is the standard library:

import json

from valgebra import ValidationError, Validator

schema = Validator({"name": str, "age": int})
try:
    schema.validate({"name": "Ada", "age": "old"})
except ValidationError as err:
    payload = json.dumps(err.errors)
    restored = json.loads(payload)
    assert restored[0]["code"] == "int_type"
    assert restored[0]["path"] == ["age"]
    assert restored[0]["expected"] == "int"

When a comparison raises

Checking membership reads a value through Python operations that can raise: an __eq__ for a literal, a rich comparison for a numeric bound, isinstance for a class, getattr for an attribute, __len__ for a length, __mod__ for a multiple-of. A value whose comparison, instance check, or attribute access raises an ordinary exception is treated as a non-member — a value that cannot answer "are you in this set?" is not in it, the same pragmatic stance pydantic-core takes. The one ordinary-exception case carved out is a user predicate (Annotated[..., some_callable]): a predicate that raises an ordinary exception is reported as a distinct predicate_error, not folded into an ordinary failed match, so a buggy predicate stays visible.

A fatal interpreter signal is never folded — at every site, the predicate and attribute access included. A base exception that is not an ordinary exception (KeyboardInterrupt, SystemExit, GeneratorExit), or a MemoryError or RecursionError, means the interpreter is unwinding, not that the value is a non-member, so it propagates out of validate/is_valid rather than being reported as "not a member" or a predicate_error.

The model is built when it is asked for

A raised ValidationError carries its failures, and the six attributes above are built by the first access that wants one. A caller that logs str(error) and moves on never pays for the rows; one that reads errors pays once, and the value is kept on the exception so a second read is an ordinary attribute lookup.

The difference is large enough to state: over ten thousand failing rows, a caller that reads none of them pays about a third of what building every row would cost. Measure it against your own shape with

uv run --group bench pytest benches/bench_validate.py -k error

Nothing about the model depends on it -- the same attributes, the same values, the same str() -- and pickling carries the plain data, because crossing a process boundary builds the model first.

When a value changes while it is checked

Membership reads a container entry by entry and runs Python at almost every one, so the container can move underneath the reading: a predicate that writes to the dict it is checking, and — on a free-threaded interpreter — another thread writing to a shared value. A reading interrupted that way decides nothing about the contents, so it is reported rather than guessed: the value is a non-member and validate names it mutated_during_validation.

from typing import Annotated

import annotated_types as at

from valgebra import ValidationError, Validator

grown = {"a": 1, "b": 2}
schema = Validator(
    {
        "a": Annotated[int, at.Predicate(lambda _: grown.setdefault("c", 3) or True)],
        "b": int,
        "c?": int,
    }
)

assert schema.is_valid(grown) is False
try:
    schema.validate(grown)
except ValidationError as error:
    assert error.code == "mutated_during_validation"

A dict, a set and a list are all read this way — each against a count taken once — so each reports rather than guesses; a tuple cannot be resized and needs no guard. Only a change in the container's size costs the reading; a value rewritten in place leaves the entries where they are and the check answers normally. The same code also reports the rarer case of a value that answers two readings differently — a predicate or an __eq__ that is not a function of the value — because it is the same failure: the check has no stable value to decide about.

The set of codes

There is no hand-maintained list of every code on this page, because a list that drifts from the walk is worse than none. The codes are pinned by tests/test_error_codes.py, which asserts the code and the path for each node kind — read it as the enumeration, and add a case there when you need one that is not covered.

What is guaranteed here is the property a caller depends on: a code is stable and does not change meaning across releases, so branching on one written down today keeps working. New codes may appear for node kinds that gain a distinct failure.

Every code is reported the same way on both entry paths. validate_json and load parse the document and then walk the Python value the parser built, so a document reaches the same codes a value does, with the same loc. Three are the exception, and each because the parser cannot build the value that reaches them:

Code Why no document reaches it
tuple_length a document's array is a list, never a tuple, so a tuple schema refuses it by kind first and the code is tuple_type
recursion_loop a parsed document is a tree, so no value contains itself
mutated_during_validation nothing runs against a parsed value while it is read, so it cannot move under the walk

tests/test_error_matrix.py drives every code through both modes and both paths, and carries that reason beside the code a document gets instead, so the claim is one a test can refute.

Determinism

For a given schema and value the error model is deterministic: the same codes, paths, and order across runs and platforms. Tools can diff it. A set has no positions, so its failing elements are reported in the order of what they say rather than the order the interpreter hands them over, which moves with the hash seed; fail_fast keeps the first of that order. The exact output is locked by snapshot tests (the message format on the Rust side, the structured errors on the Python side), so any change to it is reviewed, never silent.

Message style guide

Messages and codes follow a fixed style so they stay predictable:

  • One line, present tense, of the form expected <X>, got <Y> [<code>]; a located failure prefixes at <path>:.
  • The code is stable and machine-readable; it is the field to branch on, not the prose. Codes do not change meaning across releases.
  • expected names the set, value is a short repr of what was found, truncated so a large value cannot flood the message. A union names each of its branches, bounded, as each would name itself alone.
  • Set-membership failures (union, complement) report at the location of the combinator, not inside a discarded branch.

What a union's expected says

A union that no branch admits reports one failure at the union's own location, and its expected names each branch the way that branch names itself when it is the only thing that failed:

import enum
from typing import Literal

from valgebra import ValidationError, Validator, union


class Backend(enum.Enum):
    TORCH = "torch"


def expected_of(spec: object, value: object) -> str:
    try:
        Validator(spec).validate(value)
    except ValidationError as err:
        return err.errors[0]["expected"]
    raise AssertionError("expected a failure")


assert (
    expected_of(Literal["torch", "jax"], "tensorflow")
    == "one of: the literal 'torch', the literal 'jax'"
)
assert expected_of(union(Backend, Literal["cpu"]), "arcfase") == (
    "one of: the literal 'cpu', Backend"
)

A Literal[...] builds a union of its constants, so its branches are the constants and the message lists them. An Enum branch names the class, as it does alone.

The list is bounded at 64 labels and ends in ... beyond that, so a union wide enough to be a generated table reports a readable prefix. A branch that is itself a union contributes its own members, so the label count and the branch count are not the same number. See resource limits.

expected describes the schema as built, not a canonical name for the set. int | str, str | int and ~(~int & ~str) denote one set and read differently, in the same way repr renders the schema that was built. Read expected to see what was asked for; use is_equivalent to ask whether two schemas mean the same thing.

As built rather than as written, because the constructors fold: complement cancels a complement, union collapses a join carrying a schema beside its own complement, and intersection collapses the meet of that pair. complement(complement(int)) is the schema int, so it reports int_type and names int — there is no second schema left to report. intersection(int, complement(int)) is nothing, so it reports no_match and names nothing, for the same reason.

Which spelling produces which code

Two spellings of the same singleton denote the same set and are is_equivalent, but they build different schema shapes and so report differently:

from typing import Literal

from valgebra import ValidationError, Validator


def report(spec: object) -> tuple[str, str]:
    try:
        Validator(spec).validate("x")
    except ValidationError as err:
        return err.errors[0]["code"], err.errors[0]["expected"]
    raise AssertionError("expected a failure")


assert report("active") == ("literal_error", "the literal 'active'")
# `Literal[x]` with one member *is* that literal: a join of one member is the
# member, settled where the schema is built.
assert report(Literal["active"]) == ("literal_error", "the literal 'active'")

Literal[...] builds a union of its constants, and a union of several reports union_error. A union of one is that one, folded where the schema is built, so Literal["active"] is the literal leaf: it reports literal_error and compares equal to Validator("active") under ==.

Branch on the code you actually observe for the spelling you actually write.