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).
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:
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.
| Keyword | Meaning | Tested 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 |
enum | the 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 |
const | the value must equal this one value | {"a":[1,2]} passed and {"a":[2,1]} failed (array order matters) |
properties | a schema for each named key, if present | {} passed a schema with only properties: nothing is required by it |
required | these keys must be present | passed for an object that had the key, failed for {} |
additionalProperties | a schema (or false) for keys not listed in properties | false rejected {"a":1,"b":2}; a schema {"type":"integer"} accepted b: 1 and rejected b: "y" |
patternProperties, propertyNames, minProperties | rules 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, prefixItems | items: a schema for every array element (after any prefixItems); prefixItems: one schema per leading position | items: {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, uniqueItems | array length limits; no duplicates | uniqueItems 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, minContains | at least one (or n) element must match | contains: {type: number, minimum: 5} accepted [1,9] and rejected [1,2] and []; minContains: 2 with const: 1 accepted [1,1] |
minLength, maxLength, pattern | string length (in characters); a regular expression | maxLength: 1 accepted a single emoji in both (counted as one character); pattern: "[0-9]" accepted "a1" (see the pitfalls below) |
minimum, maximum, exclusiveMinimum, exclusiveMaximum | number limits, inclusive or exclusive | exclusiveMinimum: 0 rejected 0 and -1, accepted 0.0001 |
multipleOf | the number must be a whole multiple | multipleOf: 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.
propertiesonly describes keys that are there; an object with none of them still passes. Addrequired. - Extra properties are allowed. In my shop test, adding an unknown top-level field to a valid document left it valid. Add
additionalProperties: falseonly when you really want a closed shape, and read the Chapter 7 warning before combining it withallOf. - Keywords are ignored for the wrong type.
{"required": ["a"]}alone rejected{}but accepted[]and"x", becauserequiredonly applies to objects. State thetypetoo:{"type":"object","required":["a"]}rejected all three. - Integers are about value, not spelling. The JSON text
1.0and1e2are valid integers;trueis 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.) patternis 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
jsonschemareturned 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: false | valid | - |
Ajv with ajv-formats added | invalid (must match format "date") | valid |
| Ajv with default options and no plugin | throws at compile time: unknown format "date" ignored in schema at path "#" | |
Python jsonschema, no format_checker | valid | - |
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'svalidators.validator_for(schema)pickedDraft202012Validatorfrom it. In Ajv, the class you choose must match: the defaultnew Ajv()failed on a 2020-12 schema withno schema with key or ref "https://json-schema.org/draft/2020-12/schema"; userequire('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 withaddSchemaunder its$idcould be referenced from another schema, and an unknown id gavecan'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 gavecan'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
nodedefinition whosechildrenare$ref: "#/$defs/node"validated a three-level tree and rejected a leaf missingname(tested in Ajv). - Siblings of
$refare allowed since 2019-09:{"$ref": "#/$defs/n", "minimum": 10}wherenis an integer rejected5(the sibling applied) and"x", and accepted15(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:
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 document | Result |
|---|---|
| a valid order | valid |
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 once | seven 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 stops at the first error by default. With
allErrors: falseit reported one error; withallErrors: trueit found all seven. UseallErrors: trueto 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"}) andmessage. 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"), andjsonschema.exceptions.best_match(errors)pickedadditionalProperties. 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),.messageand.schema_path. - Messages differ (Ajv:
must be >= 1; Python:0 is less than the minimum of 1), so match onkeywordand 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 problem | Ajv (default options) | Python jsonschema |
|---|---|---|
unknown keyword "foo": 1 | throws strict mode: unknown keyword: "foo" | silently ignored (is_valid returned True) |
properties without type: object | compiled, with a warning: strict mode: missing type "object" for keyword "properties" (strictTypes) | no warning |
required names a property not in properties | compiled (strictRequired is off by default; with strict: true it threw) | no warning |
{"type": "strng"}, {"minimum": "x"}, {"required": "a"} | rejected when compiled | Draft202012Validator.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:
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.
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
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.
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.
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.
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 (
requiredalone accepts[]),{}accepts everything,1.0and1e2are integers,trueis not,patternis unanchored formatwas ignored without Ajv'sajv-formatsor Python'sFormatChecker(); with them"2026-13-45"faileddate; Ajv throws on an unknown format when no plugin is present- Reuse:
$defs+{"$ref": "#/$defs/name"},$id+addSchema,$anchor, recursion; siblings of$refapply (2019-09+); Ajv needsajv/dist/2020for 2020-12 - Output: Ajv errors have
instancePath,schemaPath,keyword,params,messageand it stops at the first error unlessallErrors: true; Pythoniter_errorsgives 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
multipleOfwith a fraction (19.99failedmultipleOf: 0.01in both libraries); use integer cents or a string with apattern
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.