Deep dive
How it works
In: untrusted JSON bytes + a compiled schema · Out: accept (typed value) or {pointer, keyword} error · State: the compiled schema tree, built once at start-up · Cost: O(instance size) per document, depth-bounded
- Cap the body at the transport (RFC 8259 §9 allows limits on size, depth, number range and string length).
- Parse. Reject duplicate member names (RFC 8259 §4: unpredictable, many parsers keep the last), invalid UTF-8, excess depth and out-of-range numbers.
- At start-up compile the schema: check it against its meta-schema, resolve
$refoffline, build a tree, compile regexes with a linear-time engine or step limit. - Validate. Assertions:
type,enum,const,minimum/maximum,minLength/maxLength,pattern,required,maxItems,items/prefixItems,additionalProperties,$ref. Annotations (title,default,formatby default,content*) never reject. - Stop at the first error, or collect at most N.
- Return 400/422 with
{pointer, keyword}; log detail privately. - Convert the accepted value into a typed struct.
bytes ─► cap ─► parse (depth, dups, numbers) ─► validate ◄── compiled schema (once)
│ ok ─► typed value ─► handler
└ err ─► {pointer, keyword}
Code
Subset validator in std Rust: type, scalar enum, minimum/maximum, maxLength, maxItems, items, required, properties, additionalProperties, $ref: "#" (recursion into the root). Strict compile, depth limit, JSON Pointer errors. The parser (byte cap, depth, duplicates) is not shown; Obj is a Vec so duplicates stay visible to it.
#[derive(Clone, Debug, PartialEq)]
pub enum Json { Null, Bool(bool), Num(f64), Str(String), Arr(Vec<Json>), Obj(Vec<(String, Json)>) }
#[derive(Clone, Copy, PartialEq)] enum Ty { Null, Bool, Int, Num, Str, Arr, Obj }
#[derive(Default)]
pub struct Schema {
ty: Option<Ty>, choices: Vec<Json>, min: Option<f64>, max: Option<f64>,
max_len: Option<usize>, max_items: Option<usize>, items: Option<Box<Schema>>,
props: Vec<(String, Schema)>, required: Vec<String>, closed: bool, recurse: bool,
}
enum Seg<'a> { Key(&'a str), Idx(usize) }
#[derive(Debug, PartialEq)] pub struct Error { pub pointer: String, pub keyword: &'static str }
impl Schema {
/// Compile once. Strict like Ajv: an unknown or malformed keyword is an error, not an annotation.
pub fn compile(doc: &Json, depth: usize) -> Result<Schema, String> {
if depth > 16 { return Err("schema too deep".into()); }
let Json::Obj(kw) = doc else { return Err("schema must be an object".into()) };
let mut s = Schema::default();
for (k, v) in kw {
match (k.as_str(), v) {
("type", Json::Str(t)) => s.ty = Some(match t.as_str() {
"null" => Ty::Null, "boolean" => Ty::Bool, "integer" => Ty::Int, "number" => Ty::Num,
"string" => Ty::Str, "array" => Ty::Arr, "object" => Ty::Obj,
_ => return Err(format!("unknown type {t}")) }),
("minimum", Json::Num(n)) => s.min = Some(*n),
("maximum", Json::Num(n)) => s.max = Some(*n),
("maxLength", Json::Num(n)) => s.max_len = Some(*n as usize),
("maxItems", Json::Num(n)) => s.max_items = Some(*n as usize),
("enum", Json::Arr(a)) if a.iter().all(|x| !matches!(x, Json::Arr(_) | Json::Obj(_))) => s.choices = a.clone(),
("required", Json::Arr(a)) => for x in a {
let Json::Str(name) = x else { return Err("required wants strings".into()) };
s.required.push(name.clone());
},
("properties", Json::Obj(p)) => for (name, sub) in p {
s.props.push((name.clone(), Schema::compile(sub, depth + 1)?));
},
("additionalProperties", Json::Bool(b)) => s.closed = !*b,
("items", sub) => s.items = Some(Box::new(Schema::compile(sub, depth + 1)?)),
("$ref", Json::Str(r)) if r == "#" => s.recurse = true, // only "recurse into the root"
_ => return Err(format!("unsupported or malformed keyword {k}")),
}
}
Ok(s)
}
pub fn validate(&self, v: &Json, max_depth: usize) -> Result<(), Error> {
let mut path = Vec::new();
self.check(self, v, &mut path, 0, max_depth).map_err(|keyword| {
let mut pointer = String::new(); // built only on failure
for seg in &path {
match seg {
Seg::Key(k) => { pointer.push('/'); pointer += &k.replace('~', "~0").replace('/', "~1"); }
Seg::Idx(i) => pointer += &format!("/{i}"),
}
}
Error { pointer, keyword }
})
}
// On Err the path is left pointing at the failing location (no pop after `?`).
fn check<'a>(&self, root: &Schema, v: &'a Json, path: &mut Vec<Seg<'a>>, d: usize, lim: usize) -> Result<(), &'static str> {
if d > lim { return Err("depth"); }
if self.recurse { root.check(root, v, path, d + 1, lim)?; }
if let Some(t) = self.ty {
let ok = match (t, v) {
(Ty::Null, Json::Null) | (Ty::Bool, Json::Bool(_)) | (Ty::Num, Json::Num(_))
| (Ty::Str, Json::Str(_)) | (Ty::Arr, Json::Arr(_)) | (Ty::Obj, Json::Obj(_)) => true,
(Ty::Int, Json::Num(n)) => n.is_finite() && n.fract() == 0.0,
_ => false,
};
if !ok { return Err("type"); }
}
if !self.choices.is_empty() && !self.choices.contains(v) { return Err("enum"); }
match v {
Json::Num(n) => {
if self.min.is_some_and(|m| *n < m) { return Err("minimum"); }
if self.max.is_some_and(|m| *n > m) { return Err("maximum"); } }
// bytes >= chars, so counting is needed only when the byte length already exceeds the limit
Json::Str(s) => if self.max_len.is_some_and(|m| s.len() > m && s.chars().count() > m) { return Err("maxLength") },
Json::Arr(a) => {
if self.max_items.is_some_and(|m| a.len() > m) { return Err("maxItems"); }
if let Some(item) = &self.items {
for (i, x) in a.iter().enumerate() {
path.push(Seg::Idx(i));
item.check(root, x, path, d + 1, lim)?;
path.pop();
}
}
}
Json::Obj(m) => {
if self.required.iter().any(|r| !m.iter().any(|(k, _)| k == r)) { return Err("required"); }
for (k, x) in m {
path.push(Seg::Key(k));
match self.props.iter().find(|(n, _)| n == k) {
Some((_, sub)) => sub.check(root, x, path, d + 1, lim)?,
None if self.closed => return Err("additionalProperties"),
None => {}
}
path.pop();
}
}
_ => {}
}
Ok(())
}
}
#[cfg(test)] mod t { use super::{Json::*, *};
fn s(x: &str) -> Json { Str(x.into()) }
fn o(v: Vec<(&str, Json)>) -> Json { Obj(v.into_iter().map(|(k, x)| (k.into(), x)).collect()) }
#[test] fn basics() {
let id = o(vec![("type", s("integer")), ("minimum", Num(1.0))]);
let doc = o(vec![("additionalProperties", Bool(false)), ("properties", o(vec![("id", id)]))]);
let sc = Schema::compile(&doc, 0).unwrap();
assert_eq!(sc.validate(&o(vec![("id", Num(2.0))]), 8), Ok(()));
assert_eq!(sc.validate(&o(vec![("id", Num(0.0))]), 8), Err(Error { pointer: "/id".into(), keyword: "minimum" }));
assert_eq!(sc.validate(&o(vec![("a/b", Null)]), 8).unwrap_err().pointer, "/a~1b");
}
}
Compiled and run with a larger assert suite (every keyword, UTF-8 maxLength, schema typo rejected, a 100-deep tree stopped at limit 64, a self-looping {"$ref":"#"} stopped by the limit): all passed.
Complexity and constants
- O(nodes visited); each object member scans the schema's
propertieslinearly (hash or sort beyond a few dozen). maxLengthcounts code points (RFC 8259 characters), exact check O(bytes); the byte-length shortcut makes the common pass O(1).uniqueItemsis quadratic without hashing;patternis the only keyword with exponential potential.- Cost drivers: allocations (path stack, error strings), regex steps, bytes parsed. No syscalls or fsync: pure CPU.
Efficiency
Cost: measured here (Apple M4 Pro, release build, one thread, 2M runs, document with 4 members and a 2-item array): about 70 ns per valid document and about 145 ns for a document that fails at its tail, including building the pointer string. Allocation-bound; parsing the bytes costs more than validating.
Levers:
- Compile once and reuse (
jsonschemaREADME: "Build & reuse (faster)") — recompiling per request is the common waste — always. - Generate the validator at build time — about 2.3-10x faster than the runtime validator on its benchmark schemas (
jsonschemaREADME, macros feature); Ajv also generates code — fixed schemas. - Fail fast — bounds work per hostile document (Ajv's security page: no
allErrorsin production) — public endpoints. - Byte-length before character count — skips scanning long strings (design, this page's code) — string-heavy messages.
- Linear-time regex engine (RE2 in Ajv via
code.regExp; theregexcrate injsonschema) — removes ReDoS at the cost of look-ahead and back-references (Ajv docs) — anypattern.
Naive → standard → state of the art:
- Parse the schema again and walk it as JSON for every request · extra O(schema) per document.
- Compile to a tree once, fail fast, limits at the parser · about 70 ns here.
- Schema compiled to code at build time, typed decode of the accepted value · fastest (
jsonschemamacros, Ajv standalone).
Implementation notes
- Limits where cost starts: body cap in the HTTP layer, depth in the parser (
serde_jsonstops at depth 128; only theunbounded_depthfeature plusdisable_recursion_limitlifts it),maxItemsandmaxLengthon every array and string. - Numbers: RFC 8259 §6 calls integers within ±(2^53-1) interoperable as doubles; JSON Schema sets no bound (validation spec §4.2). Send 64-bit ids as strings or parse integers without f64.
- Closed objects:
additionalPropertiessees only thepropertiesof its own schema object; acrossallOfuseunevaluatedProperties(core §11.3). - Errors: pointer plus keyword. Attacker-chosen member names appear in pointers; cap their length when logging.
- Never compile attacker-supplied schemas: Ajv treats schemas as trusted as application code;
jsonschemaresolves HTTP and file$refby default.
Pitfalls
- Trusting
format: email: an annotation by default in 2020-12; the Format-Assertion vocabulary is optional and formats may be no-ops. Check your library's switch. - Keyword typos pass as annotations. Compile strictly (Ajv
strict, this page's compiler). patternis not anchored (validation spec §6.3.3):[a-z]+matches"1a1". Write^[a-z]+$.(a+)+$onaaaa…!backtracks exponentially in backtracking engines; validation spec §10 warns of it. AddmaxLength, but it does not always save you (Ajv).{}andtrueaccept everything; a$refcycle must not loop (core §13: MUST NOT).- Parsers disagree on duplicate keys, so a proxy and the service can see different documents. Reject duplicates.
maxLengthcounts code points, not bytes or grapheme clusters; see Unicode confusables for look-alike names.integeraccepts1.0(zero fractional part).
Variants and alternatives
serde with deny_unknown_fields, off by default for JSON)one struct per message, no second language.In production
- Tauri validates its config and Apollo Router its configuration with the Rust
jsonschemacrate (its README lists both). - OpenAPI 3.1 request validation in API gateways (finance, retail and telecom APIs).
- Tool calling: tool definitions carry a JSON Schema (
input_schemain Anthropic's tool-use API); the model's arguments are still untrusted input. - Healthcare: the
jsonschemacrate benchmarks on the FHIR schema. - Game servers and IoT brokers apply the same pattern to client messages, often with typed decoders.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| jsonschema | Rust | Stranger6667/jsonschema | cargo add jsonschema | — | — | — | — | validator_for builds a reusable validator; with_pattern_options (fancy-regex default, backtrack_limit); build-time macro; HTTP/file $ref resolution on by default |
| boon | Rust | santhosh-tekuri/boon | cargo add boon | — | — | — | — | drafts 4 to 2020-12; format assertions behind a flag for 2019-09 and later; ECMA-262 regex |
| Ajv | JS/TS | ajv-validator/ajv | npm install ajv | — | — | — | — | compiles schemas to JS; strict mode; formats need ajv-formats; RE2 hook |
| jsonschema | Python | python-jsonschema/jsonschema | pip install jsonschema | — | — | — | — | format checking only with a format_checker; validate() checks the schema first |
Sources
- Wright, Andrews, Hutton, Dennis, JSON Schema: A Media Type for Describing JSON Documents, Internet-Draft, draft 2020-12 (core), June 2022. spec
- Wright, Andrews, Hutton, JSON Schema Validation: A Vocabulary for Structural Validation of JSON, Internet-Draft, draft 2020-12, June 2022. spec
- T. Bray (ed.), RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format, 2017. RFC
- Bryan, Zyp, Nottingham, RFC 6901: JavaScript Object Notation (JSON) Pointer, 2013. RFC
- Ajv, Security considerations and Strict mode. security · strict
jsonschemacrate docs,ValidationOptions. docs- serde, Container attributes (
deny_unknown_fields). doc · serde_json