json-schema-engine (Python)
A JSON Schema implementation for Python that aims to be both spec-complete and built for speed: an interpreter that is the reference semantics, a compiler tier for hot paths, full annotation collection, and every standard output format. It is the Python counterpart of handrews/json-schema-engine and shares its architecture: two tiers, one keyword registry, a frame-scoped record channel, and the new (2026) IETF working group draft-03 relevance model.
Produced by Henry Andrews via Claude Code.
Status: 0.0.3 is fully compliant except for draft-04 support.
See CHANGELOG.md for the current release's contents. DESIGN.md is the design contract and carries the milestone status.
All 0.0.x releases will have AI-written documentation. Version
0.1.0 will indicate that the documentation has been audited and revised
by a human.
The regular-expression translator lives in its own package,
ecma-regex (0.1.0): ECMA-262 patterns
for Python, with JavaScript semantics, no dependency on this engine.
Install
pip install json-schema-engine
Python 3.12+. Extras: [idna] for IDNA2008 support (idn-hostname, and the
xn-- label check inside hostname); [regex] to swap in the regex
package as the pattern backend.
Use
from json_schema_engine.core import create_engine
engine = create_engine()
uri = engine.register_schema(
{
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
"https://example.com/person",
)
assert engine.evaluate(uri, {"name": "Ada"}).valid
result = engine.evaluate(uri, {}, output="list")
assert result.errors is not None and result.errors[0]["evaluationPath"] == "/required"
Four drafts are built in: 2020-12 (the default), 2019-09, draft-07, and
draft-06. A document's $schema selects its dialect; create_engine( default_dialect=DIALECT_DRAFT_07) sets the dialect for documents without
one. Each keeps its own semantics, so a draft-07 $ref ignores its
siblings while a 2019-09 one does not.
Every standard output format is available by name: flag (the default),
basic, detailed, and verbose from the
IETF working group draft-03
output sections, and list and hierarchical from the
machines-oriented proposal.
Annotations are a separate control (annotations=True, or an
AnnotationSelection), verbose=True asks list/hierarchical for the
verbose level with irrelevant records marked as dropped, and trace=True
adds the application tree with error indexes into result.errors.
result = engine.evaluate(uri, {"name": 3}, output="hierarchical")
assert result.output_document == {
"valid": False,
"evaluationPath": "",
"schemaLocation": "https://example.com/person#",
"instanceLocation": "",
"details": [
{
"valid": False,
"evaluationPath": "/properties/name",
"schemaLocation": "https://example.com/person#/properties/name",
"instanceLocation": "/name",
"errors": {"type": "expected string"},
}
],
}
create_engine(validate_schemas=True) checks every registered document
against its metaschema and raises SchemaValidationError with the errors.
A loader that reports source positions (see parse_json_with_ranges,
exported from json_schema_engine.core) lets evaluate(..., positions=True)
attach a source location to every error and annotation, and
engine.locate() answers the same question for any schema location.
Compile
The compiler tier turns a registered schema into a Python function. It is
not a second implementation: any subschema it cannot emit (a $dynamicRef
whose target differs by path, an in-place cycle) calls back into the
interpreter, so a compiled validator is exactly
as correct as Engine.evaluate and never less complete. Tier choice is a
performance decision, not a semantic one.
from json_schema_engine.compiler import compile_validator, emit_standalone
compiled = compile_validator(engine, uri)
assert compiled.validate({"name": "Ada"}) is True
assert compiled.validate({}) is False
print(compiled.source) # the emitted module, for reading
module_source = emit_standalone(engine, uri) # importable without the compiler
assert "def validate" in module_source
assert "import json_schema_engine.compiler" not in module_source
compile_validator returns a verdict-only validator (the flag level) that
binds a snapshot of the registries at compile time, so register everything
first. emit_standalone writes the same code as a module that imports
only the standard library and this package's pure helpers; it refuses,
with StandaloneUnsupportedError, a schema that would need the
interpreter at evaluation time. Compiled code assumes plain data as
json.loads produces it (dict, list, str, int, float, bool,
None); subclasses of those types belong to the interpreter.
compile_evaluator compiles a schema into an evaluator serving every
output format the interpreter does — errors, annotations, dropped
records, and the application trace, not only the verdict. The annotation
selection is fixed at compile time; every other control (output,
error_params, verbose, trace, positions) is chosen per call, same
as Engine.evaluate.
from json_schema_engine.compiler import compile_evaluator
evaluator = compile_evaluator(engine, uri, annotations=True)
compiled_result = evaluator.evaluate({}, output="list", error_params=True)
interpreted_result = engine.evaluate(
uri, {}, output="list", error_params=True, annotations=True
)
assert compiled_result.errors == interpreted_result.errors
Formats
format annotates by default in every dialect (the specs' default, and
what the official format.json legs require). Assertion is opt-in, from a
format table implemented from each format's RFC and verified against the
official optional/format suite:
from json_schema_engine.core import create_engine
from json_schema_engine.formats import FORMATS_2020_12, format_table_for
# The 2020-12 format-assertion vocabulary: a metaschema declaring it makes
# `format` assert; names the table lacks are refused at registration.
engine = create_engine(formats=FORMATS_2020_12)
# Best effort in every standard dialect: known names assert, unknown names
# annotate only.
engine = create_engine(formats=FORMATS_2020_12, assert_formats=True)
uri = engine.register_schema({"format": "date-time"}, "https://example.com/dt")
assert engine.evaluate(uri, "1998-12-31T23:59:60Z").valid
assert not engine.evaluate(uri, "1998-12-31T22:59:60Z").valid
FORMATS_2020_12 (also 2019-09) carries the nineteen defined formats;
FORMATS_DRAFT_07 and FORMATS_DRAFT_06 carry each draft's list, and
format_table_for(dialect_uri) picks one. A metaschema that declares the
format-assertion vocabulary on an engine without a table raises
FormatsRequiredError, and assert_formats=True without a table does too.
A custom table is any mapping of names to FormatDefinition(test, types);
types scopes a format to instance types other than strings.
idn-hostname and the A-label checks inside hostname need IDNA2008,
provided by the idna extra:
pip install 'json-schema-engine[idna]'
Without it, asserting idn-hostname raises FormatUnavailableError at
registration, and hostname accepts a well-formed xn-- label without
decoding it. Compiled validators assert formats too; a standalone module
imports the predicates it needs from json_schema_engine.formats, so the
extra must be installed wherever such a module runs.
Documentation
- User guide — validation, output formats, annotations, dialects, loaders, source positions, metaschemas, custom keywords, security, compiling schemas, and formats, topic by topic.
- API reference — every public name, by package.
- CONTRIBUTING.md — setup, gates, and conventions.
- CHANGELOG.md — what changed, release by release.
- DESIGN.md — the engineering design contract and milestone status.
Security
Schemas and instances are both often untrusted input. The interpreter
generates no code — there is no compile() or exec() on its path — so
code-injection concerns do not apply to it; a denial-of-service bound is
best effort, not a guarantee, so treat wildly untrusted schemas with the
same care as any other untrusted program input. Three specific vectors have
a bound or an opt-out.
Regular expressions (ReDoS). pattern and patternProperties compile
untrusted regexes and run them against untrusted strings; Python's re can
backtrack catastrophically on a pattern like (a+)+$. reject_unsafe_regex
screens for nested unbounded quantifiers at registration:
from json_schema_engine.core import create_engine, UnsafeRegexError
engine = create_engine(reject_unsafe_regex=True)
try:
engine.register_schema({"pattern": "(a+)+$"}, "https://ex/redos")
except UnsafeRegexError:
pass # rejected before it ever runs
else:
raise AssertionError("expected UnsafeRegexError")
Patterns are ECMA-262 by default, translated by the ecma-regex package to
the re backend (a regex backend is available via the regex extra);
neither backend is linear-time, so the screen is a heuristic, not a proof.
regex_dialect="python" hands patterns to re untouched, for schemas
written for Python only.
Recursion depth. max_depth (default 512) bounds both registration
nesting and evaluation nesting, raising the typed MaxDepthExceededError
before CPython's own stack limit can produce an untyped RecursionError; a
stray RecursionError that does slip through is still converted to the
same typed error. The engine stays usable afterward — each evaluate or
register_schema call runs in fresh state, so a rejected document does not
poison later calls.
Array uniqueness. uniqueItems compares elements in O(n) by bucketing
on a canonical key and confirming collisions with full JSON equality, so
large arrays of distinct values do not incur quadratic cost, while genuine
duplicates — including numbers equal across int/float and objects that
differ only in member order — are still reported.
Development
uv sync --all-packages --all-groups
uv run pytest -q
uv run ruff check . && uv run ruff format --check .
uv run pyright
uv run lint-imports
The official test suite is a git submodule at test-suite/; clone with
--recurse-submodules or run git submodule update --init.
The Bowtie conformance leg builds a container
image and runs the suite through Bowtie's harness protocol. It needs a
reachable container engine (Docker, or podman machine start) and fetches
Bowtie through uvx:
uv run python scripts/bowtie_check.py
Benchmarks
uv run python scripts/bench.py --budget-ms 250 --filter user
scripts/bench.py times every jse tier (the interpreter, the compiler's
flag validator and evaluator, and the standalone artifact) against two
competitors (fastjsonschema, jsonschema) over seven corpora in
packages/bench: three small hand-authored schemas, the official OpenAPI
3.1 schema against a real document, a generated API-payload corpus, and
two 2000-record corpora. Every jse tier is timed against every corpus,
jse standalone against oas-document included, since its $dynamicRef
sites resolve at plan time and leave no interpreted unit. Two subjects
measure the record-producing tier against the verdict-only tiers:
jse interpreter list and jse compiled evaluator (list), both timing
output="list". The bench is report-only (it enforces no performance
threshold) and, per the IP policy below, runs the competitors only, never
reading or porting their source. --filter takes a regex over
corpus/subject/partition names; omit --out to skip writing JSON;
--compare BEFORE AFTER prints a before/after comparison of two results
files. The committed run lives at packages/bench/results/results.json
(--budget-ms 250). The interpreter is the reference semantics, so ratios
are informational, not a compatibility claim.
packages/bench/README.md covers corpus
provenance and licensing, methodology, and the recorded exclusions.
IP policy
The implementation is written from the JSON Schema specifications and the official test suite only. Other validators are executed as benchmark subjects and correctness oracles; their source is never used as an implementation reference. See DESIGN.md §0.
Release files for json-schema-engine 0.0.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| json_schema_engine-0.0.3.tar.gz | 137.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| json_schema_engine-0.0.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 316.6 kB
Release files / json_schema_engine-0.0.3.tar.gz
| Download URL | json_schema_engine-0.0.3.tar.gz |
|---|---|
| Size | 137.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f3e72770d652ee0ca68eddd687cbe4d42182545f01ac3ecd94b7e2ef53136ee6
|
|
BLAKE2b-256 checksum How to use checksums |
87fce33b088af3064af1c61b5f36233ac765825c7d13fce2119de3bba7c2a842
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.
Transparency logRelease files / json_schema_engine-0.0.3-py3-none-any.whl
| Download URL | json_schema_engine-0.0.3-py3-none-any.whl |
|---|---|
| Size | 179.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c1b73334985f67cf1facc8f52937058bc3cfda393931519e12582366e39d9bad
|
|
BLAKE2b-256 checksum How to use checksums |
64041546d523f00630fa0488bb050b860d4230bc72a6d10aa6fe979920169739
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.
Transparency log