mrkeyoor.com_
Wed 05 Aug 23:08 UTC
PyPIUtilsupdated 05 Aug 2026

jsonschema

jsonschema is the reference-grade Python implementation of the JSON Schema specification, supporting drafts 3 through 2020-12. You hand it a schema and an instance and it validates, either fail-fast with validate() or exhaustively with iter_errors(), with rich error objects that tell you exactly which keyword failed at which path. Maintained by Julian Berman for well over a decade, it is what OpenAPI tooling, Jupyter, and most config validation in the Python world quietly run on.

Verdict

The correct choice whenever JSON Schema documents themselves are the contract: spec-faithful, actively maintained, and with the best error reporting in the ecosystem. Know the two footguns (opt-in format checks, per-call schema revalidation) and reach for fastjsonschema in hot loops or pydantic for code-owned models.

API stability4/5validate() and the Draft*Validator classes have been stable for a decade; the one real upheaval was moving $ref handling to referencing in 4.18, which still generates deprecation-warning noise in older codebases.
Docs4/5readthedocs covers validation, error handling, and extension well, and the error documentation is genuinely good; the referencing migration docs remain the most-asked-about gap.
Maintenance4/5Single primary maintainer (Julian Berman) but relentlessly consistent: 4.26.0 current, pushed August 2026, spec-suite tracking, Tidelift-supported; the bus factor is the honest caveat.
Ecosystem5/5147M weekly downloads as the validation layer of OpenAPI tooling, Jupyter, and countless config systems, with companion projects (referencing, jsonschema-specifications, check-jsonschema) under the same org.

Use it if

  • Your schemas are data, not code: they arrive as JSON from an API contract, OpenAPI spec, or another team, which is exactly what pydantic is awkward at
  • You need spec-correct behavior across drafts, including $ref resolution, $dynamicRef, and the official test suite passing
  • You want all validation errors at once with precise JSON paths for building user-facing error reports
  • You validate config files or webhook payloads against schemas you also publish for other languages
Skip it if

Setup reality

pip install jsonschema pulls attrs, rpds-py (compiled Rust wheels, occasionally a headache on exotic platforms), referencing, and jsonschema-specifications. The two classic traps: format keywords are silently ignored unless you both install the [format] extra and pass a FormatChecker, and validate() re-checks the schema itself on every call, so looping over documents with validate() is quietly quadratic-feeling; build a validator instance once instead. The RefResolver-to-referencing migration is the other tax: post-4.18 code should use Registry and Resource, and the two models are different enough that old snippets do not translate line-for-line.

Patterns

Validate fail-fastbasic-validate

from jsonschema import validate, ValidationError

schema = {
    "type": "object",
    "properties": {"price": {"type": "number"}},
    "required": ["price"],
}

try:
    validate(instance={"price": "9.99"}, schema=schema)
except ValidationError as e:
    print(e.message)  # '9.99' is not of type 'number'

validate() raises on the first error and also validates the schema itself every call. Fine for one-offs; wasteful in loops, where you want a validator instance instead.

Build the validator once for loopsvalidator-instance

from jsonschema import Draft202012Validator

Draft202012Validator.check_schema(schema)  # once, at startup
validator = Draft202012Validator(schema)

for doc in documents:
    if validator.is_valid(doc):
        ingest(doc)

This skips per-call schema checking and pins the draft explicitly instead of guessing from $schema. It is the single biggest jsonschema performance fix and costs one line.

Report every error, not just the firstcollect-all-errors

validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(instance), key=lambda e: e.path)
for error in errors:
    where = "/".join(str(p) for p in error.absolute_path) or "<root>"
    print(f"{where}: {error.message}")

iter_errors is lazy and unordered; sort by path for humans. error.json_path gives the same location in '$.a.b[0]' form if you prefer JSONPath-style output.

Pick the most relevant error to showbest-match-error

from jsonschema.exceptions import best_match

error = best_match(Draft202012Validator(schema).iter_errors(instance))
if error is not None:
    raise ValueError(f"invalid payload: {error.message}")

With anyOf/oneOf schemas the raw error list is a wall of noise from every failed branch; best_match applies heuristics to surface the error a human most likely caused.

Actually enable format validationformat-checking

# pip install 'jsonschema[format]'
from jsonschema import Draft202012Validator, FormatChecker

schema = {"type": "string", "format": "email"}
validator = Draft202012Validator(schema, format_checker=FormatChecker())
print(validator.is_valid("not-an-email"))  # False (finally)

Per spec, format is annotation-only by default: without both the extras and an explicit FormatChecker, 'not-an-email' validates fine. The silent no-op here is the most-reported jsonschema surprise.

Resolve $ref with the referencing librarylocal-ref-registry

from referencing import Registry, Resource
from jsonschema import Draft202012Validator

address = Resource.from_contents({
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "properties": {"city": {"type": "string"}},
})
registry = Registry().with_resource("urn:example:address", address)

schema = {"properties": {"home": {"$ref": "urn:example:address"}}}
validator = Draft202012Validator(schema, registry=registry)

This replaces the deprecated RefResolver since 4.18. Registries are immutable: with_resource returns a new registry, it does not mutate in place, which trips people porting old code.

Load referenced schemas from diskref-from-files

import json
from pathlib import Path
from referencing import Registry, Resource

def retrieve(uri: str):
    path = Path("schemas") / uri.removeprefix("https://example.com/")
    return Resource.from_contents(json.loads(path.read_text()))

registry = Registry(retrieve=retrieve)
validator = Draft202012Validator(root_schema, registry=registry)

The retrieve callback runs on cache miss for any unknown URI, which is how you back $ref by files (or a database) without pre-registering everything. jsonschema never fetches over the network by itself.

Extend a validator with a custom keywordcustom-keyword

from jsonschema.validators import Draft202012Validator, extend
from jsonschema.exceptions import ValidationError

def multiple_of_five(validator, value, instance, schema):
    if value and isinstance(instance, (int, float)) and instance % 5:
        yield ValidationError(f"{instance} is not a multiple of five")

FiveValidator = extend(Draft202012Validator, validators={"multipleOfFive": multiple_of_five})
FiveValidator({"multipleOfFive": True}).validate(12)  # raises

Keyword functions are generators yielding ValidationError; extend() builds a new validator class without touching the originals. Custom keywords are ignored by every other JSON Schema implementation, so document them.

Check that a schema is validvalidate-schema-itself

from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError

try:
    Draft202012Validator.check_schema(user_supplied_schema)
except SchemaError as e:
    print(f"bad schema: {e.message}")

Essential when schemas come from users or config: a malformed schema otherwise surfaces later as a confusing validation-time error on innocent data.

Fill in defaults while validatingdefaults-from-schema

def extend_with_default(validator_class):
    validate_props = validator_class.VALIDATORS["properties"]
    def set_defaults(validator, properties, instance, schema):
        for prop, subschema in properties.items():
            if isinstance(instance, dict) and "default" in subschema:
                instance.setdefault(prop, subschema["default"])
        yield from validate_props(validator, properties, instance, schema)
    return extend(validator_class, {"properties": set_defaults})

DefaultFiller = extend_with_default(Draft202012Validator)

jsonschema deliberately never mutates instances, so default-filling is a documented DIY recipe (this one is from the official FAQ). It mutates the input dict in place; copy first if that matters.

Route errors to form fieldserror-paths-programmatic

validator = Draft202012Validator(schema)
field_errors = {}
for error in validator.iter_errors(payload):
    key = ".".join(str(p) for p in error.absolute_path) or "__root__"
    field_errors.setdefault(key, []).append(error.message)
# {'items.0.price': ["'x' is not of type 'number'"], ...}

absolute_path is a deque of keys and indices from the instance root, which maps cleanly onto form field names or API error envelopes; error.validator tells you which keyword failed.

Alternatives

PackageRegistryPick it when
fastjsonschemaPyPIYou validate high volumes against a stable schema and want compiled-to-Python speed at some spec-strictness cost.
pydanticPyPIThe models live in your Python code and you want parsing, coercion, and types, not just a validity verdict.
check-jsonschemaPyPIYou want CLI and pre-commit validation of config files (GitHub workflows, renovate, etc.) without writing any Python.