How to Validate JSON Against a Schema in Python and JavaScript
Writing a schema is the easy half. The half that decides whether schema validation survives contact with a real codebase is the error handling: which library, which draft, how to read the error objects, and how to turn a list of validator output into a message someone can act on.
In short
In Node, install ajv, import the draft 2020-12 build from ajv/dist/2020, compile the schema once with allErrors set, and call the returned function — failures land in validate.errors as objects carrying instancePath, keyword and message. In Python, install jsonschema and use Draft202012Validator: validate() raises on the first failure, iter_errors() yields all of them, and best_match() picks the most relevant one.
What you need before you validate
Three things: a schema, a document, and a validator that speaks the schema's draft. The schema is itself JSON — type constrains the kind of value, properties describes an object's fields, required lists the ones that must be present, and additionalProperties set to false closes the object to anything you did not name. If those four keywords are unfamiliar, read them up once before continuing; everything below assumes them. The JSON Schema tutorial on json-schema.org, under Understanding JSON Schema, is the canonical introduction.
The draft matters more than people expect, because validators enforce different keyword sets and the differences are silent. Draft 2020-12 is the current release and what OpenAPI 3.1 targets; its headline changes from draft-07 are that array item schemas moved from items and additionalItems to prefixItems and items, that the recursive-reference keyword was replaced by $dynamicRef, and that $ref may now sit alongside other keywords in the same schema object instead of overriding them. Declare the draft in the schema with $schema and configure the validator to match — a mismatch usually shows up as a constraint that quietly never runs.
Then decide what failure means. Validation gives you a boolean plus a list of errors, and the interesting design question is which of those errors a human should see. A request body rejected at an API boundary wants a field-by-field list; a config file loaded at startup wants one clear message about the first thing that is wrong. The libraries below support both, and choosing deliberately is the difference between validation that helps and validation people learn to ignore. To sanity-check a document before you point a schema at it, run it through the JSON Validator — a syntax error and a schema violation are different failures with different fixes.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": { "type": "integer", "minimum": 1 },
"email": { "type": "string" },
"age": { "type": "integer", "minimum": 0 },
"tags": { "type": "array", "items": { "type": "string" }, "minItems": 1 }
},
"required": ["id", "email"],
"additionalProperties": false
}Ajv in Node: install, compile, validate
Ajv is the standard choice in the JavaScript ecosystem, and it is fast because it compiles a schema into a purpose-built JavaScript function rather than walking the schema at runtime. Install it with npm install ajv. The default export targets draft-07; for draft 2020-12 import the separate build at ajv/dist/2020, which registers the right meta-schema and vocabulary. Mixing them up is the most common first-run problem — a 2020-12 schema loaded into the draft-07 class will complain about an unknown $schema or silently ignore prefixItems.
Compile once, validate many times. Calling compile on a schema returns a function; calling that function returns a boolean and, on failure, attaches the errors to the function object as validate.errors. That mutable property is deliberate — Ajv reuses it on each call — so read or copy the errors immediately, and never hold a reference across another validation. By default Ajv stops at the first error; the allErrors option collects them all, which is what you want for a form. The Ajv documentation warns against enabling allErrors when validating untrusted data against complex schemas, because it removes the short-circuit that limits worst-case work.
The code below is the complete first program, and the commented output is the real output from running it against ajv 8.20.0 on Node v22.22.2. Two more options are worth knowing on day one. Ajv 8 runs in strict mode by default and throws on schema constructs it considers mistakes — an unknown keyword, an ignored constraint — which is a feature rather than an obstacle. And formats such as email, uri and date-time are not built in: install ajv-formats and register it, or format keywords are ignored entirely and your email check does nothing.
// npm install ajv (add ajv-formats for "format" support)
const Ajv2020 = require("ajv/dist/2020"); // draft 2020-12 build
const ajv = new Ajv2020({ allErrors: true });
// const addFormats = require("ajv-formats"); addFormats(ajv);
const validate = ajv.compile(schema); // compile once, reuse
const data = { id: 0, age: "42", tags: [], nickname: "ada" };
if (!validate(data)) {
console.log(ajv.errorsText(validate.errors, { separator: "\n" }));
}
// Real output, ajv 8.20.0 on Node v22.22.2:
// data must have required property 'email'
// data must NOT have additional properties
// data/id must be >= 1
// data/age must be integer
// data/tags must NOT have fewer than 1 itemsReading an Ajv error object
The errorsText helper is fine for a console; an API needs the structured form. Each entry in validate.errors is a small object with a fixed shape, and the two fields that carry most of the weight are instancePath — a JSON Pointer to the offending value in the document — and keyword, which names the constraint that failed. The table below lists them against the real errors Ajv produced for the invalid document above.
instancePath is the field to map onto a form. It is a JSON Pointer, so a path like /items/0/sku means the sku of the first array element, and an empty string means the root of the document. Turning it into a UI path is usually a split on the slash with the empty leading segment dropped. Note the asymmetry that trips people up: for a required failure, instancePath points at the containing object rather than at the missing field — the missing name lives in params.missingProperty. The same is true of additionalProperties, where params.additionalProperty holds the offending key.
params is where the machine-readable detail lives, and its contents vary by keyword: limit and comparison for minimum, limit for minItems and maxLength, allowedValues for enum, pattern for pattern. Build your user-facing messages from keyword plus params rather than parsing the message string, which is English prose and may change between releases. schemaPath, a JSON Pointer into the schema itself, is what you want when the question is which rule fired rather than which value broke — invaluable with $ref-heavy schemas.
| Field | What it holds | Example (id: 0 against minimum: 1) |
|---|---|---|
| instancePath | JSON Pointer to the failing value in the data | "/id" |
| schemaPath | JSON Pointer to the rule that fired, inside the schema | "#/properties/id/minimum" |
| keyword | The constraint that failed | "minimum" |
| params | Machine-readable detail, keyword-specific | { comparison: ">=", limit: 1 } |
| message | English prose, with no leading path | "must be >= 1" |
| params.missingProperty | Present only on required errors | "email" |
| params.additionalProperty | Present only on additionalProperties errors | "nickname" |
// The same failure, structured - verbatim from the run
[
{ instancePath: "", schemaPath: "#/required",
keyword: "required", params: { missingProperty: "email" },
message: "must have required property 'email'" },
{ instancePath: "", schemaPath: "#/additionalProperties",
keyword: "additionalProperties", params: { additionalProperty: "nickname" },
message: "must NOT have additional properties" },
{ instancePath: "/id", schemaPath: "#/properties/id/minimum",
keyword: "minimum", params: { comparison: ">=", limit: 1 },
message: "must be >= 1" }
]
// Map to a field-keyed object for an API response
const byField = {};
for (const e of validate.errors) {
const field = e.keyword === "required"
? e.params.missingProperty
: e.instancePath.slice(1).split("/").join(".") || "(root)";
(byField[field] ||= []).push(e.message);
}jsonschema in Python
The Python equivalent is the jsonschema package — pip install jsonschema, or install it with the format extra if you want the optional dependencies that make format checking real. The quickest call is validate(instance=data, schema=schema), which raises ValidationError on the first failure and SchemaError if the schema itself is invalid. It is convenient and appropriate for a config file loaded at startup, where the first problem is the one worth reporting.
For anything that needs all the errors, instantiate a validator class directly. Draft202012Validator(schema) gives you iter_errors(data), a generator of ValidationError objects; is_valid(data) for a plain boolean; and the classmethod check_schema(schema), which validates your schema against the meta-schema. Instantiating the class explicitly is also how you pin the draft — validate() infers it from $schema and falls back to the latest, so an explicit class removes the guesswork.
A ValidationError carries the same information Ajv's does under different names. message is the human string; validator is the keyword that failed and validator_value the constraint's value; absolute_path is a deque of path segments and json_path renders it in JSONPath form; instance is the failing value itself; and context holds the sub-errors when an anyOf or oneOf branch fails, which is where the real explanation lives for union schemas. Sorting by the error's path before printing gives you a stable, document-order list.
# pip install jsonschema (the "format" extra adds format checking)
from jsonschema import Draft202012Validator, validate
from jsonschema.exceptions import ValidationError
# 1. Fail fast on the first problem
try:
validate(instance=data, schema=schema)
except ValidationError as err:
print(err.json_path, err.message) # $.id 0 is less than the minimum of 1
# 2. Collect every problem
Draft202012Validator.check_schema(schema) # is the SCHEMA itself valid?
validator = Draft202012Validator(schema)
for error in sorted(validator.iter_errors(data), key=lambda e: list(e.path)):
print(f"{error.json_path}: {error.message} [{error.validator}]")
# 3. Formats are opt-in here too
strict = Draft202012Validator(
schema, format_checker=Draft202012Validator.FORMAT_CHECKER
)
print(strict.is_valid(data))Picking one message a human can act on
Dumping every error at a user is its own failure mode. A schema with a oneOf across three payload shapes produces a wall of complaints about the two branches the user was never trying to use, and the one useful line is buried. python-jsonschema ships best_match for exactly this: pass it the iterator from iter_errors and it applies a relevance heuristic — preferring errors deeper in the document and deprioritising the noise from failed anyOf and oneOf branches — then returns the single error most likely to be the real problem.
Ajv has no direct equivalent, so the common approach is to sort the errors yourself: longest instancePath first, so the most specific complaint wins, and drop the anyOf and oneOf umbrella errors that merely report that no branch matched. Ajv's discriminator option is worth knowing for tagged unions — with it, a oneOf keyed on a discriminator property reports errors only from the matching branch, which removes the noise at the source.
Whichever library you use, keep two channels. Log the full structured error list, with instancePath or json_path intact, so debugging has everything. Show the user one message per field, phrased in your own words from the keyword and its params rather than the validator's prose, which says things like must NOT have fewer than 1 items. Attaching a title or description to each schema property gives you somewhere to put the human phrasing so it travels with the constraint.
from jsonschema.exceptions import best_match
error = best_match(validator.iter_errors(data))
if error is not None:
print(error.message) # the single most relevant failure
print(list(error.absolute_path)) # ['tags', 0]
print(error.json_path) # $.tags[0]
print(error.validator, error.validator_value) # minItems 1
# anyOf / oneOf: the real reason lives in .context, not in .message
for sub in error.context or []:
print(" branch:", list(sub.path), sub.message)Four ways a schema passes when it should not
First, the missing additionalProperties. By default JSON Schema is permissive — any property you did not describe is allowed through untouched. A mistyped key such as emial does not fail validation; it is simply an extra property, and the required email is what fails, if you remembered to list it. Close your objects unless you have a specific reason not to, and be aware that additionalProperties does not see through allOf: each subschema evaluates it against only the properties that subschema declares, which is a classic source of confusion. In 2020-12, unevaluatedProperties is the keyword that does look across the composition.
Second, required versus nullable. required means the key is present, not that its value is useful — an email set to null satisfies a required list containing email, and fails only if the type says string. If null is a legitimate value, give the property a type array of string and null and keep the field required; if it is not, the type alone rejects it. Deciding which of absent, null and empty string you actually mean is most of the work of a good schema.
Third, format doing nothing. In Ajv, format is ignored unless ajv-formats is installed and registered; in python-jsonschema it is an annotation unless you pass a format checker, and some individual checkers need optional dependencies. Both libraries fail open here, so a schema that looks like it validates email addresses may be validating only that the value is a string. Fourth, $ref resolution: a relative reference resolves against the schema's $id, and a validator with no way to fetch a remote URI will raise or, worse, be configured to skip. Bundle your schemas into one document with $defs, or register them explicitly — addSchema in Ajv, a registry or resolver in Python — so nothing depends on network access at validation time.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/order",
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"phone": { "type": ["string", "null"] },
"shipTo": { "$ref": "#/$defs/address" }
},
"required": ["email", "phone"],
"additionalProperties": false,
"$defs": {
"address": {
"type": "object",
"properties": {
"city": { "type": "string" },
"postcode": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false
}
}
}Wiring it into CI
Schema validation earns its keep when it runs without anyone remembering to run it. The smallest useful setup is a script that walks a directory of fixtures or config files, validates each against its schema, prints the failures and exits non-zero. Both libraries make this a dozen lines, and a non-zero exit is all a CI job needs. Validate the schema itself first — compile throws on an invalid schema in Ajv's strict mode, and check_schema raises SchemaError in Python — so a broken schema fails loudly instead of silently accepting everything.
Beyond fixtures, three placements repay the effort. Validate API request bodies at the edge, so a malformed payload is rejected with a field-level message before it reaches business logic. Validate recorded API responses in your integration tests, which turns a provider's breaking change into a red build rather than a production incident. And validate configuration at process startup, where failing immediately with a clear message beats a null dereference forty minutes later.
Two operational notes. Compile the schema once at module load, not per request — Ajv's compile step is the expensive part and the returned function is cheap, and the same is true of instantiating a Python validator class. And keep the schema in version control next to the code it describes, so a change to the contract shows up in the diff. When you need a quick check outside the pipeline, the JSON Schema Validator validates a document against a pasted schema in the browser, and the JSON Formatter will point at the line if the document does not parse at all.
# Node: validate every fixture, exit non-zero if any fail
node -e '
const Ajv = require("ajv/dist/2020"), fs = require("fs");
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(JSON.parse(fs.readFileSync("schema.json", "utf8")));
let bad = 0;
for (const f of fs.readdirSync("fixtures")) {
const data = JSON.parse(fs.readFileSync("fixtures/" + f, "utf8"));
if (!validate(data)) { bad++; console.error(f, ajv.errorsText(validate.errors)); }
}
process.exit(bad ? 1 : 0);
'
# Python: same idea
python - <<PY
import json, pathlib, sys
from jsonschema import Draft202012Validator
schema = json.loads(pathlib.Path("schema.json").read_text())
Draft202012Validator.check_schema(schema)
v, bad = Draft202012Validator(schema), 0
for f in pathlib.Path("fixtures").glob("*.json"):
for e in v.iter_errors(json.loads(f.read_text())):
bad += 1
print(f.name, e.json_path, e.message, file=sys.stderr)
sys.exit(1 if bad else 0)
PYSources
Frequently asked questions
Which library should I use in Node?
Ajv, for almost every case. It compiles schemas to JavaScript functions, which makes it the fastest option by a wide margin, and it supports draft-07, 2019-09 and 2020-12 alongside JSON Type Definition. Install ajv, import ajv/dist/2020 for the current draft, and add ajv-formats if your schema uses format. The main alternative worth knowing is a TypeScript-first library such as Zod, but that is a different model — a validator you write in TypeScript rather than a JSON Schema document you can share with non-JavaScript consumers.
Why does my format: email constraint accept anything?
Because format is opt-in in both libraries. Ajv ignores format keywords unless you install ajv-formats and register it on the instance; python-jsonschema treats format as an annotation unless you pass a format checker, and some individual checkers need extra packages installed. Both fail open rather than raising, so the schema looks correct while doing nothing. Test one deliberately invalid value after wiring it up.
How do I get all the errors instead of just the first?
In Ajv, pass allErrors when constructing the instance; the default short-circuits at the first failure for speed. Note the Ajv documentation's caution about using it on untrusted data with complex schemas, since it removes that short-circuit. In Python, use Draft202012Validator(schema).iter_errors(data), which yields every error — validate() is the fail-fast convenience wrapper that raises on the first one.
How do I read the path in an error?
Ajv gives you instancePath, a JSON Pointer such as /items/0/sku, where an empty string means the document root. python-jsonschema gives you absolute_path as a deque of segments and json_path as a JSONPath string. The trap in both is required and additionalProperties errors: the path points at the containing object, and the field name lives in params.missingProperty in Ajv or has to be read out of the message in Python.
Should validation run in production or only in CI?
Both, at different boundaries. In CI, validate fixtures and recorded responses so contract drift breaks the build. In production, validate inbound request bodies and configuration at startup — that is where invalid data actually arrives. Compile the schema once at module load rather than per request; the compiled Ajv function and an instantiated Python validator are cheap to call and expensive to build.
Paste a document and a schema into the JSON Schema Validator to see every violation with its path, without installing anything.
Validate against a schemaKeep working
Related tools
JSON Schema Validator
Paste a document and a JSON Schema, press Validate, and get every violation at once — each one located by its JSON Pointer path and named by the keyword that failed. Runs Ajv entirely in your browser.
JSON to JSON Schema
Paste a JSON or XML sample — the format is auto-detected — and get typed JSON Schema models with names, nesting and nullability inferred for you.
JSON Formatter
Format, beautify, and validate JSON instantly in a professional VS Code-style editor — free, fast, and 100% private.