Validating

Working with JSON

Chapter 7 ยท Validating with JSON Schema

Chapter 6 taught the vocabulary. This chapter is about using it in a real program: how to run a validator without making it slow, how to express "one of these shapes" and "if this, then that", how to keep a schema closed without breaking it, how to get error messages a human can act on, how to see the schema working in your editor, how to generate types and documentation from it, and how to change a schema without breaking the people who depend on it. For where a schema sits in API design (request and response contracts), see API Types & Design 4.

Every example here was run, except where marked
Node 24.18.0 with Ajv 8.20.0, ajv-formats 3.0.1 and ajv-errors 3.0.0; Python 3.14.0 with jsonschema 4.26.0; json-schema-to-typescript 16.0.0 with TypeScript 7.0.2; json-schema-for-humans 1.5.1; and vscode-json-languageservice 6.0.0-next.4, all installed in a scratch folder (nothing global). Timings are single runs on one Windows 11 machine: use them for the ratios, not the absolute values. Not run: the VS Code window itself (I ran the JSON language service package it is built on, below), other validators, and other generators.

Run the Validator Efficiently: Compile Once

A validator library reads your schema and builds a checking function. In Ajv that step is compiling and it is expensive; running the compiled function is very cheap. I validated the six real orders, repeated to 20,000 documents:

What the code did (Ajv, Node)Time
compile the schema once39.7 ms, one time
then validate 20,000 documents with the compiled function8.1 ms total (0.40 microseconds each); all 20,000 valid
ajv.validate(schemaObject, doc) with the same schema object, 20,000 times20.4 ms (Ajv caches by object)
create a new Ajv and compile for each document, 200 documents1,943.5 ms (9.7 ms each), about 24,000 times slower per document than the compiled function
ajv.validate(freshCopyOfSchema, doc) on one Ajvthrew on the second document: schema with key or id "https://example.com/schemas/order.json" already exists

The last row is a trap worth remembering: a schema with an $id can only be registered once per Ajv. Code that re-reads or re-creates the schema for every request will either be slow (new Ajv each time) or throw (same Ajv, new copy). Compile at start-up, keep the function, call it for every document.

Python's jsonschema has a different cost profile:

What the code did (Python)Time
v = Draft202012Validator(schema, format_checker=FormatChecker()), once0.1 ms
then v.is_valid(doc), 5,000 times313.6 ms (62.7 microseconds each)
jsonschema.validate(doc, schema), 500 times1,211 ms (2.42 ms each)
Draft202012Validator(schema).is_valid(doc), 500 times (new validator each time)34.8 ms (0.07 ms each)
Draft202012Validator.check_schema(schema) alone, 500 times1,773 ms (3.55 ms each)

Building a Python validator is cheap, but jsonschema.validate() is the slow convenience function: it checks that the schema is valid on every call (given a broken schema it raised SchemaError, while Draft202012Validator(bad).is_valid did not check it, and a misspelt type name raised UnknownType only at use time). Build one validator object, call check_schema once when the schema loads, then call is_valid or iter_errors per document. Python was about 150 times slower per document than compiled Ajv here (62.7 versus 0.40 microseconds); that is rarely a problem unless you validate millions of documents.

Combining Schemas: allOf, anyOf, oneOf, not

KeywordValid whenTested
allOfevery sub-schema passes[{type: string}, {minLength: 3}, {pattern: "^a"}] accepted "abc"; rejected "ab" (minLength), "xyz" (pattern) and 5 (type)
anyOfat least one passes[string, number] accepted "x" and 1; null failed with three errors
oneOfexactly one passesthe same disjoint pair behaved like anyOf; but [integer, number] rejected 1 (it matches both: Ajv reported passingSchemas: [0,1]) and accepted 1.5, while anyOf accepted both
notthe sub-schema does not pass{not: {type: null}} rejected null (must NOT be valid) and accepted 0 and "x"

The usual mistake is oneOf where anyOf was meant: if the branches overlap, a document that matches two fails. Use oneOf only when the branches are genuinely exclusive (usually because a field such as type differs). For "a string or null", prefer "type": ["string", "null"], which gives one error instead of three. All of these gave the same verdicts in Python's jsonschema.

Conditionals: if/then/else and dependentRequired

if picks a branch: when its schema passes, then must pass; otherwise else must. A UK address needs a postcode and a US address needs a state and a zip:

{ "type": "object", "required": ["country"], "properties": { "country": { "enum": ["GB", "US", "FR"] } }, "if": { "properties": { "country": { "const": "GB" } } }, "then": { "required": ["postcode"] }, "else": { "if": { "properties": { "country": { "const": "US" } } }, "then": { "required": ["state", "zip"] } } }
{"country":"GB","postcode":"EH1 1YZ"} valid {"country":"GB"} INVALID required: must have required property 'postcode' {"country":"US","state":"NY","zip":"10001"} valid {"country":"US","state":"NY"} INVALID required: must have required property 'zip' {"country":"FR"} valid {} INVALID postcode required (the "then" branch ran) and 'country' required

The last row is the if trap. {} has no country, and an if made only of properties passes when the property is absent, so the document was sent down the then branch and was also told that postcode is missing, which is a confusing message. Put "required": ["country"] inside the if so it only fires when the property is really there (the payment schema below does). Python gave the same verdicts.

Two lighter tools: dependentRequired says "if this property is present, these others must be too" ({"card": ["expiry", "cvv"]} accepted {} and a full card, rejected {"card":"4111"}; Ajv printed one message per missing property, so two lines), and dependentSchemas applies a whole schema when a property is present ({"card": {required: ["billing"], properties: {billing: {type: "object"}}}} rejected a card with no billing, and a card with billing: "no").

Closed Objects: additionalProperties versus unevaluatedProperties

additionalProperties: false means "no keys except the ones in this schema's own properties". It cannot see inside allOf. A common design, a base schema plus extras, therefore breaks:

const base = { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] }; // A: closed with additionalProperties (the problem) { allOf: [base], properties: { name: { type: 'string' } }, additionalProperties: false } // B: closed with unevaluatedProperties (the fix) { allOf: [base], properties: { name: { type: 'string' } }, unevaluatedProperties: false }
A: {"id":1} INVALID additionalProperties (id is not in A's own properties!) A: {"id":1,"name":"x"} INVALID additionalProperties B: {"id":1} valid B: {"id":1,"name":"x"} valid B: {"id":1,"name":"x","extra":1} INVALID unevaluatedProperties

Version A rejects every document, including the simplest valid one, because id is declared inside allOf where A's additionalProperties cannot see it. unevaluatedProperties (new in 2019-09) means "no keys that no keyword in this schema or its allOf/$ref/if branches evaluated", so B is closed and still allows both id and name. The older workaround is to repeat additionalProperties: false and every property in each branch, which I also tried: the base branch with additionalProperties: false then rejected name. unevaluatedItems is the same idea for arrays (prefixItems with unevaluatedItems: false accepted ["a"] and rejected ["a", 1]). Ajv and Python gave identical verdicts for all of these (except that the repeated-branch variant was run in Ajv only).

A caveat that I met in the payment exercise: when a then branch fails, the properties it would have evaluated count as unevaluated, so the validator also reports unevaluatedProperties errors for them. For the document {"method":"card","amount":500,"card_number":"411111111111111","expiry":"10/29"} (15 digits) Ajv listed the real error, /card_number pattern, and then two unevaluatedProperties errors that are just echoes. When you show errors to people, ignore the unevaluated ones if another error is present.

A Realistic Schema: Payments

This schema combines most of the chapter: a required core, a method-dependent part via if/then (with required inside each if), dependentRequired, and unevaluatedProperties: false so unknown fields and fields for the wrong method are rejected:

{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["method", "amount"], "properties": { "method": { "enum": ["card", "bank", "cash"] }, "amount": { "type": "integer", "minimum": 1 }, "currency": { "enum": ["GBP", "EUR"] } }, "allOf": [ { "if": { "properties": { "method": { "const": "card" } }, "required": ["method"] }, "then": { "required": ["card_number", "expiry"], "properties": { "card_number": { "type": "string", "pattern": "^[0-9]{16}$" }, "expiry": { "type": "string", "pattern": "^(0[1-9]|1[0-2])/[0-9]{2}$" } } } }, { "if": { "properties": { "method": { "const": "bank" } }, "required": ["method"] }, "then": { "required": ["iban"], "properties": { "iban": { "type": "string", "pattern": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$" } } } } ], "dependentRequired": { "expiry": ["card_number"], "currency": ["amount"] }, "unevaluatedProperties": false }

I wrote an expected verdict for each of 18 test documents (4 valid, 14 invalid) and ran them in both validators: 18 of 18 behaved as expected in each, and the two agreed on every document. Examples: a complete card payment was valid; a cash payment with a stray iban was invalid (unevaluatedProperties); a card with expiry month 13 failed the expiry pattern; an amount of 9.99 failed type (money as integer cents again); currency without amount failed both required and dependentRequired. As a control, I flipped one verdict on purpose and the comparison named exactly that case. The IBAN pattern is only a shape check (two letters, two digits, then 11 to 30 letters or digits): it does not verify the IBAN checksum, which a schema cannot do.

Error Messages People Can Use

The more flexible the schema, the noisier the errors. I took an event document that should have been a click but has x: "ten" (a string where an integer is needed), and validated it against three ways of writing "one of these three event shapes":

Schema styleErrors for {"type":"click","x":"ten","y":5}
oneOf of three event schemas6 errors: the real one (/x type: must be integer), then complaints from the other two branches (required 'key', const "key", required 'dy', const "scroll") and a final oneOf summary
a chain of if/then keyed on type (inside allOf)2 errors: /x type: must be integer and if: must match "then" schema; an unknown type "hover" gave one clear /type enum error
Ajv with discriminator: true and a discriminator: {propertyName: "type"} keyword1 error: /x type: must be integer; "hover" gave value of tag "type" must be in oneOf; a missing type gave required and tag "type" must be string

So for documents with a "kind" field, use if/then keyed on that field, or Ajv's discriminator option, instead of a bare oneOf: the validator then looks only at the branch that applies. The discriminator keyword is an Ajv option, not part of the JSON Schema specification, so another validator would ignore it and fall back to ordinary oneOf behaviour. In Python, the same oneOf produced a single top-level error, ... is not valid under any of the given schemas, with the per-branch errors tucked into its context (five of them); best_match returned that top-level error, not the useful one, while the if/then chain gave just ['x'] type: 'ten' is not of type 'integer'.

Two ways to make messages friendlier. Custom messages in the schema, with the ajv-errors plugin: an errorMessage keyword turned the results for {}, {qty: 1.5} and {qty: 0} into quantity is required, quantity must be a whole number and quantity must be at least 1. And a small formatter in your code; mine for Python turned the order errors into plain lines:

def lines(validator, doc): out = [] for e in sorted(validator.iter_errors(doc), key=lambda e: [str(p) for p in e.absolute_path]): where = '/'.join(str(p) for p in e.absolute_path) or '(whole document)' out.append(where + ': ' + e.message) return out
customer: '' should be non-empty id: 0 is less than the minimum of 1 items/0/price: 0 is less than or equal to the minimum of 0 items/0/qty: 0 is less than the minimum of 1 items/0/sku: 'a1' does not match '^[A-Z][0-9]$' status: 'lost' is not one of ['pending', 'shipped', 'cancelled']

Group errors by path, show the path in the user's vocabulary (a form field name, not items/0/sku), and never match on message text: it differs between libraries and versions.

Schemas in the Editor

VS Code can check a JSON file against a schema as you type. You map files to a schema in settings.json with json.schemas; the VS Code documentation shows fileMatch (a list of patterns, * is supported, a leading ! excludes), a url (use a relative path such as ./order.schema.json for a workspace schema), or an inline schema instead of a url:

{ "json.schemas": [ { "fileMatch": ["/orders/*.json"], "url": "./schemas/order.schema.json" } ] }

A file can also name its own schema with a "$schema" property (the docs call this VS Code-specific and warn it changes the file's contents, so other tools may fail validation). The documentation also says VS Code supports drafts 4 to 7 fully and has limited support for 2019-09 and 2020-12.

I did not drive the VS Code window. Instead I ran the JSON language service package that VS Code's JSON support is built on (vscode-json-languageservice 6.0.0-next.4; the JSON language server inside the VS Code 1.138.0 installed here depends on ^6.0.0-next.4) and asked it for diagnostics. That shows what the editor's engine reports, not how the squiggles look. Results with my order schema:

DocumentDiagnostics
a valid ordernone
id 0, status "lost", sku "a1", qty 0, price 0, extra notesix warnings with line numbers, for example Value is below the minimum of 1., Value is not accepted. Valid values: "pending", "shipped", "cancelled". and Property note is not allowed.
{"id": 1}Missing property "customer", and the same for status, date and items
date "2026-13-45"none: the format: date rule was not reported
a trailing comma; a commentseverity 1 (an error): Trailing comma; Comments are not permitted in JSON.
2020-12 keywords: prefixItems with items: false, dependentRequired, unevaluatedProperties: falseall three were reported: Array has too many items according to schema. Expected 2 or fewer., Object is missing property b required by property a., Property zzz is not allowed.
a $schema inside the document, for a schema that has additionalProperties: falsethe good document got Property $schema is not allowed.; the bad one also got its real error; a $schema pointing at a missing file gave Unable to load schema from ...

Three practical points. Schema problems showed as warnings (severity 2) and syntax problems as errors (severity 1). The engine understood the 2020-12 keywords I tried, even though the documentation says "limited", so test your own schema rather than assuming either way. It did not check format for the one format I tried. And a schema that closes the object with additionalProperties: false will reject the very $schema line you add to link it, so for closed schemas map files with json.schemas instead.

Generating Types and Documentation

Types. json-schema-to-typescript 16.0.0 turned the order schema into:

export interface Order { id: number; customer: string; status: "pending" | "shipped" | "cancelled"; date: string; items: Item[]; } export interface Item { sku: string; qty: number; price: number; }

The $defs item became its own interface, the enum a union of strings, and the optional or open shapes differed: for the shop schema (no additionalProperties: false) each object also got [k: string]: unknown;, phone became phone?: string | null, and minItems: 1 became the tuple [Product, ...Product[]]. I then wrote a TypeScript file with eight objects and compiled it with TypeScript 7.0.2 (tsc --noEmit, strict). The compiler caught five mistakes: status: 'lost', a missing items, an extra note field on the closed order, id: '1' (a string), and an empty products list. It did not catch an order with id: 0, an empty customer, a nonsense date, sku: 'a1', qty: -3 and price: 0, because those rules (minimum, pattern, format) cannot be expressed as TypeScript types; nor an extra field on the open shop schema. Generated types describe the shape; the validator still has to run on real data.

Documentation. json-schema-for-humans 1.5.1 (generate-schema-doc --config template_name=md order.schema.json order.md) wrote a 5.4 KB Markdown page with a table of contents, one section per property and its restrictions (for id: type integer, required, minimum ≥ 1), and "Additional properties: Not allowed" for the closed object. It also marked the whole document's Required as "No" (that row is about whether the root itself is required in a larger document), which is not what a reader might expect. Other generators exist; I tested only these two.

Changing a Schema Without Breaking People

Once other programs rely on a schema, every change is a compatibility question, and there are two directions: is the new schema backward compatible (does it still accept every document that was valid under the old one, so existing data keeps working?) and is it forward compatible (does the old schema accept documents produced under the new one, so programs that have not upgraded keep working?). I tested eleven kinds of change with sample documents in both directions:

ChangeOld documents still valid under the new schema?New documents accepted by the old schema?
add an optional property (open schema)2/2 yes2/2 yes
add an optional property (schema with additionalProperties: false)2/2 yes1/2 no: old readers reject documents using it
add a required property0/2 no1/1 yes
make a required property optional2/2 yes1/2 no
add an enum value2/2 yes1/2 no (old readers reject the new value)
remove an enum value1/2 no1/1 yes
widen a range (minimum 1 → 0)2/2 yes1/2 no
narrow a range (minimum 1 → 5)1/3 no1/1 yes
widen a type (integer → number)2/2 yes1/2 no
narrow a type (number → integer)1/2 no2/2 yes
rename a property0/2 no0/1 no

The pattern: loosening a schema (optional property, extra enum value, wider range or type) keeps old data valid but can make old readers reject new data; tightening it (required property, fewer enum values, narrower range) does the opposite. A rename is both a removal and an addition, so it breaks both directions. Keep the safe changes (adding optional properties to an open schema) for minor versions, put breaking ones behind a new version of the schema (a new $id, for example .../order/v2.json), and keep a corpus of example documents that every version must still accept. This table used a handful of sample documents per change, so it demonstrates the pattern; it is not a proof for any particular schema.

Mistakes while preparing this chapter
1. I expected {} to be valid against my if/then address schema. The if passed because it had no required, so then ran and complained about a missing postcode. How to avoid it: put required inside the if, and test the empty document and a document missing the discriminator field.

2. My "fresh copy of the schema" benchmark crashed after one document. Ajv refuses to register the same $id twice. That was a result, not a failure of the test, and it is now a row in the table. How to avoid it: read a crashing benchmark's message before "fixing" the benchmark; it may be the finding.

3. I first installed the default vscode-json-languageservice (6.0.0-next.3) while VS Code 1.138 depends on ^6.0.0-next.4. How to avoid it: read the dependency from the thing you are imitating, and install that version (I reinstalled and re-ran).

4. Chapter 6 implied that a misspelt type is only caught by check_schema. In fact Draft202012Validator(schema).is_valid raised UnknownType at first use (an unknown keyword is still silent). I added that to Chapter 6's table. How to avoid it: when a later experiment contradicts an earlier statement, go back and correct the earlier chapter.

Hands-On Exercises

Exercise 1

Measure the cost of how you call a validator. In Node with Ajv: compile once and validate 20,000 documents, then ajv.validate(schemaObject, doc) with a cached object, then a new Ajv and compile per document (200 documents), then ajv.validate with a fresh copy of a schema that has an $id. In Python: build one validator and call is_valid 5,000 times, then jsonschema.validate, then a new validator per call, then check_schema alone. Check that every method gives the same number of valid documents, and show validate() rejecting a broken schema while is_valid on a broken type name raises at first use.

๐Ÿ“„ View solution
Exercise 2

Write the payment schema (card, bank or cash with method-specific required fields and patterns, dependentRequired, unevaluatedProperties: false). Write 18 test documents with an expected verdict each, run them in Ajv and in Python, report how many behave as expected in each, list any disagreement between the two, and prove the comparison can fail. Then demonstrate allOf, anyOf, oneOf (including the overlapping integer/number case) and not, the if trap with {}, and additionalProperties: false next to allOf against unevaluatedProperties: false.

๐Ÿ“„ View solution
Exercise 3

Using the order schema: compare the error output of three styles of "one of these event shapes" (oneOf, an if/then chain, Ajv's discriminator) on one wrong document, and show ajv-errors custom messages. Run the JSON language service on a valid order, an invalid one, a trailing comma and a 2020-12 schema, and note what it does not flag. Generate TypeScript types, compile eight deliberately varied objects with tsc --noEmit and list which mistakes the compiler catches and which it cannot. Generate Markdown documentation. Finally build a table of eleven schema changes and test each in both directions (old documents against the new schema, new documents against the old).

๐Ÿ“„ View solution

Chapter 7 Quick Reference

  • Compile once: Ajv compile 39.7 ms once, then 0.40 microseconds per document; a new Ajv per document was 9.7 ms each; the same $id twice throws. Python: build one validator, check_schema once, use is_valid/iter_errors; jsonschema.validate() was 2.42 ms per call
  • allOf all, anyOf at least one, oneOf exactly one (overlap fails: 1 against [integer, number]), not; for "string or null" use "type": ["string","null"]
  • if/then/else: put required inside the if or an empty object takes the then branch; dependentRequired, dependentSchemas
  • additionalProperties: false cannot see inside allOf and rejected even {"id":1}; use unevaluatedProperties: false (and unevaluatedItems); a failing branch also produces echo unevaluatedProperties errors
  • Error quality: bare oneOf gave 6 errors for one real mistake, an if/then chain 2, Ajv's discriminator option 1; ajv-errors adds errorMessage; format errors yourself and match on keyword and path, never message text
  • VS Code: json.schemas with fileMatch and url/schema, or "$schema" in the file (VS Code-specific; breaks closed schemas); docs say drafts 4 to 7 fully, 2019-09/2020-12 limited; the language service I ran flagged the 2020-12 keywords I tried but not format
  • Generated types (json-schema-to-typescript 16.0.0, checked with tsc 7.0.2) catch enum, missing, extra and wrong-type mistakes but not minimum, pattern or format; json-schema-for-humans 1.5.1 writes Markdown docs
  • Compatibility: loosening (optional property, more enum values, wider range or type) keeps old data valid but old readers may reject new data; tightening (required property, fewer enum values, narrower range) breaks old data; a rename breaks both; version breaking changes under a new $id; keep a corpus of documents every version must accept
Coming next
Chapter 8 is addressing and changing JSON: JSON Pointer (RFC 6901, the same paths the validators printed), JSONPath (RFC 9535), JSON Patch (RFC 6902) and JSON Merge Patch (RFC 7396), applied with real libraries in Node and Python and with jq, and how to choose between them. Start with Working with JSON 8.