JSON Schema is a language for writing rules about JSON data. A schema is itself a JSON document, and it says which members an object must have, what type each value is and which values are allowed. A validator library reads the schema and the data, and reports either “valid” or a list of errors with the path of each failing value. With it, an API can reject a bad request body before any business code runs, and the same schema file also serves as documentation. The current version is 2020-12, published on json-schema.org, and OpenAPI 3.1 builds on that version.
Here we write one schema for a person record and validate data against it with Ajv in Node.js and with the jsonschema package in Python. We also look at the keywords used most often, reuse with $ref, how an API should report schema errors, and what changed between the older drafts that many tutorials still use.
1. Well-Formed JSON Can Still Be Wrong Data
A JSON parser only checks syntax. It confirms that quotes, commas and brackets are in the right places, and nothing more. That check is called syntactic validation, and tools such as JSONLint and JSON.parse() stop there. The document below passes that check without complaint:
{ "name": "", "age": "35", "admin": true }
Yet a program expecting a person record has three problems here. The name is empty, the age is a string instead of a number, and admin is a field the client should never send. Catching these is semantic validation: checking that the data makes sense for our application. JSON Schema describes those rules in a form that both people and programs can read. It can check required members, data types, number ranges, string lengths and patterns, fixed lists of values, and formats such as email addresses and dates.
2. A Person Schema, Keyword by Keyword
Our example schema describes a person with a name, an age between 18 and 64, an optional email address, a role and a list of tags. The rule at the end rejects any member the schema does not list.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/person.schema.json",
"title": "Person",
"description": "A person in our address book",
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"age": { "type": "integer", "minimum": 18, "maximum": 64 },
"email": { "type": "string", "format": "email" },
"role": { "enum": ["admin", "editor", "viewer"] },
"tags": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
}
},
"required": ["name", "age"],
"additionalProperties": false
}
Each keyword adds one rule. Two of the rules surprise many developers: listing a member under properties does not make it required, and many validators skip format unless we turn format checks on.
| Keyword | What it does |
|---|---|
$schema | Says which version of JSON Schema the file follows, here 2020-12. |
$id | Gives the schema a unique URI so other schemas can refer to it. The URI does not have to be downloadable. |
title, description | Notes for people. Validators ignore them. |
type | The expected JSON type: object, array, string, number, integer, boolean or null. JSON itself has no integer type; JSON Schema adds it for whole numbers. |
properties | A small schema for each member name. Listing a member here does not make it required. |
required | Member names that must be present. |
minLength, maxLength, minimum, maximum | Limits on string length and number size. The limits themselves are allowed. |
enum | A fixed list of allowed values. |
format | A named string format such as email, date-time or uri. Many validators check it only when told to. |
items, uniqueItems | A schema that every array item must match, and a rule against duplicate items. |
additionalProperties | With false, any member not listed under properties is an error. |
3. Validating with Ajv in Node.js
Ajv is a widely used JSON Schema validator for JavaScript. It compiles a schema into a JavaScript function once, so validating many documents later is fast. The default Ajv class targets draft-07, so for a 2020-12 schema we import the Ajv2020 class instead. The option allErrors: true collects every error; without it, Ajv stops at the first one.
We test two documents. The file valid.json holds { "name": "John Doe", "age": 35, "email": "john@example.com" }, and invalid.json holds { "name": "John Doe", "age": 68, "email": "john.example.com", "role": "owner", "nickname": "JD" }.
// npm install ajv ajv-formats
import { readFileSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
const load = (file) => JSON.parse(readFileSync(file, 'utf8'));
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(load('person.schema.json'));
for (const file of ['valid.json', 'invalid.json']) {
const ok = validate(load(file));
console.log(file, ok ? 'is valid' : 'is invalid');
for (const e of validate.errors ?? []) {
console.log(' ' + (e.instancePath || '(root)'), e.message, JSON.stringify(e.params));
}
}
With Ajv 8.20.0 and ajv-formats 3.0.1, Node.js 22 prints one line per error:
valid.json is valid
invalid.json is invalid
(root) must NOT have additional properties {"additionalProperty":"nickname"}
/age must be <= 64 {"comparison":"<=","limit":64}
/email must match format "email" {"format":"email"}
/role must be equal to one of the allowed values {"allowedValues":["admin","editor","viewer"]}
Each error carries an instancePath, which is a JSON Pointer to the failing value, such as /age. The params object holds the details a client needs, such as the name of the unexpected member. Those two fields are enough to build a clear error response.
3.1. The Format Check Needs ajv-formats
Ajv does not include format checks of its own. If we remove the ajv-formats lines, compiling the schema fails in Ajv’s default strict mode with this error:
unknown format "email" ignored in schema at path "#/properties/email"
Turning strict mode off with strict: false hides the error, but then Ajv skips the check and accepts "john.example.com" as an email address. Installing ajv-formats is the better fix.
4. Validating with jsonschema in Python
In Python, the jsonschema package does the same job. Like Ajv, it skips format unless we pass a format checker, so the example turns it on.
# pip install jsonschema
import json
from jsonschema import Draft202012Validator
with open("person.schema.json") as f:
schema = json.load(f)
with open("invalid.json") as f:
person = json.load(f)
validator = Draft202012Validator(
schema, format_checker=Draft202012Validator.FORMAT_CHECKER
)
for error in sorted(validator.iter_errors(person), key=lambda e: e.json_path):
print(error.json_path, "->", error.message)
With jsonschema 4.26.0, Python 3 prints:
$ -> Additional properties are not allowed ('nickname' was unexpected)
$.age -> 68 is greater than the maximum of 64
$.email -> 'john.example.com' is not a 'email'
$.role -> 'owner' is not one of ['admin', 'editor', 'viewer']
Both libraries found the same four problems, and only the wording differs. Without the format_checker argument, the email error disappears from the list. For other languages, the JSON Schema tools page lists validators and the versions they support.
5. Reusing Rules with $defs and $ref
Real payloads repeat shapes. An order may have a shipping address and a billing address with the same rules. Instead of copying them, we define the shape once under $defs and point to it with $ref. The value #/$defs/address is a JSON Pointer inside the same file.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Order",
"type": "object",
"properties": {
"shipping": { "$ref": "#/$defs/address" },
"billing": { "$ref": "#/$defs/address" }
},
"required": ["shipping"],
"$defs": {
"address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"postalCode": { "type": "string", "pattern": "^[0-9]{5}$" }
},
"required": ["street", "city", "postalCode"]
}
}
}
For an order whose billing address has no city and a four-digit postal code, Ajv reports both problems under the /billing path:
/billing must have required property 'city'
/billing/postalCode must match pattern "^[0-9]{5}$"
Schemas can also be combined. These keywords take a list of subschemas and decide how many of them must match:
- The keyword
allOfmeans the data must match every subschema. It is useful for extending a base shape. - The keyword
anyOfmeans the data must match at least one subschema. - The keyword
oneOfmeans the data must match one subschema and only one, for example a payment that is either a card or a bank transfer. - The keyword
notmeans the data must not match the subschema. - The keywords
if,thenandelseapply a second rule only when a first rule matches, such as requiring state only when country is US.
6. Schema Checks in a REST API
The best place to validate a request body is at the edge of the API, right after parsing and before any business logic. A bad request fails early with a clear message, and the rest of the code can trust its input. For a POST /people request, the diagram shows the HTTP 422 path and the HTTP 201 path.

For a body that is valid JSON but breaks the schema, many APIs answer with HTTP 422 Unprocessable Content; others use HTTP 400 Bad Request for every input error. Whichever we choose, the body should list each problem. The problem details format from RFC 9457, sent as application/problem+json, fits well. The example response below maps each Ajv error from section 3 to one entry in an errors array.
{
"type": "https://example.com/problems/validation-error",
"title": "The request body does not match the schema",
"status": 422,
"errors": [
{ "pointer": "/age", "detail": "must be <= 64" },
{ "pointer": "/email", "detail": "must match format \"email\"" },
{ "pointer": "/role", "detail": "must be equal to one of the allowed values" },
{ "pointer": "", "detail": "must NOT have additional property nickname" }
]
}
Two more habits help in practice. First, keep the schema in one place. OpenAPI 3.1 uses JSON Schema 2020-12 for its data models, so the schema in the API description can also drive validation, documentation and tests. Second, be careful with additionalProperties: false on the client side. If a client rejects responses with unknown members, the server can no longer add a field without breaking that client. Strict rules suit request bodies; tolerant rules suit responses. Our guide to REST API error handling covers the rest of the error response design.
7. Draft Versions and the $schema Line
JSON Schema went through several drafts before 2020-12, and the $schema line tells a validator which rules to apply. Older tutorials, including the first version of this page, used draft-04, so it helps to know what changed.
| Version | Change to watch for |
|---|---|
| draft-04 | exclusiveMinimum and exclusiveMaximum are booleans that modify minimum and maximum. |
| draft-06 | exclusiveMinimum and exclusiveMaximum become numbers. const, contains and propertyNames are added. |
| draft-07 | if, then and else are added. The default Ajv class still targets this draft. |
| 2019-09 | $defs replaces definitions. dependentRequired and dependentSchemas replace dependencies. unevaluatedProperties is added. |
| 2020-12 | prefixItems describes tuple positions, and items now applies to the remaining items. This is the current version. |
Mixing versions leads to confusing errors. If we pass our 2020-12 schema to the default Ajv class, compiling fails with this message:
no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
The fix is the class or validator for the right version: Ajv2020 in JavaScript or Draft202012Validator in Python.
8. When a Schema Accepts Data It Should Reject
Most schema bugs are rules that do nothing, so invalid data passes without any error. These five cases come up most often:
- A member is listed under properties but not under required, so an empty object
{}passes. - The format keyword is ignored because the validator was not told to check formats.
- The rule
type: numberaccepts 35.5 for an age. Useintegerfor whole numbers. - The rule
additionalProperties: falsesits next to anallOf. That rule only sees the members listed in its own properties, so it rejects every member. In Ajv,{"name": "John Doe", "age": 35}fails with two errors. UseunevaluatedProperties: false, which looks into the subschemas, and the same data passes. - A
patternhas no^and$anchors, so it matches any string that merely contains the pattern.
9. Conclusion
A JSON parser tells us whether text is JSON; a schema tells us whether that JSON is the data we expect. Write schemas against version 2020-12, list required members explicitly, turn on format checks, and validate request bodies before business logic sees them. Then return every error with its JSON Pointer so the client can fix the request in one try.
10. References
The specification, the validators we ran and the related standards are linked below.
- JSON Schema specification (current version 2020-12)
- JSON Schema 2020-12 release notes
- JSON Schema specification links for older drafts
- Understanding JSON Schema: type-specific keywords
- JSON Schema tools and validators
- Ajv JSON schema validator
- ajv-formats
- jsonschema for Python
- OpenAPI Specification 3.1.1
- RFC 9457: Problem Details for HTTP APIs
Happy Learning !!
Comments