JSON Schema
A Probatio schema is Python data. JSON Schema is the lingua franca other tools
speak. The two codecs translate between them, for the constructs that map
cleanly. They are exported from the top level, so
from probatio import to_json_schema, from_json_schema is all you need.
That unlocks the places JSON Schema already lives: editor validation of config
files, form generation in a frontend, and LLM tool definitions (worked through
in the LLM tool recipe). Decoded input is treated as
untrusted by default; the
guards are covered at the bottom
of this page.
The same decoder backs OpenAPI (see OpenAPI), and a third codec renders the flat shape config frontends consume (see Field lists).
Both directions
Section titled “Both directions”to_json_schema(schema) renders a schema as a JSON Schema dictionary.
from_json_schema(dict) is the inverse: it builds a Schema back. Together they
round trip the parts that have a clean mapping.
from probatio import Schema, Required, Optional, to_json_schema
schema = Schema({Required("name"): str, Optional("port", default=8080): int})to_json_schema(schema)# {'type': 'object', 'properties': {'name': {'type': 'string'}, 'port': {'type': 'integer', 'default': 8080}}, 'additionalProperties': False, 'required': ['name']}Going the other way, a JSON Schema becomes a working validator. required
controls which keys must be present, minimum becomes a Range, and so on:
from probatio import from_json_schema
document = { "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer", "minimum": 0}, }, "required": ["name"],}schema = from_json_schema(document)schema({"name": "Ada", "age": 37}) # {'name': 'Ada', 'age': 37}A small round trip stays intact. Render a schema, build it back, validate:
from probatio import Schema, Required, to_json_schema, from_json_schema
original = Schema({Required("name"): str, Required("age"): int})rebuilt = from_json_schema(to_json_schema(original))rebuilt({"name": "Ada", "age": 37}) # {'name': 'Ada', 'age': 37}Widening the encoder, and overriding it
Section titled “Widening the encoder, and overriding it”By default to_json_schema never rejects an input the schema accepts: where it
cannot express a construct exactly it widens (an open {}, or a looser keyword),
so the emitted schema stays a superset of what Probatio validates. Two options
tune that:
strict=TrueraisesSchemaErrorfor a construct with no JSON Schema form (an unknown validator, aCoercewith a non-type target, anenummember with no JSON representation), instead of widening to{}.custom_serializeris called first for each node and may return a dict to override the rendering, or theUNSUPPORTEDsentinel to defer to the default, the same hookto_openapitakes.
from probatio import Schema, to_json_schemafrom probatio.codecs import UNSUPPORTED
def as_password(node): if node is str.strip: return {"type": "string", "writeOnly": True} return UNSUPPORTED
to_json_schema(Schema({"token": str.strip}), custom_serializer=as_password)# {'type': 'object', 'properties': {'token': {'type': 'string', 'writeOnly': True}}, 'additionalProperties': False}Supported keywords
Section titled “Supported keywords”from_json_schema understands the keywords below. A purely descriptive keyword
it does not read (title, examples) is ignored, so a partial schema still
yields a usable validator. A restrictive keyword it cannot honor
(if/then/else, patternProperties, dependentSchemas, dependencies,
unevaluatedProperties, unevaluatedItems, $dynamicRef, $recursiveRef) is
refused with a SchemaError rather than silently dropped, so an untrusted schema
is never quietly widened to accept what its author meant to forbid.
dependentRequired is honored only in the symmetric all-or-none form that maps
to an Inclusive group (see below); its asymmetric form is refused the same way.
propertyNames is honored: a mapping key is itself validated by a schema, so it
becomes the key validator of the mapping (see below). The object and array
keywords apply even on a node without a type (scoped to instances of their
type, as the spec says). to_json_schema emits the same constructs in the other
direction.
| Area | Keywords |
|---|---|
| Objects | properties, required, additionalProperties, propertyNames, minProperties, maxProperties, dependentRequired (symmetric all-or-none only) |
| Arrays | items, minItems, maxItems, prefixItems, uniqueItems, contains, minContains, maxContains |
| Strings | minLength, maxLength, pattern, format (date, date-time, time, email, uri, ipv4, ipv6, uuid, hostname, byte), writeOnly, contentEncoding (Base64) |
| Numbers | minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf |
| Values | enum, const, not, type (including a type array like ["string", "null"]) |
| Compose | anyOf, oneOf, allOf, $ref (resolved against $defs/definitions) |
The named validators map to JSON Schema like this, and decode back into the
matching validator (a writeOnly property decodes to a Secret key), so they
round-trip:
| Probatio construct | JSON Schema output |
|---|---|
ExactSequence | prefixItems (with items: false, matching minItems/maxItems) |
Unique | uniqueItems |
Contains | contains |
Equal / Literal | const (type: null for a None target) |
NotIn | not over an enum |
Email / Email() | format: email |
Url / Url() / FqdnUrl() | format: uri |
Datetime / Date / Time | format: date-time / date / time (a custom strptime format has no JSON Schema equivalent, so it exports as a plain string) |
IPv4Address / IPv6Address | format: ipv4 / ipv6 |
UUID | format: uuid |
Hostname / Fqdn | format: hostname |
Port | a bounded integer |
MultipleOf | multipleOf |
Secret key | its property with writeOnly: true |
Base64 | contentEncoding: base64 |
Inclusive group | dependentRequired (all-or-none); the symmetric map decodes back to an Inclusive group |
| Variable key validator | propertyNames, for a mapping that declares no properties (a plain str key emits nothing: it accepts every JSON key) |
Combinators and a few more constructs also render, though most have no inverse so they do not round-trip:
| Probatio construct | JSON Schema output |
|---|---|
Any / Or | anyOf (one branch renders as that branch alone, none as not: {}) |
Union / Switch | anyOf (the discriminant is an optimization, so any branch is allowed) |
All / And | one merged object, or allOf when two validators disagree on a keyword |
Maybe | anyOf with {"type": "null"} |
SomeOf | oneOf (exactly one), anyOf (at least one), or allOf (all) |
Msg | the wrapped validator’s shape (the message has no JSON Schema equivalent) |
An enum.Enum class | enum of the member values |
Self | {"$ref": "#"} (a recursive reference to the document root) |
Alias | one property per accepted name (plus anyOf of required when required) |
Any key of literal names | one property per name (plus anyOf of required when the key is required) |
A group keyed on an Any | one member satisfied by any of its names, so the group renders under allOf rather than dependentRequired; a name another key can also match carries no rule at all |
Exclusive group | at-most-one (not over the pairs), or oneOf when the group is required |
Duration / AsTimedelta | format: duration, which has no decoder, so it decodes to a plain string |
The known widener: JSON Schema has a single hostname format, so both
Hostname and Fqdn export to it and decode back as Hostname, meaning a
round-tripped Fqdn accepts a dotless host the original would reject. Pin it
with a pattern or an explicit check if the distinction matters.
An Inclusive group round-trips through dependentRequired. to_json_schema
emits the symmetric all-or-none map (every member requires every other), and
from_json_schema reads that shape back into an Inclusive group per connected
set of mutually dependent properties. The general dependentRequired is broader:
an asymmetric dependency (a requires b but not the reverse) has no Inclusive
equivalent, so it is refused rather than silently widened.
propertyNames maps onto the key side of a mapping, where probatio already
validates keys with a schema. {"propertyNames": {"enum": ["a", "b"]}} decodes
to {In(["a", "b"]): ...}, so a key outside the set is rejected instead of
quietly accepted, and to_json_schema emits the keyword back for a restrictive
key validator. The two directions treat a permissive key schema differently, on
purpose. Decoding keeps {"type": "string"} as the mapping’s str key, because
a decoded schema also runs against Python mappings, where a non-string key is
reachable and should be rejected; only {} and true, which accept anything at
all, are dropped. Encoding drops a plain str key, since every JSON property
name is a string and emitting it would add noise to every record schema.
The encoder emits the keyword only for a mapping that declares no properties: JSON
Schema applies propertyNames to declared names too, while a probatio literal
key is matched ahead of the variable keys and never sees them, so emitting it
beside a declared property would reject input the schema accepts. Dropping it
widens instead, the direction the encoder is allowed to be wrong in.
This matters in practice: Zod emits propertyNames for every z.record(...) and
Pydantic emits it for a dict keyed by a Literal, which makes the keyword
common in machine-generated schemas such as MCP tool definitions.
oneOf decodes with its exact semantics (a value must match exactly one branch,
so one matching two or more is rejected), unlike the looser anyOf.
from_openapi adds the OpenAPI nullable keyword, covered in
OpenAPI.
Untrusted input is the default assumption
Section titled “Untrusted input is the default assumption”from_json_schema and from_openapi treat their input as untrusted. A JSON
Schema can arrive from anywhere, and two of its constructs can wreck a naive
decoder: a pattern that backtracks catastrophically, and a document nested deep
enough to overflow the Python stack. Both are refused with SchemaError rather
than hanging or crashing.
A nested unbounded quantifier like (a+)+ is the classic catastrophic regular
expression. Probatio refuses to compile it:
from probatio import from_json_schema
from_json_schema({"type": "string", "pattern": "(a+)+$"})A pathologically deep document hits the same wall: past a generous depth limit,
the decoder raises SchemaError instead of recursing into a RecursionError.