JSON Schema validator

Check a JSON document against a JSON Schema draft 2020-12 schema, or compile the schema into its own WebAssembly validator. Everything runs in this page and nothing is uploaded. Both are QIP components written in Zig:

Both pass all 1,298 cases of the official JSON-Schema-Test-Suite for draft 2020-12.

Remote schemas for $ref (optional)
Loading the validator…

The page validates as you type. Valid and Invalid say whether the document conforms. If the schema itself can't be used, the validator stops with an error instead of calling the document invalid. That happens with invalid JSON, a $ref that doesn't resolve, an unsupported regular expression or an unknown required vocabulary. So a broken schema never looks like a bad document.

Compile schema to Wasm turns the schema into a standalone validator and offers it as a download. The page then checks the document with the compiled module too and says whether it agrees.

What it supports #

Patterns match anywhere in the string, as the specification says, so [a-z]+ also accepts abc<script>. Write ^[a-z]+$ to match the whole string.

Correctness #

The Compliance oracle json-schema-2020-12.comply.wasm packages the JSON-Schema-Test-Suite's 1,298 draft 2020-12 core cases, without the optional/ tests. The same oracle drives the QIP components and, through a small adapter, two JavaScript libraries.

ValidatorPassed
QIP json-schema-2020-12-must-be-valid1,298 / 1,298100.00%
QIP compiled with json-schema-to-wasm1,298 / 1,298100.00%
ata-validator 1.62.11,298 / 1,298100.00%
@cfworker/json-schema 4.1.41,257 / 1,29896.84%

cfworker always asserts format (15 cases), resolves $dynamicRef like $ref in most cases (22), and misses 4 other edge cases.

The oracle passes only when an invalid case is cleanly rejected. A validator that fails by crashing on a schema doesn't count as rejecting the document.

A second oracle, json-schema-2020-12-interoperable-regex.comply.wasm, holds the 1,292 cases whose patterns stay inside the spec's interoperable regular expression subset, plus . and escaped syntax characters such as \.. It's for validators that accept only that subset.

Speed #

Node v26.3.0 on Apple Silicon, fastest of 31 runs, each workload in a fresh process. Read the ratios rather than the exact times.

WorkloadQIPata-validator@cfworker/json-schema
Whole suite, each schema compiled and used once1.77 ms19.8 ms39.2 ms
0.75 MB document, schema compiled each time6.18 ms2.03 ms24.5 ms
0.75 MB document, schema already compiled, from bytes1.96 ms2.03 ms—
0.75 MB document, schema already compiled, from a parsed object—0.52 ms22.6 ms

The benchmark harness is in benchmarks/json-schema-2020-12/ in the qip repository.

Use it outside the browser #

The one-call validator takes a JSON envelope with the schema, the document and any remote schemas, in any key order. When the document conforms, it outputs the document's exact bytes, so it can sit in a pipeline:

printf '{"schema": {"type": "array", "items": {"type": "integer"}}, "instance": [1, 2, 3]}' \
  | npx @qip.dev/qipx qip.dev run application/json/json-schema-2020-12-must-be-valid.wasm

To reuse a schema, compile it once. The compiler takes the same envelope without "instance". The second command prints [1, 2, 3], and the third fails because "x" isn't an integer:

printf '{"schema": {"type": "array", "items": {"type": "integer"}}}' \
  | npx @qip.dev/qipx qip.dev run -o ints.wasm application/json/json-schema-to-wasm.wasm
printf '[1, 2, 3]' | npx @qip.dev/qipx run ints.wasm
printf '[1, "x"]' | npx @qip.dev/qipx run ints.wasm

qipx downloads each component from qip.dev the first time and saves it at the same path, such as application/json/json-schema-to-wasm.wasm. The compiled ints.wasm is a local file, so those two commands need no host.

The compiled module runs in any QIP host: the browser, Node, Go, Rust and Swift. It keeps the schema it checks in a custom section named qip.json-schema. The compiler checks every keyword and pattern up front, so a broken schema fails when you compile it, not when the first document reaches the broken part.

Each component reads up to 4 MiB of input. Rejection carries no detail: it says the document doesn't conform, not where. To find the failing part, validate pieces of the document against parts of the schema, or use a JavaScript library that reports errors.

See JSON components for other application/json components.