JSON Schema Draft 2020-12 validator in Zig. Single runtime dependency (mvzr for regex), zero allocations during validation, single-file implementation.
Tested against Zig 0.15.2 and 0.16.0.
- Full Draft 2020-12 keyword support (see Supported Keywords)
- Format validation as assertion —
formatrejects invalid values (not annotation-only) - Punycode/IDNA2008 hostname validation — full RFC 3492 decoder, RFC 5891/5892 codepoint classification
- Arena-friendly — schema parsing allocates, validation does not
- Tested against the official JSON Schema Test Suite — 1190/1191 non-skipped tests passing (99.9%)
Add to your build.zig.zon:
.dependencies = .{
.jsonschema = .{
.url = "https://github.com/whiskeytuesday/zig-jsonschema/archive/refs/tags/v0.2.0.tar.gz",
.hash = "...",
},
},In your build.zig, import the module:
const jsonschema = b.dependency("jsonschema", .{
.target = target,
.optimize = optimize,
});
your_module.addImport("jsonschema", jsonschema.module("jsonschema"));Then use it:
const jsonschema = @import("jsonschema");
// Schema and data are std.json.Value (parsed from JSON)
const parsed_schema = try std.json.parseFromSlice(
std.json.Value, allocator, schema_json_string, .{},
);
defer parsed_schema.deinit();
const schema = try jsonschema.parseSchema(allocator, parsed_schema.value);
const parsed_data = try std.json.parseFromSlice(
std.json.Value, allocator, data_json_string, .{},
);
defer parsed_data.deinit();
var result = jsonschema.validate(allocator, parsed_data.value, schema);
defer result.deinit();
if (result.isValid()) {
// data matches schema
} else {
for (result.errors.items) |err| {
std.debug.print("{s}: {s}\n", .{ err.instance_path, err.message });
}
}Build the static library:
zig build -Doptimize=ReleaseFast
# produces: zig-out/lib/libjsonschema.a
# zig-out/include/jsonschema.hLink against it and include the header:
#include "jsonschema.h"
#include <stdio.h>
int main(void) {
// Parse a schema
const char *schema_json =
"{\"type\": \"object\","
" \"properties\": {\"name\": {\"type\": \"string\", \"minLength\": 1}},"
" \"required\": [\"name\"]}";
jsonschema_schema *schema = jsonschema_parse(schema_json, strlen(schema_json));
if (!schema) {
fprintf(stderr, "schema parse failed\n");
return 1;
}
// Validate data
const char *good = "{\"name\": \"Alice\"}";
jsonschema_result *r1 = jsonschema_validate(schema, good, strlen(good));
printf("valid: %d\n", jsonschema_result_is_valid(r1)); // 1
jsonschema_free_result(r1);
// Invalid data
const char *bad = "{\"name\": \"\"}";
jsonschema_result *r2 = jsonschema_validate(schema, bad, strlen(bad));
printf("valid: %d\n", jsonschema_result_is_valid(r2)); // 0
size_t n = jsonschema_result_error_count(r2);
for (size_t i = 0; i < n; i++) {
size_t msg_len, path_len;
const char *msg = jsonschema_result_error_message(r2, i, &msg_len);
const char *path = jsonschema_result_error_path(r2, i, &path_len);
printf(" error at %.*s: %.*s\n",
(int)path_len, path, (int)msg_len, msg);
}
jsonschema_free_result(r2);
jsonschema_free_schema(schema);
return 0;
}Compile and link:
cc -o validate example.c -Lzig-out/lib -ljsonschema -lc -lmtype, enum, const
properties, patternProperties, additionalProperties, required, propertyNames, minProperties, maxProperties, dependentRequired, dependentSchemas
items, prefixItems, minItems, maxItems, uniqueItems, contains, minContains, maxContains
minLength, maxLength, pattern, format
minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf
allOf, anyOf, oneOf, not
if, then, else
$ref, $defs
true (accept everything), false (reject everything)
uuid, email, date, date-time, time, uri, uri-reference, hostname, ipv4, ipv6, regex, json-pointer
These keywords are recognized and rejected at parse time (error.UnsupportedKeyword), not silently ignored. This is a deliberate design choice — silent acceptance of keywords that alter validation semantics would give users false confidence that their schemas are fully enforced.
What they do: Catch properties or array items that weren't "evaluated" by any subschema in a composition (allOf, anyOf, oneOf, if/then/else). This requires tracking which properties were touched by which branch of schema composition — annotation collection.
Why not supported: Annotation collection fundamentally changes the validation architecture. Every subschema evaluation must propagate metadata about which properties/items it examined, and the parent must aggregate this across all branches. This couples composition and object/array validation in a way that adds significant complexity for a feature that has limited real-world use. Most schemas that need to restrict extra properties can use additionalProperties at the leaf level.
Workaround: Use additionalProperties: false on each individual schema in your composition, listing all expected properties explicitly. This is more verbose but unambiguous.
What they do: Enable dynamic scoping for $ref resolution. A $dynamicRef resolves to the nearest $dynamicAnchor in the call stack of schema evaluation, rather than the lexical location. This is primarily used for recursive extension — allowing a base schema to define a recursive point that derived schemas can override.
Why not supported: Dynamic scoping requires maintaining a runtime stack of evaluation contexts, fundamentally different from the static resolution model used by $ref. The primary use case (extensible recursive schemas) is niche, and the mental model is notoriously difficult to reason about correctly. The JSON Schema specification itself notes this feature is for advanced use cases.
Workaround: Use $ref with $defs. For recursive schemas, define the full schema inline rather than relying on dynamic dispatch.
What they do: Named anchors within a schema document, allowing $ref: "#my-anchor" instead of JSON Pointer paths like $ref: "#/$defs/foo".
Why not supported: $defs + $ref with JSON Pointer paths covers the same functionality. Named anchors add a second naming system that must be tracked and resolved alongside JSON Pointer paths, with no additional expressive power. They also interact with $dynamicAnchor (which I don't support), and their primary ergonomic benefit (shorter paths) doesn't justify the implementation complexity.
Workaround: Use $defs with $ref: "#/$defs/name".
What they do: Declare which JSON Schema vocabularies a metaschema requires. Validators that don't support a required vocabulary must refuse to validate.
Why not supported: Vocabulary negotiation is a meta-level feature for validators that need to support arbitrary custom metaschemas. I implement a fixed set of Draft 2020-12 keywords. Supporting $vocabulary would require remote $schema fetching and vocabulary registry — infrastructure concerns orthogonal to validation.
Workaround: None needed — the supported keyword set is documented. If your schema uses only supported keywords, it will validate correctly.
What it does: Fetch schemas from URLs ($ref: "https://example.com/schema.json").
Why not supported: Network I/O during schema parsing introduces latency, failure modes (DNS, timeouts, certificate errors), and security concerns (SSRF). It also breaks the property that schema parsing is a pure function of its input. All schemas must be self-contained or use local $defs.
Workaround: Inline referenced schemas into $defs and use local $ref: "#/$defs/name".
mvzr ("Minimum Viable Zig Regex") is a compact bytecode regex engine (~2000 lines, no dependencies beyond std). It provides the regex support needed for the pattern keyword, patternProperties, and the regex format validator.
mvzr is zero-allocation — regex compilation and matching happen in fixed-size structs (64 operations, 8 character sets by default), with no heap allocation.
Security consideration: regex denial-of-service. mvzr uses backtracking, which means carefully crafted patterns can cause exponential matching time. Patterns like (a+)+$ matched against "aaaaaaaaaaaaaaaaab" will hang. This is a concern only if untrusted users can supply regex patterns — i.e., if user-supplied strings end up in pattern or patternProperties keys in your schemas.
If your schemas are authored by trusted developers (the typical case), this is not a concern. If you accept schemas from untrusted sources, you should either:
- Validate/reject regex patterns before schema parsing (e.g., reject patterns with nested quantifiers)
- Set timeouts around schema validation calls
- Strip
patternandpatternPropertiesfrom untrusted schemas before parsing
The pattern keyword validates data against a schema-defined pattern. The data string being matched cannot cause exponential blowup — only the pattern itself can. So even with untrusted data, trusted schemas are safe.
These keywords are accepted in schemas but have no validation effect. They are metadata for documentation and tooling:
contentEncoding— content encoding hint (e.g.,"base64")contentMediaType— media type hint (e.g.,"application/json")contentSchema— schema for decoded content
Per the JSON Schema spec, these are informational only and do not affect validation.
Tested against the official JSON Schema Test Suite for Draft 2020-12.
| Metric | Count |
|---|---|
| Test files run | 44 of 46 keyword files + 12 format files |
| Tests passed | 1190 |
| Tests failed | 1 |
| Tests skipped | 305 (unsupported keywords/refs) |
| Pass rate | 99.9% of non-skipped tests |
| File | Reason |
|---|---|
format.json |
Tests annotation-only semantics (everything passes). I use optional/format/*.json which tests assertion semantics — the behavior users expect. |
refRemote.json |
Tests remote $ref via URL. Not supported by design (see above). |
One test in vocabulary.json fails: a custom metaschema with no validation vocabulary expects all inputs to pass. This requires remote $schema resolution to detect that validation is disabled, which I don't support.
Two other Zig JSON Schema validators exist:
- DrDeano/jsonschema — Targets Draft 7 with partial keyword coverage (missing
contains,if/then/else,$ref,format,uniqueItems, and others). Appears dormant since December 2022. - pascalPost/json-schema-validator — Also targets Draft 7 with broader keyword support but incomplete
$refanddefinitionshandling.
This project exists because I needed Draft 2020-12, which is the current standard and the schema language underlying OpenAPI 3.1. Neither existing project targets a post-Draft-7 spec.