Schema Basics

Working with JSON

Chapter 6 ยท JSON Schema: Describing Data

Until now JSON has been "whatever the sender produced". That works until a field is missing, a number arrives as a string, or a price is zero, and the failure shows up three steps later in code that trusted the data. JSON Schema is a standard way to write down what a valid document looks like, as JSON, so a program can check it. A schema is a contract: it can reject bad input at the door, document an API for humans, and feed editors and code generators. This chapter covers the vocabulary of the current version, the defaults that surprise people, how to write a schema for a real API response, and how to read a validator's error output. The next chapter covers validators in more depth (combining schemas, conditionals, editor integration).

Every example here was run, except where marked
Two validators, both installed in a scratch folder: Ajv 8.20.0 in Node 24.18.0 (class ajv/dist/2020, with ajv-formats 3.0.1) and jsonschema 4.26.0 in Python 3.14.0 (Draft202012Validator). Where a result is the same in both I say so; where it differs I show both. Not run: any other validator (Go, Java, .NET, Rust), the draft-07 and 2019-09 behaviour of these libraries, the unreleased "v1" specification, and keywords not listed here (if/then/else, allOf, dependentRequired and unevaluatedProperties are Chapter 7). The version history comes from json-schema.org.

What a Schema Is For

  • Validation: "is this document acceptable?" with a list of exactly what is wrong. This is the main use, in API servers, config loaders and data pipelines.
  • Documentation: a schema says which fields exist, their types, their limits and which are required, in a form tools can read.
  • Tooling: editors can offer completion and red squiggles from a schema, and some tools generate types or forms from one (Chapter 7).

What a schema does not do: it does not change the data (it will not turn "1" into 1), it does not check things that need other data (is this customer id in the database?), and it does not make a bad design good. It answers one question, "is this JSON the right shape?".

Your First Schema

A schema is itself a JSON document, usually an object, made of keywords. Each keyword adds one rule, and a document is valid only if it passes all the rules. Here is a schema for one product from shop.json:

{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "sku": { "type": "string", "pattern": "^[A-Z][0-9]$" }, "name": { "type": "string", "minLength": 1 }, "price": { "type": "number", "exclusiveMinimum": 0 }, "stock": { "type": "integer", "minimum": 0 }, "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } }, "required": ["sku", "name", "price", "stock"] }

Read it as a list of conditions. It must be an object. If it has sku, that must be a string like A1; if it has stock, an integer of zero or more; and four properties must be present. The $schema line says which version of the standard the schema is written for (see "Versions" below).

The Core Keywords

I tested every keyword below in both validators with documents that should pass and fail; where a cell shows a result, both gave it unless noted.

KeywordMeaningTested behaviour
type"string", "number", "integer", "boolean", "null", "array", "object", or a list of them["string","null"] accepted "x" and null, rejected 1; "integer" accepted the JSON text 1.0 and 1e2 but rejected 1.5 and true
enumthe value must equal one of a list["a", 1, null, {"x":1}] accepted each of those exactly; "1" and {"x":2} failed; enum: [1] accepted 1 and 1.0 but rejected true
constthe value must equal this one value{"a":[1,2]} passed and {"a":[2,1]} failed (array order matters)
propertiesa schema for each named key, if present{} passed a schema with only properties: nothing is required by it
requiredthese keys must be presentpassed for an object that had the key, failed for {}
additionalPropertiesa schema (or false) for keys not listed in propertiesfalse rejected {"a":1,"b":2}; a schema {"type":"integer"} accepted b: 1 and rejected b: "y"
patternProperties, propertyNames, minPropertiesrules by key pattern; a schema for the key names; a minimum key count{"^x-": {"type":"string"}} checked only x- keys; propertyNames: {pattern: "^[a-z]+$"} rejected the key A-b; minProperties: 1 rejected {}
items, prefixItemsitems: a schema for every array element (after any prefixItems); prefixItems: one schema per leading positionitems: {type: integer} rejected [1,"x"]; prefixItems: [string, integer] with items: false accepted ["a",1] and ["a"] but rejected ["a",1,2] and [1,"a"]
minItems, maxItems, uniqueItemsarray length limits; no duplicatesuniqueItems rejected [1,2,1], [{"a":1},{"a":1}] and [[1],[1]], and accepted [1,"1"]; in Python also rejected [1, 1.0] and accepted [1, true]
contains, minContainsat least one (or n) element must matchcontains: {type: number, minimum: 5} accepted [1,9] and rejected [1,2] and []; minContains: 2 with const: 1 accepted [1,1]
minLength, maxLength, patternstring length (in characters); a regular expressionmaxLength: 1 accepted a single emoji in both (counted as one character); pattern: "[0-9]" accepted "a1" (see the pitfalls below)
minimum, maximum, exclusiveMinimum, exclusiveMaximumnumber limits, inclusive or exclusiveexclusiveMinimum: 0 rejected 0 and -1, accepted 0.0001
multipleOfthe number must be a whole multiplemultipleOf: 1 accepted 3 and rejected 3.5; with 0.01 see the pitfalls

Two more building blocks need no keyword at all: an empty schema {} accepts everything (I fed it a number, a string, null, an array and an object), and a schema can be just true (accept everything) or false (reject everything). false is useful as items: false or additionalProperties: false.

Defaults That Surprise People

  • Nothing is required unless you say so. properties only describes keys that are there; an object with none of them still passes. Add required.
  • Extra properties are allowed. In my shop test, adding an unknown top-level field to a valid document left it valid. Add additionalProperties: false only when you really want a closed shape, and read the Chapter 7 warning before combining it with allOf.
  • Keywords are ignored for the wrong type. {"required": ["a"]} alone rejected {} but accepted [] and "x", because required only applies to objects. State the type too: {"type":"object","required":["a"]} rejected all three.
  • Integers are about value, not spelling. The JSON text 1.0 and 1e2 are valid integers; true is not an integer, in either validator. (In Node, JSON.parse("1.0") is already the number 1, so the JavaScript side cannot even see the difference.)
  • pattern is not anchored. "[0-9]" accepted "a1". Write ^...$ when the whole string must match. In JSON text a backslash in a pattern is doubled ("^[0-9]+\\.[0-9]{2}$" is the regular expression ^[0-9]+\.[0-9]{2}$).
  • Unknown keywords are ignored, silently, by default. Python's jsonschema returned valid for {"type":"object","foo":1}, so a typo such as "requird" is not an error. See "Strictness" below.

format: Often Not Checked

format names a common pattern (date, date-time, email, uuid, uri and others). In the 2020-12 specification it is an annotation by default: a validator is allowed to ignore it, and the validators I ran did. The same string "2026-13-45" against {"type":"string","format":"date"}:

Setup"2026-13-45""2026-10-12"
Ajv with validateFormats: falsevalid-
Ajv with ajv-formats addedinvalid (must match format "date")valid
Ajv with default options and no pluginthrows at compile time: unknown format "date" ignored in schema at path "#"
Python jsonschema, no format_checkervalid-
Python with format_checker=FormatChecker()invalid ('2026-13-45' is not a 'date')valid

So the same schema can accept a nonsense date in one program and reject it in another. If you rely on format, turn format checking on explicitly (Ajv: add ajv-formats; Python: pass a FormatChecker), and for anything critical add a pattern as well. I only tested date; which other formats a checker supports depends on the library and on extra packages. The January 2025 post on json-schema.org says the planned next specification will make format validate by default (see "Versions").

$schema, $id, $defs and $ref

Repeating the same sub-shape (an order's items) in several places invites mistakes. Put it under $defs once and point to it with $ref. The pieces:

  • $schema: which version of the rules this document uses. Include it. Python's validators.validator_for(schema) picked Draft202012Validator from it. In Ajv, the class you choose must match: the default new Ajv() failed on a 2020-12 schema with no schema with key or ref "https://json-schema.org/draft/2020-12/schema"; use require('ajv/dist/2020').
  • $id: the schema's own URI (it does not need to exist online). Other schemas refer to it. In Ajv, a schema added with addSchema under its $id could be referenced from another schema, and an unknown id gave can't resolve reference https://example.com/nope.json from id #.
  • $defs: a place for named sub-schemas. {"$ref": "#/$defs/item"} means "the schema at that path in this document". A missing target gave can't resolve reference #/$defs/missing from id # at compile time.
  • $anchor: a plain-name target: a definition with "$anchor": "num" was reachable as "$ref": "#num" (tested in Ajv).
  • Recursion is allowed: a node definition whose children are $ref: "#/$defs/node" validated a three-level tree and rejected a leaf missing name (tested in Ajv).
  • Siblings of $ref are allowed since 2019-09: {"$ref": "#/$defs/n", "minimum": 10} where n is an integer rejected 5 (the sibling applied) and "x", and accepted 15 (the same in both validators). Older drafts ignored siblings of $ref.

A Schema for a Real API Response

Here is a schema for the order documents from Chapter 5, using $defs for the item shape and enums for the status. It is the schema used for every test in this chapter:

{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.com/schemas/order.json", "title": "Order", "type": "object", "properties": { "id": { "type": "integer", "minimum": 1 }, "customer": { "type": "string", "minLength": 1 }, "status": { "enum": ["pending", "shipped", "cancelled"] }, "date": { "type": "string", "format": "date" }, "items": { "type": "array", "items": { "$ref": "#/$defs/item" } } }, "required": ["id", "customer", "status", "date", "items"], "additionalProperties": false, "$defs": { "item": { "type": "object", "properties": { "sku": { "type": "string", "pattern": "^[A-Z][0-9]$" }, "qty": { "type": "integer", "minimum": 1 }, "price": { "type": "number", "exclusiveMinimum": 0 } }, "required": ["sku", "qty", "price"], "additionalProperties": false } } }

All six real orders from Chapter 5 validated in both validators, including order 4, which has an empty items list (the schema has no minItems, so empty is allowed; that is a design choice you make deliberately). Then I ran 14 test documents through both validators and compared their answers. The results, as Ajv reported them (keyword at path):

Test documentResult
a valid ordervalid
no items/ required
id is the string "1"/id type
id is 0/id minimum
status "lost"/status enum
an extra field note/ additionalProperties
date "2026-13-45"/date format
sku "a1"/items/0/sku pattern
qty 0 and price 0/items/0/price exclusiveMinimum; /items/0/qty minimum
an item with no price/items/0 required
empty customer/customer minLength
an array instead of an object; null/ type (both)
everything wrong at onceseven errors, one per mistake

The two validators agreed on the verdict and on the keyword and path of every error in all 14 cases; the only differences are wording (below). As a check on the comparison, I deleted one Python result on purpose and the comparison reported exactly that case.

Reading Validator Output

A validator gives you either a yes/no or a list of error records. Each record answers four questions: where in the document, which rule, why, and where in the schema. For the "everything wrong" document:

Ajv, allErrors: false -> 1 error(s): additionalProperties must NOT have additional properties Ajv, allErrors: true -> 7 error(s): additionalProperties must NOT have additional properties /id minimum must be >= 1 /customer minLength must NOT have fewer than 1 characters /status enum must be equal to one of the allowed values /items/0/sku pattern must match pattern "^[A-Z][0-9]$" /items/0/qty minimum must be >= 1 /items/0/price exclusiveMinimum must be > 0 Python iter_errors() -> 7 errors, e.g.: path ['id'] validator minimum schema_path ['properties','id','minimum'] 0 is less than the minimum of 1 path ['items',0,'sku'] validator pattern schema_path ['properties','items','items','properties','sku','pattern'] 'a1' does not match '^[A-Z][0-9]$' path [] validator additionalProperties Additional properties are not allowed ('note' was unexpected)
  • Ajv stops at the first error by default. With allErrors: false it reported one error; with allErrors: true it found all seven. Use allErrors: true to show a user everything at once (I did not measure how much slower it is, and I did not check Ajv's own advice about using it on untrusted input).
  • Each Ajv error is an object with instancePath ("/items/0/sku", a JSON Pointer: Chapter 8), schemaPath ("#/$defs/item/properties/sku/pattern" style), keyword, params (for example {"additionalProperty":"note"}) and message. The root of the document is the empty string "".
  • Python: iter_errors(doc) yields every error; validate(doc) raises the first one (for this document "0 is less than the minimum of 1"), and jsonschema.exceptions.best_match(errors) picked additionalProperties. Different tools can pick a different "first" error, so do not build logic on which one comes first. Each error has .path (a list such as ['items', 0, 'sku']), .validator (the keyword), .message and .schema_path.
  • Messages differ (Ajv: must be >= 1; Python: 0 is less than the minimum of 1), so match on keyword and path, never on message text.

Strictness: Typos in Schemas

A schema with a mistake in it can silently validate nothing. The two libraries guard against that differently:

Schema problemAjv (default options)Python jsonschema
unknown keyword "foo": 1throws strict mode: unknown keyword: "foo"silently ignored (is_valid returned True)
properties without type: objectcompiled, with a warning: strict mode: missing type "object" for keyword "properties" (strictTypes)no warning
required names a property not in propertiescompiled (strictRequired is off by default; with strict: true it threw)no warning
{"type": "strng"}, {"minimum": "x"}, {"required": "a"}rejected when compiledDraft202012Validator.check_schema(schema) rejected each ('strng' is not valid under any of the given schemas, 'x' is not of type 'number', 'a' is not of type 'array'); you must call it yourself. (A misspelt type name such as strng also raised UnknownType the first time the validator was used; an unknown keyword never raises.)

The habit: always call check_schema in Python (or compile in Ajv) when a schema loads, and keep a test with one valid and one invalid document for every schema, so a typo shows up as a test that no longer fails.

Two Number Pitfalls

multipleOf with a decimal fails on valid prices. I checked multipleOf: 0.01 in both validators:

multipleOf 0.01 19.99=NO 4.25=ok 0.07=NO 1.005=NO 0.1=ok (same result in Ajv and in Python) integer cents 1999=ok 425=ok 7=ok 100.5=NO price as text "19.99"=ok "4.250"=NO "5"=NO "1x99"=NO (pattern ^[0-9]+\.[0-9]{2}$)

19.99 and 0.07, perfectly good prices, were rejected: they are not exact multiples of 0.01 in binary floating point (this is the same cause as 0.1 + 0.2 in Chapter 3). So avoid multipleOf with a fraction. Send money as integer cents ("type": "integer", which is exact) or as a string with a pattern.

Large and precise numbers. A schema validates what the parser produced, so the JavaScript problem from Chapter 1 applies: a 20-digit id arrives in Node as an already-rounded number, and the schema sees the rounded one. A schema cannot detect digits that the parser lost before validation.

Versions: Which Draft?

JSON Schema is published as dated drafts. From json-schema.org's specification links page: Draft 4 (31 January 2013), Draft 6 (21 April 2017), Draft 7 (19 March 2018), 2019-09 (17 September 2019) and 2020-12, published 16 June 2022, which the site calls "the current version" and which is the newest published one. The meta-schema URI is https://json-schema.org/draft/2020-12/schema (I fetched it; the server returned it as application/schema+json).

The "v1" release. The project has been working on a new, more stable release with strict compatibility rules. A January 2025 post on json-schema.org describes the upcoming release as breaking some things in 2020-12 ("format will validate by default", extra data needs an x- prefix, $dynamicRef is separated from $ref), says it was "really close", and gives no date. As of 2026-10-12 I could not find a published v1: the specification page and the specification-links page list 2020-12 as newest, and a "v1/2026" address mentioned in the project's process document returned a 404 when I requested it. So write for 2020-12, say so in $schema, and re-check the site before assuming anything else. Differences you may meet between drafts in older material: items as an array (replaced by prefixItems in 2020-12), definitions instead of $defs, dependencies split into dependentRequired and dependentSchemas, and siblings of $ref ignored before 2019-09. I did not test any draft older than 2020-12.

Mistakes while preparing this chapter
1. My first keyword tests were mostly "COMPILE ERROR". I had set Ajv's strict option to true, which turns every strictness check (including "missing type") into an error, whereas the default is much milder. How to avoid it: use the library's default options for the first experiment, then tighten them; and say which options you used.

2. I "tested" the integer 1.0 in JavaScript. JSON.parse("1.0") is just 1, so the test could not show anything. How to avoid it: check that the value you test actually reaches the validator as what you think; I repeated the test with the JSON text in both languages.

3. My price pattern accepted any character where the dot should be. I typed "^[0-9]+\.[0-9]{2}$" in JavaScript source, where \. is just ., so the regular expression's dot matched anything. How to avoid it: double the backslash in source code, print the pattern, and include a control that must fail ("1x99", which now does).

4. My first "allErrors" demonstration only ever printed the false case. A stray break ended the loop. How to avoid it: when two settings are being compared, check that the output shows both before reading the result.

Hands-On Exercises

Exercise 1

Write a 2020-12 schema for shop.json (shop name, open flag, an owner whose phone may be a string or null, and a non-empty list of unique products with a SKU pattern, a positive price, an integer stock and optional unique tags, using $defs and $ref). Check that the real file is valid, then write fifteen mutated copies, each with the keyword and path you expect to complain (three of them controls that must stay valid, such as an extra field), and report how many behaved as predicted.

๐Ÿ“„ View solution
Exercise 2

In both Ajv and Python's jsonschema, demonstrate the schema traps: the JSON text 1.0 as an integer, format with and without a format plugin or checker, required without type: object, an unanchored pattern, an emoji against maxLength: 1, multipleOf: 0.01 on prices and two exact alternatives (integer cents, a string with a pattern, with a control that must fail), an unknown keyword under each library's strictness, and check_schema on three broken schemas.

๐Ÿ“„ View solution
Exercise 3

Write the order schema from this chapter. Run fourteen documents (one valid and thirteen invalid in different ways) through both validators, each writing its results (keyword and path of every error, sorted) to a file, and write a third script that compares the two files and lists any case where they disagree. Add a control that deliberately damages one result and shows the comparison catches it. Then show Ajv's default class failing on a 2020-12 schema, a missing $ref and an unknown $id, and Ajv with allErrors off and on.

๐Ÿ“„ View solution

Chapter 6 Quick Reference

  • A schema is a JSON document of keywords; a document is valid if it passes all. It checks shape, never changes data. Write "$schema": "https://json-schema.org/draft/2020-12/schema" (2020-12, published 16 June 2022, is the newest published version; I found no published "v1" on 2026-10-12)
  • Types: type (or a list such as ["string","null"]), enum, const; objects: properties, required, additionalProperties, patternProperties, propertyNames, minProperties; arrays: items, prefixItems, minItems, maxItems, uniqueItems, contains; strings: minLength, maxLength, pattern; numbers: minimum, exclusiveMinimum, maximum, multipleOf
  • Defaults: nothing required, extra properties allowed, keywords ignored for the wrong type (required alone accepts []), {} accepts everything, 1.0 and 1e2 are integers, true is not, pattern is unanchored
  • format was ignored without Ajv's ajv-formats or Python's FormatChecker(); with them "2026-13-45" failed date; Ajv throws on an unknown format when no plugin is present
  • Reuse: $defs + {"$ref": "#/$defs/name"}, $id + addSchema, $anchor, recursion; siblings of $ref apply (2019-09+); Ajv needs ajv/dist/2020 for 2020-12
  • Output: Ajv errors have instancePath, schemaPath, keyword, params, message and it stops at the first error unless allErrors: true; Python iter_errors gives all, with .path, .validator, .message; match on keyword and path, not message text
  • Strictness: Ajv throws on unknown keywords by default; Python ignores them silently, so call check_schema; keep one passing and one failing test document per schema
  • Avoid multipleOf with a fraction (19.99 failed multipleOf: 0.01 in both libraries); use integer cents or a string with a pattern
Coming next
Chapter 7 is validating in practice: Ajv in Node and jsonschema in Python in more depth (compile once, formats, good error messages), combining schemas with allOf, anyOf, oneOf and not, if/then/else, dependentRequired, the unevaluatedProperties fix for the additionalProperties problem, schemas in the editor, generating types or docs, and compatible schema changes. Start with Working with JSON 7.