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.
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.
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
- You own the data model in Python: pydantic gives you parsing, types, and IDE support, not just validation, and is the better default for internal models
- Validation sits in a hot path: pure-Python keyword dispatch is slow, and fastjsonschema compiles schemas to Python code that runs many times faster
- You expect format: 'email' to just work: format validation is opt-in per spec, needs a FormatChecker plus the [format] extras, and silently does nothing otherwise
- You have old RefResolver-based code you refuse to touch: $ref handling moved to the separate referencing library in 4.18 and RefResolver has been deprecated since
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) # raisesKeyword 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
| Package | Registry | Pick it when |
|---|---|---|
| fastjsonschema | PyPI | You validate high volumes against a stable schema and want compiled-to-Python speed at some spec-strictness cost. |
| pydantic | PyPI | The models live in your Python code and you want parsing, coercion, and types, not just a validity verdict. |
| check-jsonschema | PyPI | You want CLI and pre-commit validation of config files (GitHub workflows, renovate, etc.) without writing any Python. |