← Wiki

Pydantic, in practice

2026-10-17 · 7 min read · python, pydantic

A short companion to section 5 of the Python lecture, which covers models, discriminated unions, TypeAdapter and validators. This page is the behaviour underneath: what the defaults actually do, and which of them bite.

Checked against pydantic 2.14.


The one idea

Pydantic is a parsing boundary. Outside it you have bytes and dict and promises; inside it you have values whose annotations are true, because they were checked once on the way in.

vs OCaml — it is of_yojson, derived from the type rather than written by hand. The discipline is the same one ppx_deriving_yojson enforces: parse at the edge into a real type, and never pass a dict inward. The difference is that nothing in Python stops you passing the dict inward, so the boundary is a convention you hold rather than one the compiler holds for you.

Everything below follows from asking where that boundary sits.

What a BaseModel actually is

A compiled validator, built once when the class statement runs.

BaseModel's metaclass is ModelMetaclass. When you define a subclass, it does three things before your class object exists:

  1. Collects the annotations into model_fields, a dict[str, FieldInfo] recording each field's type, default, and whether it is required.
  2. Builds a core schema — a plain nested dict describing a tree of validators. It is data, not code:
class M(BaseModel):
    n: int
    tag: str = "x"

M.__pydantic_core_schema__
# {'type': 'model',
#  'schema': {'type': 'model-fields',
#             'fields': {'n':   {'type': 'model-field',
#                                'schema': {'type': 'int'}},
#                        'tag': {'type': 'model-field',
#                                'schema': {'type': 'default',
#                                           'schema': {'type': 'str'},
#                                           'default': 'x'}}}}}
  1. Hands that schema to pydantic-core, the Rust engine, which compiles it into a SchemaValidator and a SchemaSerializer parked on the class as __pydantic_validator__ and __pydantic_serializer__.

So __init__ has almost nothing to do. The whole of it, modulo a warning:

validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)

vs OCaml — ModelMetaclass is a ppx that runs at import instead of at compile time: read the type definition, derive a parser. The difference is what gets derived. ppx_deriving_yojson generates OCaml source that the compiler then optimises; pydantic generates a data structure that a Rust interpreter walks. Hence a core schema you can print, and errors that carry a loc path through it.

What that costs, and where

Building the class is expensive and validating with it is cheap. Measured on a three-field model:

stepcosthow often
defining the class (schema build + compile)~460 µsonce, at import
one model_validate~1.3 µsper payload

One class definition costs about as much as 350 validations. Two consequences: import time grows with how many models a package defines, and a TypeAdapter built inside a function throws away the expensive half on every call — which is why the lecture says to build them at module level.

What an instance is

BaseModel.__slots__
# ('__dict__', '__pydantic_fields_set__', '__pydantic_extra__', '__pydantic_private__')

Field values live in an ordinary __dict__; there are no per-field descriptors, so reading m.n on a validated model is a plain attribute lookup and costs what any object costs. __pydantic_fields_set__ records which fields you actually passed — {'n'} for M(n=1), with tag absent — which is what model_dump(exclude_unset=True) reads.

This layout explains three things from further down this page:

Forward references

A schema cannot be built against a name that does not exist yet, so a model referring to one defined later is left incomplete:

class A(BaseModel):
    child: "B | None" = None

A.__pydantic_complete__      # False -- B was not defined yet

Pydantic retries the build automatically the first time you validate, so this usually resolves itself and you never notice. model_rebuild() forces it, and is only needed when the missing name will not be resolvable from the module namespace later — a type defined inside a function, typically.

Coercion is on by default

class M(BaseModel):
    n: int

M(n="7").n        # 7 -- the string was converted, not rejected

This is the single biggest surprise coming from OCaml. int means "something I can make an int out of", not "an int". Usually convenient, occasionally a silent bug: a field that should have been "7" arrives as 7 and the mistake surfaces three layers away.

Opt out per call or per model:

M.model_validate({"n": "7"}, strict=True)       # ValidationError: int_type
model_config = ConfigDict(strict=True)          # for the whole model

Strict mode at a wire boundary is worth the strictness, since a peer sending "7" for an int field is a bug worth hearing about.

frozen=True is shallow, and not always hashable

class F(BaseModel):
    model_config = ConfigDict(frozen=True)
    items: list[int] = []

f = F(items=[1])
f.items.append(2)    # fine. the list was never frozen
hash(f)              # TypeError: unhashable type: 'list'

frozen blocks assignment to fields. It does not deep-freeze, and the hashability it advertises only materialises if every field is itself hashable. This is the concrete reason every collection field in hermes-protocol is a tuple, not a list: with tuples you get both properties, with lists you get neither.

Defaults are not validated

class D(BaseModel):
    n: int = "not an int"

D().n       # 'not an int' -- no error, ever

The default is trusted and installed as-is. This is a deliberate speed trade-off, and it means a typo in a default survives every test that does not exercise it. ConfigDict(validate_default=True) turns it into an error at construction.

Mutable defaults, unlike in dataclass, are safe — pydantic deep-copies them per instance, so items: list[int] = [] does not share one list across instances.

extra defaults to dropping unknown keys

class E(BaseModel):
    a: int

E.model_validate({"a": 1, "surprise": 9})     # E(a=1) -- 'surprise' vanished

Fine for config, wrong for a protocol: a version-skewed peer's extra field becomes invisible instead of loud. Hence extra="forbid" on every wire model.

Discriminated unions, measured

The lecture says an undiscriminated union gives useless errors. Concretely, for {"kind": "a", "x": "nope"} against A | B:

unionerrors reportedlocations
A | B3A.x, B.kind, B.y
Annotated[A | B, Field(discriminator="kind")]1a.x

The plain union reports every branch's complaints, including two about the branch you never meant. The discriminated one reads kind, picks A, and tells you the one thing that is wrong. With four frame kinds nested two deep, that is the difference between a readable error and a wall.

Dumping: python mode vs json mode

m.model_dump()                 # {'tags': ('a', 'b')}   -- python objects
m.model_dump(mode="json")      # {'tags': ['a', 'b']}   -- json-safe objects
m.model_dump_json()            # '{"tags":["a","b"]}'   -- str, in one step

model_dump() is not a round trip to JSON: tuples stay tuples, datetime stays datetime, Decimal stays Decimal. Hand that to json.dumps and it raises; hand it to msgpack and you get whatever msgpack decides a tuple is.

Rule of thumb: mode="json" whenever the sink is a serialiser, plain model_dump() only when the consumer is Python that wants real objects back.

Validators: which kind, and when

UtcTimestamp = Annotated[AwareDatetime, AfterValidator(_to_utc)]   # reusable type

Order is before → pydantic's own parsing → after:

n: Annotated[int, BeforeValidator(f), AfterValidator(g)]
# f('5')  then  g(5)

So a BeforeValidator gets the raw input and must be defensive; an AfterValidator is guaranteed the parsed type. Prefer after.

Reading a ValidationError

except ValidationError as e:
    e.errors()      # [{'loc': ('kind',), 'type': 'literal_error', 'msg': ...}, ...]
    e.error_count()

loc is the path into the input, which is what you want in a log line — the default str(e) is several lines per error and unreadable in structured logs.

Escape hatches

callwhat it skipswhen
model_construct(**fields)all validationdata you just validated, in a hot loop
TypeAdapter(X)needing a classX is Annotated[...] or a bare alias
model_copy(update={...})validation of the updatecheap edits to a frozen model

model_construct will happily build M(n="garbage"). It is a promise you are making to the type checker, not a check.

Where pydantic should not be

At the edges, not in the core. A value that was validated on the way in does not need to be re-validated as it moves between functions, and a model is heavier than a dataclass or a NamedTuple for something that never crosses a boundary. The lecture's "when dataclass, when pydantic" rule in one line: pydantic if it came from outside this process, dataclass otherwise.

For speed when it matters: TypeAdapter.validate_json(raw_bytes) beats json.loads followed by validate_python — about 3x on a small model here — because the parse and the validation happen in one pass in Rust rather than building an intermediate dict in Python and walking it twice.