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, propertyNames, 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. 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.

AreaKeywords
Objectsproperties, required, additionalProperties, minProperties, maxProperties, dependentRequired (symmetric all-or-none only)
Arraysitems, minItems, maxItems, prefixItems, uniqueItems, contains, minContains, maxContains
StringsminLength, maxLength, pattern, format (date, date-time, time, email, uri, ipv4, ipv6, uuid, hostname, byte), writeOnly, contentEncoding (Base64)
Numbersminimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf
Valuesenum, const, not, type (including a type array like ["string", "null"])
ComposeanyOf, 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 constructJSON Schema output
ExactSequenceprefixItems (with items: false, matching minItems/maxItems)
UniqueuniqueItems
Containscontains
Equal / Literalconst
NotInnot over an enum
Email / Email()format: email
Url / Url() / FqdnUrl()format: uri
Datetime / Date / Timeformat: date-time / date / time (a custom strptime format has no JSON Schema equivalent, so it exports as a plain string)
IPv4Address / IPv6Addressformat: ipv4 / ipv6
UUIDformat: uuid
Hostname / Fqdnformat: hostname
Porta bounded integer
MultipleOfmultipleOf
Secret keyits property with writeOnly: true
Base64contentEncoding: base64
Inclusive groupdependentRequired (all-or-none); the symmetric map decodes back to an Inclusive group

Combinators and a few more constructs also render, though most have no inverse so they do not round-trip:

Probatio constructJSON Schema output
Any / OranyOf
Union / SwitchanyOf (the discriminant is an optimization, so any branch is allowed)
All / Andone merged object, or allOf when two validators emit the same keyword
MaybeanyOf with {"type": "null"}
SomeOfoneOf (exactly one), anyOf (at least one), or allOf (all)
Msgthe wrapped validator’s shape (the message has no JSON Schema equivalent)
An enum.Enum classenum of the member values
Self{"$ref": "#"} (a recursive reference to the document root)
Aliasone property per accepted name (plus anyOf of required when required)
Exclusive groupat-most-one (not over the pairs), or oneOf when the group is required
Duration / AsTimedeltaformat: 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.

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.