Skip to content

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).

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}

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=True raises SchemaError for a construct with no JSON Schema form (an unknown validator, a Coerce with a non-type target, an enum member with no JSON representation), instead of widening to {}.
  • custom_serializer is called first for each node and may return a dict to override the rendering, or the UNSUPPORTED sentinel to defer to the default, the same hook to_openapi takes.
from probatio import Schema, to_json_schema
from 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}

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)
A leading EnsureList anyOf of whichever unwrapped forms it reaches, beside the list. The scalar branch needs room for one item and an item a lone value can be, and takes on a contains as well; the null branch needs room for an empty list, which contains never leaves. Anything the branches cannot be read off, a keyword asserting about the list as a whole among it, widens instead, which strict=True reports
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
AtLeastOne / ExactlyOne anyOf / oneOf over the per-key required
AtMostOne / AllOrNone at-most-one (not over the pairs) / dependentRequired; a key that is not a plain string has no spelling, so the rule widens
RequiredWith dependentRequired, or an anyOf implication when every trigger has to fire
RequiredWithout / RequiredIf anyOf: [{not: trigger}, {required: ...}]; RequiredIf compares with const, so a value with no JSON form widens
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.

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.