Skip to content

Lazy building

A Schema normally compiles its declaration into a validator the moment you build it. That is the right default: a malformed schema fails where it is written, the same as voluptuous. But some programs build far more schemas than they ever validate against. Home Assistant registers on the order of a thousand service schemas and a thousand websocket-command schemas at startup, and a given install exercises a small fraction of them. Every unused one still pays to compile, and holds its compiled validator tree in memory for the life of the process.

Lazy building defers that work. Under the LAZY build policy a schema stores its declaration and compiles on the first validation instead of at construction, so a schema that is built but never validated never compiles and never holds a validator tree. It is opt-in: the default stays eager, so nothing changes unless you ask.

The process starts on the EAGER policy. A schema compiles when you build it, and a definition error is raised right there, at construction, exactly as voluptuous does. This is the drop-in behavior, and for most programs it is what you want: schemas are a fixed set built once at import, and building them is cheap.

from probatio import Schema, Required
schema = Schema({Required("name"): str}) # compiled here, on this line
schema({"name": "app"}) # {'name': 'app'}

Set the policy once, early, from deliberate startup code, before the schemas you want deferred are built. There is no environment variable on purpose: this is an architectural choice, not a deployment toggle.

from probatio import BuildPolicy, set_build_policy
set_build_policy(BuildPolicy.LAZY) # defer every eligible schema to first use
set_build_policy(BuildPolicy.EAGER) # the default: compile at construction

After set_build_policy(BuildPolicy.LAZY), a schema you build does not compile until its first validation. A schema you build and never call never compiles at all, and never allocates its validator tree.

from probatio import Schema, Required, BuildPolicy, set_build_policy
set_build_policy(BuildPolicy.LAZY)
# Registered at startup; the walk is deferred.
schema = Schema({Required("resource"): str})
# ... much later, only if this command is ever invoked:
schema({"resource": "sensor.sun"}) # compiles now, then validates

Only a plain top-level Schema defers. Anything whose compiled form is needed at construction builds eagerly, even under LAZY, so laziness never leaves a half-built schema anywhere it would be read:

  • A combinator branch (Any(...), All(...)) compiles its branches when the combinator is built.
  • A DataclassSchema or TypedDictSchema builds at construction, since it wires field validation (and, for a dataclass, instance construction) together then.
  • A nested schema reused as a value builds when its parent is first validated.
  • An explicit compile= request, or Schema.compile(), builds now.

Reading a lazy schema’s declaration does not build it: .schema, str(), .extend(), and the codecs all work on the declaration, so introspection stays free.

Deferring the compile walk defers the errors it raises. Under EAGER, a malformed schema (a contradiction like two presence markers on one key, an alias colliding with another key) raises SchemaError at construction. Under LAZY, that same error raises on the first validation instead, because that is when the walk runs. The schema is just as wrong; you learn about it a moment later.

For a program whose schemas are static and covered by tests, this is a non-issue, the tests validate, so a broken schema still fails in CI. But it is a real difference from voluptuous, which is why LAZY is opt-in and EAGER is the default. If you rely on construction-time schema errors, stay eager.

Reach for LAZY when you build many schemas at import but validate against only some of them in a given run: a large plugin surface, a command or service registry, per-feature validation that most installs never touch. The win is both time (the deferred walks never run) and memory (their validator trees never materialize), which matters most on a small device. If your schemas are a fixed set you validate against on every run, eager is simpler and there is nothing to gain.