Pydantic, in practice
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 oneppx_deriving_yojsonenforces: parse at the edge into a real type, and never pass adictinward. 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:
- Collects the annotations into
model_fields, adict[str, FieldInfo]recording each field's type, default, and whether it is required. - 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'}}}}}
- Hands that schema to pydantic-core, the Rust engine, which compiles it into a
SchemaValidatorand aSchemaSerializerparked 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 —
ModelMetaclassis 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_yojsongenerates 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 alocpath through it.
What that costs, and where
Building the class is expensive and validating with it is cheap. Measured on a three-field model:
| step | cost | how often |
|---|---|---|
| defining the class (schema build + compile) | ~460 µs | once, at import |
one model_validate | ~1.3 µs | per 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:
- Defaults are not validated because the default sits in the schema as a
defaultnode wrapping the field's validator (visible in the dump above). The wrapped validator only runs on input that was supplied. model_constructis fast and unsafe because it writes__dict__and__pydantic_fields_set__directly and never calls theSchemaValidator.- Strictness is a flag on a schema node, not Python code, which is why
strict=Truecosts nothing.
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:
| union | errors reported | locations |
|---|---|---|
A | B | 3 | A.x, B.kind, B.y |
Annotated[A | B, Field(discriminator="kind")] | 1 | a.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
Annotated[...]+AfterValidator— the rule belongs to the type, so every field of that type inherits it. Default choice.@field_validator("x")— the rule belongs to this one model's field.@model_validator(mode="after")— the rule spans fields ("endmust be afterstart"), so it cannot live on either one.
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
| call | what it skips | when |
|---|---|---|
model_construct(**fields) | all validation | data you just validated, in a hot loop |
TypeAdapter(X) | needing a class | X is Annotated[...] or a bare alias |
model_copy(update={...}) | validation of the update | cheap 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.