Skip to content

Schemas from dataclasses

If your data already has a dataclass, you should not have to write the schema twice. DataclassSchema reads a dataclass’s fields and builds a schema from the annotations: it validates a plain mapping and hands you back a constructed instance. This is the same idea as voluptuous PR #533, with a richer type mapping (Probatio descends into element types instead of stopping at the container).

The same engine reads a TypedDict. If your shape is a dict rather than a constructed object, see Schemas from TypedDicts; this page is about dataclasses.

Pass a dataclass type to DataclassSchema. A field without a default is required; a field with a default (or default_factory) is optional, and the default fills in when the key is absent.

from dataclasses import dataclass
from probatio import DataclassSchema
@dataclass
class User:
name: str
age: int = 18
schema = DataclassSchema(User)
schema({"name": "ada"}) # User(name='ada', age=18)

The result is a real User, not a dict. A missing required field is reported like any other validation error:

from dataclasses import dataclass
from probatio import DataclassSchema
@dataclass
class User:
name: str
age: int = 18
DataclassSchema(User)({}) # required key not provided at 'name'

The mapping is deep, not just the container type. A parameterized generic keeps its element types, a union with None becomes Maybe, and a nested dataclass recurses into its own schema.

from dataclasses import dataclass, field
from probatio import DataclassSchema
@dataclass
class Address:
street: str
number: int = 0
@dataclass
class Person:
name: str
tags: list[str] = field(default_factory=list)
home: Address | None = None
schema = DataclassSchema(Person)
schema({"name": "ada", "tags": ["x"], "home": {"street": "Main", "number": 5}})
# Person(name='ada', tags=['x'], home=Address(street='Main', number=5))

The full set of mappings:

AnnotationSchema
int, str, a plain typethe type (an isinstance check)
floatthe numeric tower (int in, float out)
list[T][T] (element type validated)
set[T], frozenset[T]{T} / frozenset([T])
dict[K, V]{K: V} (key and value validated)
tuple[X, Y]ExactSequence([X, Y]) (positional)
tuple[X, ...]a homogeneous sequence of X
X | NoneMaybe(X)
X | YAny(X, Y)
Literal["a", "b"]In(["a", "b"])
a nested dataclassits own generated schema
Anyaccepts any value

A tuple field accepts a list or a tuple, since a sequence arrives as a list from JSON, and keeps whichever you gave it.

A float field is the one plain type that is not a bare isinstance. It honors the PEP 484 numeric tower: an int is a valid float value, so 5 is accepted and normalized to 5.0, while bool, a string, and None are still rejected. See ADR-017.

The annotation gives you the type check. For anything beyond that (a length, a range, a regular expression), pass additional_constraints: a map from field name to a validator that runs after the type check.

from dataclasses import dataclass
from probatio import DataclassSchema, Length
@dataclass
class User:
name: str
schema = DataclassSchema(User, {"name": Length(min=2)})
schema({"name": "ada"}) # User(name='ada')

The same rule can live on the field itself with Annotated, keeping the constraint next to the field instead of in a separate map. The rules:

  • The first argument is the type; every callable after it is applied as a validator, in order.
  • The type is checked on the result, not the raw input, so the annotation says what the field is: a coercer runs first and the type confirms what it produced.
  • It composes inside containers: list[Annotated[int, Range(min=1)]] checks every element.
  • A producing base (an enum, a nested dataclass, a container) coerces first, so an asserting constraint after it checks the produced value. A coercer in the metadata (Coerce or Map) still runs ahead of it, so you can reshape a raw value, like folding an enum sentinel to None, before the base sees it (see the cookbook).
  • Metadata that is not callable is left alone, so an Annotated value you share with another tool passes through untouched.

That second rule is what keeps a coerced field honestly typed. Annotated[datetime, AsDatetime()] accepts the string, parses it, and confirms a datetime, instead of annotating str and hiding the real type in the validator. To find the right callable to drop in here, whether it checks the value or transforms it, see Built-ins by role.

from dataclasses import dataclass
from typing import Annotated
from probatio import DataclassSchema, Length, Range
@dataclass
class User:
name: Annotated[str, Length(min=2)]
age: Annotated[int, Range(min=0)] = 0
schema = DataclassSchema(User)
schema({"name": "ada", "age": 30}) # User(name='ada', age=30)

Here is that coercer-plus-type pairing in practice:

from dataclasses import dataclass
from datetime import datetime
from typing import Annotated
from probatio import DataclassSchema, AsDatetime
@dataclass
class Event:
when: Annotated[datetime, AsDatetime()]
DataclassSchema(Event)({"when": "2020-01-01T12:00"})
# Event(when=datetime.datetime(2020, 1, 1, 12, 0))

A NewType is followed to the type it wraps, so a field typed UserId = NewType("UserId", int) validates as an int.

A plain type says what a field is. To give a field a key facet, redact it, accept it under other names, forbid it, group it, add Key to its Annotated metadata. It sits next to any value validators; the two do not interfere.

from dataclasses import dataclass
from typing import Annotated
from probatio import DataclassSchema, Key, Length
@dataclass
class Account:
name: str
password: Annotated[
str, Key(secret=True), Length(min=8)
] # redacted, length-checked
user_name: Annotated[str, Key(alias=["user-name", "userName"])] = (
"" # accept aliases
)
is_admin: Annotated[bool, Key(forbidden=True)] = (
False # reject if the caller sends it
)
schema = DataclassSchema(Account)
schema({"name": "a", "password": "secret123", "user-name": "frenck"})
# Account(name='a', password='secret123', user_name='frenck', is_admin=False)

Key(secret=True) redacts the field’s value in validation errors. It shows up when an error renders the offending value: the message stays useful, the secret does not leak.

from probatio import MultipleInvalid
from probatio.humanize import humanize_error
data = {"name": "a", "password": "short", "user-name": "frenck"}
try:
schema(data)
except MultipleInvalid as err:
print(humanize_error(data, err))
# length of value must be at least 8 at 'password'. Got <redacted>

Key(alias=[...]) accepts the field under alternate input names (a bare string works for one), emitting it under the field name; accept_canonical=False makes it a strict rename. Key(inclusive="grp") / Key(exclusive="grp") group fields the way the dict form does. Key(required=True) (or required=False) overrides the presence the field’s default would imply. required=False marks a field optional, so on a dataclass it still needs a default for the constructor to fall back on; without one it raises SchemaError.

Key is a field-only spec; a plain dict schema keeps using the markers directly ({Secret("password"): str}, {Alias("user_name", "user-name"): str}). It works the same on a TypedDict.

forbidden and remove make a key contribute nothing to the result, so on a dataclass (whose constructor needs a value for every field) such a field must have a default; without one it raises SchemaError. A TypedDict constructs nothing, so there they need no default.

One boundary to know, on which defaults pass through the schema:

  • An optional field, or a selected exclusive member, carries its default through the schema, so it is validated and coerced like input.
  • A field the schema keeps out of the input (a forbidden field, a remove field, an unselected exclusive member) takes the dataclass’s own default exactly as declared: no validation, no coercion.

A nested dataclass field can default to a built instance, the idiomatic “always-present, defensive empty object”: inner: Inner = field(default_factory=Inner). When the key is absent, the Inner() fills in as-is; it is not re-validated as a mapping. A caller may also hand in an already-built instance for such a field, and it passes through untouched; only a mapping is validated and constructed.

The schema validates input, and that second group’s values are not input, so write an already-typed default for such a field; a wrong-typed one is a type error your type-checker flags. See ADR-013 for the model.

create_dataclass_schema(dataclass_type, additional_constraints=None) builds the same schema without the class wrapper, and is_dataclass is the standard-library check, re-exported so you do not have to import it separately.

Both DataclassSchema and create_dataclass_schema also take keyword-only required and extra arguments, passed straight through to Schema, so you can, for example, accept and keep unknown keys with extra=ALLOW_EXTRA.

from dataclasses import dataclass
from probatio import create_dataclass_schema, is_dataclass
@dataclass
class User:
name: str
is_dataclass(User) # True
create_dataclass_schema(User)({"name": "ada"}) # User(name='ada')

Parsing an API payload into a dataclass tree is the common shape: a DataclassSchema built once, then called, usually behind a from_dict classmethod. SchemaMixin bundles that into the class. Inherit it, pass the extra-key policy as a class argument, and the dataclass gets a cached, validating from_dict, with no separate module-level schema and no hand-written classmethod.

from dataclasses import dataclass
from probatio import REMOVE_EXTRA, SchemaMixin
@dataclass
class Server(SchemaMixin, extra=REMOVE_EXTRA):
host: str = ""
port: int = 80
Server.from_dict({"host": "nas", "port": 8080, "unmodeled": 1})
# Server(host='nas', port=8080)

from_dict returns an instance of the class, typed as such, so your editor and type-checker know the result. The DataclassSchema is built on the first call and reused after, and extra is inherited: a subclass that sets none keeps the parent’s policy. The class stays an ordinary dataclass; the mixin adds from_dict and records extra, and leaves the fields alone. A TypedDict cannot carry methods, so it keeps using TypedDictSchema directly.

from_dict is the validation boundary, not the class. Coming from pydantic, this is the one thing to know: SchemaMixin does not make the dataclass validate itself. Constructing one directly (Server(host="nas", port="no")) runs no validation, the same as any plain dataclass, and a type-checker already flags the wrong type there. Validate untrusted input at the edge with from_dict, and treat an instance you already hold as trusted. The schema stays separate from the data, so the same dataclass keeps working with probatio nowhere in sight.

extra propagates. Set extra=REMOVE_EXTRA (or ALLOW_EXTRA) and the policy applies at every level: nested dataclasses, nested TypedDicts, and dataclasses inside a list, dict, tuple, or union all follow it. This is the case for parsing a third-party API response into a tree of dataclasses while dropping the fields you do not model, one flag, not a wrapper per level.

from dataclasses import dataclass, field
from probatio import DataclassSchema, REMOVE_EXTRA
@dataclass
class Server:
host: str = ""
@dataclass
class Config:
server: Server = field(default_factory=Server)
data = {"server": {"host": "nas", "unmodeled": 9}, "toplevel_junk": 1}
DataclassSchema(Config, extra=REMOVE_EXTRA)(data) # Config(server=Server(host='nas'))

To pin a different policy for one field’s subtree, put Key(extra=...) in its Annotated metadata. It overrides the inherited policy for that field only, so you can keep one noisy sub-object loose while the rest stays strict, or the reverse:

from dataclasses import dataclass, field
from typing import Annotated
from probatio import DataclassSchema, Key, PREVENT_EXTRA
@dataclass
class Strict:
id: int = 0
@dataclass
class Doc:
meta: Annotated[Strict, Key(extra=PREVENT_EXTRA)] = field(default_factory=Strict)

The schema is loose, but meta stays strict and rejects the unknown key:

DataclassSchema(Doc, extra=REMOVE_EXTRA)({"meta": {"id": 1, "typo": 2}})
# MultipleInvalid: not a valid option at 'meta.typo'

The default (PREVENT_EXTRA) is unchanged: an unknown key, at any level, is rejected. Key(extra=...) on a plain (leaf) field has nothing to apply to and is ignored.

A field annotated with datetime validates by isinstance, so a string from JSON or YAML is rejected. That is the safe default: probatio validates, it does not silently transform. When you do want a field coerced, say so on the field with an Annotated hint, and the coercion is scoped to exactly that field:

from dataclasses import dataclass
from datetime import datetime
from typing import Annotated
from probatio import DataclassSchema, Coerce
@dataclass
class Event:
when: Annotated[datetime, Coerce(datetime.fromisoformat)]
DataclassSchema(Event)(
{"when": "2020-01-01T12:00"}
) # Event(when=datetime(2020, 1, 1, 12, 0))

The Coerce runs first and the datetime type confirms the result, so the field stays honestly typed (see the annotation mapping table). This works the same way in a nested dataclass field, and additional_constraints still layers on top. The hand-written Schema(datetime) path is never affected.

It holds for a nullable or union field too: the coercer runs before the X | None (or X | Y) base confirms the result, so Annotated[int | None, Coerce(...)] coerces a raw string and Annotated[datetime | None, Coerce(FromEpoch())] turns a timestamp into a datetime, keeping the field’s real int | None / datetime | None type instead of falling back to Any.

Reach for a built-in coercer before writing a lambda. AsTimedelta reads a count of seconds, a H:MM:SS string, or an ISO 8601 duration into a timedelta, and Maybe(Coerce(float)) coerces a value while letting None through. Both compose straight onto a field, so a “seconds or a timedelta” field is Annotated[timedelta, AsTimedelta()] and an “optional, coerce if present” field is Annotated[float | None, Maybe(Coerce(float))], with no coercer of your own to write or test:

from dataclasses import dataclass
from datetime import timedelta
from typing import Annotated
from probatio import AsTimedelta, Coerce, DataclassSchema, Maybe
@dataclass
class Job:
timeout: Annotated[timedelta, AsTimedelta()] = timedelta()
ratio: Annotated[float | None, Maybe(Coerce(float))] = None
DataclassSchema(Job)({"timeout": 90, "ratio": "0.5"})
# Job(timeout=datetime.timedelta(seconds=90), ratio=0.5)

The As* coercers return their native type unchanged (AsTimedelta on a timedelta, AsDatetime on a datetime), so they are idempotent in the sense above: one sits on a defaulted field without re-converting the default when it flows back through the schema.

A dataclass whose field refers back to itself (a tree node, a linked list) is supported: the field validates against the same schema, all the way down, and each level is constructed. Mutually recursive dataclasses (two types that point at each other) work the same way.

from __future__ import annotations
from dataclasses import dataclass, field
from probatio import DataclassSchema
@dataclass
class Tree:
name: str
children: list[Tree] = field(default_factory=list)
DataclassSchema(Tree)({"name": "root", "children": [{"name": "leaf"}]})
# Tree(name='root', children=[Tree(name='leaf', children=[])])

Recursion follows the data, with the same depth guard as Self: cyclic or pathologically deep input raises a clean Invalid, never a RecursionError. A recursive dataclass level does more work than a bare Self level, so it bottoms out sooner; raise the limit with sys.setrecursionlimit() if you genuinely need to go deeper.

A field that is a union of dataclasses sharing a literal tag field becomes a discriminated union: the tag picks the one branch to validate, rather than trying each member in order. The branch is chosen by the tag, so a failure reports that branch’s error instead of a vague “matched no member”.

from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from probatio import DataclassSchema
@dataclass
class Circle:
kind: Literal["circle"]
radius: int
@dataclass
class Square:
kind: Literal["square"]
side: int
@dataclass
class Shape:
shape: Circle | Square
DataclassSchema(Shape)({"shape": {"kind": "square", "side": 3}})
# Shape(shape=Square(kind='square', side=3))

The tag field must be a single-value Literal present on every member, with a distinct value per member. Without one (or if a member is not a dataclass), the union stays an ordinary “try each” Any. An unknown tag value falls back to trying every member, so it still fails cleanly rather than silently.

A DataclassSchema is the clearest case for the compiled engine. When a schema compiles, it fuses field validation and object construction into a single generated function with no intermediate dict, which is most of why a hot dataclass schema validates several times faster than the interpreted one and lands close to a pure deserializer like mashumaro while still checking every field. This happens on its own once the schema proves hot; see Compiled schemas to opt in eagerly or read the trade-offs.

Probatio is a validation library, but there are spots where validation is not needed: the input is your own data round-tripping back in, or it was already validated upstream. For those, DataclassSchema.construct builds the instance from trusted data and skips validation entirely.

from dataclasses import dataclass
from probatio import DataclassSchema
@dataclass
class Point:
x: int
y: int
@dataclass
class Line:
start: Point
end: Point
schema = DataclassSchema(Line)
# Trusted input: build straight through, no type checks, no coercion.
schema.construct({"start": {"x": 0, "y": 0}, "end": {"x": 3, "y": 4}})
# Line(start=Point(x=0, y=0), end=Point(x=3, y=4))

It reads each field straight from the dict, recursing into nested dataclasses, lists of them, Optional fields, and a single dataclass inside a union (like Comment | str), and filling defaults, then constructs. With no checks it is faster than validating, fast enough to beat dedicated deserializers like mashumaro on the performance page, because it is a purpose-built constructor and pure Python.

The catch is in the name: it trusts you. A wrong type lands in the instance unchecked, and it does not convert: a field typed datetime will hold whatever the dict held (a string stays a string), where validation would coerce it. For input that still needs decoding, validate.

A field with init=False is left out of the schema, since it is not a constructor argument; it keeps whatever its default or __post_init__ gives it. An InitVar goes the other way: it is a constructor argument, so it becomes a schema key, validated against its annotation like any field, and it feeds __post_init__ as usual.

from dataclasses import InitVar, dataclass, field
from probatio import DataclassSchema
@dataclass
class Rect:
width: int
height: int
scale: InitVar[int] = 1
area: int = field(init=False, default=0)
def __post_init__(self, scale: int) -> None:
self.area = self.width * self.height * scale
DataclassSchema(Rect)({"width": 2, "height": 3, "scale": 10})
# Rect(width=2, height=3, area=60)

The value is not coerced between container types: a list stays a list even where the annotation says tuple.