JSON Schema validation: validate JSON in Node, Python, and the browser (2026)
TL;DR: a JSON Schema is a contract for JSON data. You declare field types, required keys, and constraints, then a validator checks whether a document follows that contract. Use Ajv in Node for the fastest validation, the jsonschema library in Python for portable schemas, and bundle Ajv in the browser for instant form and config feedback. For new projects in 2026, pick Draft 2020-12.
This guide walks through the smallest working example, end-to-end patterns in all three runtimes, and the traps behind “validation passes but production rejects the data” bugs.
What JSON Schema is (and is not)
A one-sentence definition
A JSON Schema is a JSON document that describes the shape of other JSON documents. A validator reads the schema and the data, then confirms conformance or returns the failing paths.
The smallest useful example:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
{"name": "Alice"} passes. {"age": 30} fails (missing name). {"name": 42} fails (name is not a string). That is the entire mental model.
JSON Schema vs JSON syntax validation
These are two different problems and they get confused all the time.
| Dimension | JSON syntax check | JSON Schema validation |
|---|---|---|
| What it checks | Is this a legal JSON document? | Does this JSON match the contract? |
| Catches | Missing commas, single quotes, comments | Wrong types, missing required fields, out-of-range values |
| Tools | JSON.parse(), JSON Formatter | Ajv, jsonschema (Python), fastjsonschema |
| When you reach for it | First thing, before parsing | Right after parsing, before business logic |
In practice you do both. Pretty-print the payload in JSON Formatter to confirm it parses, then run it through a schema to confirm it matches the contract.
JSON Schema vs JSONPath, JSON Patch, jq, and TypeScript
Five tools share this problem space. Here is how to choose.
| Tool | Question it answers | Reach for it when |
|---|---|---|
| JSON Schema | Does this JSON match the expected structure? | Validating API input, config files, form payloads |
| JSONPath | How do I query a value out of this JSON? | Extracting nested fields, batch reads |
| JSON Patch (RFC 6902) | How do I describe the diff from A to B? | Collaborative editing, incremental sync |
| jq | How do I process JSON on the command line? | Shell scripts, log pipelines, CI checks |
| TypeScript types | Does my code use this shape correctly? | Compile-time guarantees inside one codebase |
The split that matters: JSON Schema validates unknown data at runtime, while TypeScript validates known code at compile time. TypeScript can’t help you with JSON arriving from a third-party webhook or a user paste, and that is exactly the gap JSON Schema fills. Zod and Pydantic sit in the middle (compile-time types plus runtime validation), and we cover them below.
JSON Schema vs OpenAPI
A common misconception is that OpenAPI replaces JSON Schema. It doesn’t. OpenAPI uses JSON Schema internally to describe request and response bodies, then layers paths, parameters, security schemes, and server URLs on top. The schema is the data-shape contract; OpenAPI is the API contract that wraps it.
| Dimension | JSON Schema | OpenAPI |
|---|---|---|
| Scope | Shape of one JSON document | Shape of an entire HTTP API |
| Dependencies | None (schema is self-contained JSON) | Imports JSON Schema for body definitions |
| Version pairing | Draft 7 / Draft 2019-09 / Draft 2020-12 | OpenAPI 3.0 uses a Draft 4 subset; OpenAPI 3.1 uses Draft 2020-12 natively |
| Typical use | Config files, message envelopes, form validation, single-payload contracts | REST API design, SDK generation, mock servers, contract testing |
| Code generation | Limited (some quicktype-style tools) | Mature ecosystem (openapi-generator, oapi-codegen, vendor SDKs) |
| Contract management | One file per shape, no routing | Paths, operations, auth flows, versioned endpoints in one document |
Reach for plain JSON Schema when the artifact you care about is a single document: a webhook payload, a config file, a queue message, or a form. There’s no HTTP surface to describe, so OpenAPI is overhead.
Reach for OpenAPI when you’re publishing an HTTP API and want one document to drive docs, SDK generation, mock servers, and contract tests. Define your schemas first as standalone JSON Schema files in a schemas/ directory, then $ref them from the OpenAPI document. That keeps the schemas reusable outside the API context.
The version pairing trips teams up. OpenAPI 3.0 uses a Draft 4 subset, so you cannot use Draft 2020-12 keywords like prefixItems or unevaluatedProperties inside a 3.0 document — the generators will silently ignore them. OpenAPI 3.1 is a superset of Draft 2020-12, so anything valid in 2020-12 is valid in 3.1. If you have the choice, target OpenAPI 3.1 and write Draft 2020-12 schemas everywhere.
Your first JSON Schema (5 minutes)
The keywords you need first
You’ll get 80% of the way with these:
{
"type": "object",
"properties": {
"id": { "type": "integer", "minimum": 1 },
"email": { "type": "string", "format": "email" },
"age": { "type": "integer", "minimum": 0, "maximum": 150 },
"tags": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"role": { "enum": ["admin", "editor", "viewer"] },
"metadata": { "type": "object", "additionalProperties": true }
},
"required": ["id", "email"],
"additionalProperties": false
}
The vocabulary:
type: one ofstring,number,integer,boolean,null,array,objectpropertiesplusrequired: declare fields and mark which must be presentenum/const: restrict to a fixed set or single literalminimum/maximum/multipleOf: numeric boundsminLength/maxLength/pattern: string length and regexminItems/maxItems/uniqueItems: array shapeadditionalProperties: false: reject undeclared keys (always set this on input contracts)
JSON Schema examples by use case
The keywords above show up in different combinations depending on what you’re validating. A few representative shapes:
API request body — a signup endpoint that accepts email and password:
{
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"password": { "type": "string", "minLength": 8, "maxLength": 128 }
},
"required": ["email", "password"],
"additionalProperties": false
}
Configuration file — a logger config that locks the level to a fixed set:
{
"type": "object",
"properties": {
"level": { "enum": ["debug", "info", "warn", "error"] },
"output": { "type": "string", "default": "stdout" }
},
"required": ["level"],
"additionalProperties": false
}
Form payload with conditional rules — when accountType is "business", taxId becomes required:
{
"type": "object",
"properties": {
"accountType": { "enum": ["personal", "business"] },
"taxId": { "type": "string" }
},
"if": { "properties": { "accountType": { "const": "business" } } },
"then": { "required": ["taxId"] }
}
CSV-row JSON record — one row of an exported orders table:
{
"type": "object",
"properties": {
"orderId": { "type": "string", "pattern": "^ORD-[0-9]{6}$" },
"orderedOn": { "type": "string", "format": "date" },
"totalUsd": { "type": "number", "minimum": 0 }
},
"required": ["orderId", "orderedOn", "totalUsd"]
}
Webhook event envelope — oneOf discriminates by the type literal, so each event variant has its own payload shape:
{
"oneOf": [
{ "properties": { "type": { "const": "order.created" }, "data": { "$ref": "#/$defs/order" } } },
{ "properties": { "type": { "const": "order.refunded" }, "data": { "$ref": "#/$defs/refund" } } }
]
}
These five examples cover the bulk of what teams write in practice. Copy the closest match and adjust field names — the keyword vocabulary stays the same.
Validate without installing anything
Paste schema and payload into the playground at ajv.js.org or jsonschemavalidator.net for an instant verdict. If the JSON itself looks suspect, run it through JSON Formatter first.
Validating in Node.js with Ajv
Install and a 12-line example
Ajv compiles your schema into an optimized function on first compile, then reuses it.
npm install ajv
import Ajv from "ajv";
const ajv = new Ajv();
const schema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer", minimum: 0 }
},
required: ["name"]
};
const validate = ajv.compile(schema);
const data = { name: "Alice", age: 30 };
if (!validate(data)) console.log(validate.errors);
else console.log("OK");
Switching to Draft 2020-12
The default Ajv constructor is still pinned to Draft 7 for backward compatibility. Opt into 2020-12 explicitly:
import Ajv2020 from "ajv/dist/2020";
const ajv = new Ajv2020({ strict: true, allErrors: true });
Now prefixItems, unevaluatedProperties, and $dynamicRef are available. See the Draft 2020-12 section below for what each one does.
Turning on format validation
This trips more developers than any other Ajv quirk: format: "email" does nothing by default. The spec treats format as advisory, so you have to register the format module:
npm install ajv-formats
import addFormats from "ajv-formats";
addFormats(ajv); // now "format": "email" actually validates
Without this step, {"email": "not-an-email"} passes a schema that requires format: "email". Always install ajv-formats in production.
An Express middleware that actually runs in production
One validator per route, compiled at boot:
import express from "express";
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validateUser = ajv.compile({
type: "object",
properties: {
email: { type: "string", format: "email" },
age: { type: "integer", minimum: 13 }
},
required: ["email"],
additionalProperties: false
});
const app = express();
app.use(express.json());
app.post("/users", (req, res) => {
if (!validateUser(req.body)) {
return res.status(400).json({ errors: validateUser.errors });
}
// ... business logic
res.status(201).json({ ok: true });
});
The most expensive mistake to make here is calling ajv.compile(schema) inside the request handler. Compile once at module scope and reuse the returned function. Recompiling per request drops throughput 50x or more.
Validating in Python with jsonschema
Install and basic usage
pip install jsonschema
from jsonschema import validate, ValidationError
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer", "minimum": 0}
},
"required": ["name"]
}
try:
validate(instance={"name": "Alice", "age": 30}, schema=schema)
print("OK")
except ValidationError as e:
print("FAIL:", e.message, "at", list(e.absolute_path))
Collecting all errors with Draft202012Validator
validate() throws on the first error. To list every issue at once (useful for form responses), use iter_errors:
from jsonschema import Draft202012Validator
validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(instance), key=lambda e: e.path)
for err in errors:
print(f" - {'/'.join(map(str, err.absolute_path))}: {err.message}")
Now the user fixes everything at once instead of cycling through round trips.
jsonschema vs Pydantic: when to pick which
Two strong Python libraries, two different problems.
| Dimension | jsonschema | Pydantic v2 |
|---|---|---|
| Schema format | A JSON dict (schema is data) | A Python class with type hints |
| Performance | Interpreted, ~10–100× slower than Pydantic | Rust core, the fastest in the ecosystem |
| Cross-language portability | Yes (same schema works in JS, Go, Rust) | No (Python only) |
| FastAPI / native model integration | Manual conversion | Built in |
Full Draft 2020-12 keywords ($dynamicRef, etc.) | Complete | Partial |
The rule that holds up in production is to use jsonschema for cross-language contracts (OpenAPI, public APIs, webhooks) and Pydantic for internal Python services. Plenty of teams run both: jsonschema at the gateway for contract enforcement, Pydantic at the application layer for typed business logic. The schema is the portable artifact, identical to what you would feed Ajv.
Validating in the browser
Why validate client-side at all
There are three reasons, in order of importance. Instant feedback as the user types beats a server round trip, so the UX is better. Obvious mistakes never leave the browser, which saves bandwidth. And client-side validation cuts garbage volume to the backend, which is good security hygiene but does not replace server validation. Never trust client-side validation alone; validate again on the server.
Bundling Ajv for the browser
npm install ajv ajv-formats
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
export const validateForm = ajv.compile({
type: "object",
properties: {
email: { type: "string", format: "email" },
password: { type: "string", minLength: 8 }
},
required: ["email", "password"]
});
The bundle adds about 30 KB gzipped, which is meaningful but not catastrophic. Teams ship Ajv when they want one schema definition shared between server and client.
Lighter alternatives: Zod and Valibot
If you don’t need the JSON Schema ecosystem and you’re already in TypeScript, a TS-native validator gives you smaller bundles and tighter type inference:
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
const result = UserSchema.safeParse(data);
if (!result.success) console.log(result.error.issues);
Valibot ships in roughly 3 KB gzipped with a similar API. Pick it when bundle size dominates. The catch is that neither library produces JSON Schema. If you need one source of truth shared with backends, third-party clients, or OpenAPI generators, stay on Ajv. If everything is your own TypeScript, Zod and Valibot are more ergonomic.
What Draft 2020-12 adds
prefixItems for tuple validation
Draft 7 expressed tuples through items: [] plus additionalItems. Draft 2020-12 splits them cleanly:
{
"type": "array",
"prefixItems": [
{ "type": "string" },
{ "type": "number" }
],
"items": false
}
["x", 42] passes. ["x", 42, "extra"] fails. The schema reads as exactly what it does.
unevaluatedProperties for composed schemas
There is a subtle bug that bites every team using allOf or oneOf: additionalProperties: false only checks the immediate level it appears on. Sibling subschemas inside allOf declare whatever properties they like. The 2020-12 fix is unevaluatedProperties: false:
{
"allOf": [
{ "$ref": "#/$defs/base" }
],
"unevaluatedProperties": false
}
This rejects any property not evaluated by any branch, which is the behavior most developers expect from additionalProperties: false.
$dynamicRef for recursive schemas
Anyone who has tried to declare a recursive tree schema in Draft 7 knows the contortions involved. $dynamicRef plus $dynamicAnchor cleans this up:
{
"$dynamicAnchor": "node",
"type": "object",
"properties": {
"value": { "type": "string" },
"children": { "type": "array", "items": { "$dynamicRef": "#node" } }
}
}
The recursion is declarative and overrideable from descendants without a $id rewrite.
Draft 7 vs 2020-12: which to pick
For a new project on a modern toolchain, pick Draft 2020-12. If you are building or consuming OpenAPI 3.1, 2020-12 is the native dialect. If you are working with OpenAPI 3.0 or older services, you need Draft 4, since OpenAPI 3.0 uses a Draft 4 subset and you should not mix dialects. If you need broad validator compatibility (Postman, older CI tools), Draft 7 is still the safest interchange format.
Every modern validator (Ajv, Python jsonschema, jsonschema-rs, Java’s networknt/json-schema-validator) supports 2020-12 today.
Real-world patterns
API input validation
The Express middleware above is roughly the shape that runs in production. Two practices on top of it: keep all schemas in a schemas/ directory at the repo root, and add a CI step that runs ajv test (or the Python equivalent) to validate the schemas themselves against the JSON Schema meta-schema.
Configuration files
Visual Studio Code ships with SchemaStore integration, giving you autocomplete and inline validation for package.json, tsconfig.json, and dozens of others. Add a $schema field to your own configs and editor users get the same treatment.
CI test fixtures
Test fixtures rot. Someone updates a model, a fixture stays at the old shape, and the test still passes because the assertions never touched the changed field. Catch this with a schema check before the assertions run:
import { glob } from "glob";
const files = await glob("__tests__/fixtures/*.json");
for (const f of files) {
const data = JSON.parse(await fs.readFile(f, "utf8"));
if (!validate(data)) throw new Error(`${f}: ${ajv.errorsText(validate.errors)}`);
}
When the schema check fires, the next move is usually a structural diff. Pull the fixture into JSON Diff against a fresh production sample to see what drifted. If timestamps and IDs dominate the diff, apply the snapshot path-ignore patterns from the JSON diff guide to separate signal from noise.
Webhook payloads (Stripe, GitHub)
Third-party webhooks are one of the most useful places to apply JSON Schema. The webhook is a contract, the provider can change it, and you want to know the moment they do. Stripe and GitHub publish OpenAPI descriptions you can extract JSON Schemas from. Validate inbound events, and a breaking upgrade trips your monitoring instead of corrupting state quietly.
Schema-driven form validation
React Hook Form has an @hookform/resolvers/ajv adapter, and Vue’s VeeValidate has an equivalent Ajv plugin. Both drive form rendering, error messages, and submission validation from one JSON Schema, so the UI inherits its rules from the schema.
Friendly error messages
Why the defaults are rough
Out of the box, Ajv produces errors like #/properties/email format must match "email". That is fine for an engineer debugging a 400, but useless for a user filling in a checkout form.
ajv-errors for custom messages
npm install ajv-errors
import ajvErrors from "ajv-errors";
ajvErrors(ajv);
const schema = {
type: "object",
properties: { email: { type: "string", format: "email" } },
required: ["email"],
errorMessage: {
properties: { email: "Please enter a valid email address" },
required: { email: "Email is required" }
}
};
The errorMessage keyword stays inside the schema, so validation rules and user-facing copy travel together.
ajv-i18n for translated errors
ajv-i18n ships translations of the default messages in 30+ languages. One line at startup and your validator speaks Spanish, French, Japanese, or whichever locales you serve. Handy as a fallback when your errorMessage overrides don’t cover every constraint.
Mapping schema paths to form fields
Each Ajv error has an instancePath like /users/0/email. Most form libraries expect dotted paths like users[0].email. One-liner:
const fieldPath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
In Python’s jsonschema, the equivalent lives at error.absolute_path. Join with . for the same effect.
Five traps that pass validation then crash production
1. format is advisory by default
Without ajv-formats plus addFormats(ajv), every format keyword is a no-op. {"format": "email"} accepts "not-an-email". Always install the format package in production.
2. additionalProperties defaults to true
Without additionalProperties: false, your schema accepts every undeclared field. Clients can ship extra fields that bypass validation entirely. Make additionalProperties: false the default on input contracts and relax deliberately when needed.
3. additionalProperties doesn’t compose
Inside allOf, oneOf, or anyOf, additionalProperties: false only inspects properties at its own level. Sibling subschemas slip through. The Draft 2020-12 fix is unevaluatedProperties: false.
4. Remote $ref is a production risk
$ref: "https://example.com/schema.json" makes Ajv fetch over the network on first compile. That means latency, DoS exposure if the remote host stalls, and an MITM attack surface. Inline all $ref targets or load them from disk at build time.
5. Generated schemas drift from real data
Tools like quicktype and typescript-json-schema generate schemas from existing types. The output usually over-permits, with every field optional and additionalProperties open. Treat generated schemas as drafts, tighten by hand, and run CI that validates real production samples against the schema (and vice versa) so drift surfaces fast.
Performance: numbers and rules of thumb
- Ajv (Node.js): compiled validators clear one check in well under a microsecond. The fastest production-grade JS validator available.
jsonschema(Python): interpreted, 10-100x slower than Pydantic. Swap infastjsonschemawhen this bites; it generates Python code and lands close to Ajv.- Rust and Go:
jsonschema-rsandxeipuuv/gojsonschemagive you another 2-5x over Ajv at the gateway tier. - The biggest win is to precompile. Call
ajv.compile(schema)once at module load and reuse the returned validator on every request. Recompiling per request kills throughput by 50x or more.
Frequently asked questions
What is JSON Schema validation in plain English?
JSON Schema validation checks whether a JSON document follows a contract. The contract (the schema) is itself JSON, declaring types, required fields, and constraints. A validator reads the schema and the data and reports “passes” or the paths that failed and why.
How do I validate JSON against a schema online?
Paste schema and data into the ajv.js.org playground or jsonschemavalidator.net for an instant verdict. If the JSON looks malformed, clean it up in JSON Formatter first; both run in the browser, no upload.
Which JSON Schema validator is fastest in 2026?
In Node, Ajv with precompiled validators clears one check in under a microsecond. In Python, fastjsonschema generates code and reaches Ajv-class throughput. At the gateway tier, jsonschema-rs (Rust) and gojsonschema (Go) are 2-5x faster than Ajv. Whichever you pick, precompile once and reuse.
What is the difference between JSON Schema and TypeScript types?
TypeScript checks the code you write at compile time. JSON Schema checks unknown JSON at runtime. TypeScript can’t see JSON arriving from an HTTP response, a file, or a user paste; that’s exactly what JSON Schema is for.
Should I use Draft 2020-12 or Draft 7?
For new projects in 2026, pick Draft 2020-12. prefixItems, unevaluatedProperties, and $dynamicRef solve real problems, and OpenAPI 3.1 uses 2020-12 natively. Stay on Draft 7 only for Postman compatibility or older services. OpenAPI 3.0 uses a Draft 4 subset, so don’t mix dialects.
How do I generate a JSON Schema from existing JSON?
You have three options. Paste samples into quicktype.io or jsonschema.net, run npx genson-js or pip install genson && genson sample.json on the command line, or hand-write it. Auto-generated schemas are too permissive (every field optional, additionalProperties: true), so always tighten before treating them as contracts.
Can JSON Schema replace OpenAPI?
No. OpenAPI uses JSON Schema internally to describe request and response bodies, then adds paths, security schemes, parameters, and server URLs. They compose: write your schemas, reference them from an OpenAPI document, get full API contracts.
Is JSON Schema the same as JSONPath or jq?
Different problems. JSON Schema validates structure (“does this JSON match the contract?”). JSONPath and jq extract values (“every pod name in the Running phase”). Validate with a schema; query with JSONPath or jq.
Why does my Ajv validation pass but production rejects the data?
Three culprits cover almost every case. You forgot ajv-formats, so format: "email" never validated. You omitted additionalProperties: false, so extra client fields slipped through. Or you used additionalProperties: false inside allOf or oneOf and discovered it doesn’t compose; the fix there is to switch to unevaluatedProperties: false.
Can I customize JSON Schema error messages for end users?
Yes. In Node, install ajv-errors to embed errorMessage inside the schema, and ajv-i18n for translations across 30+ locales. In Python, jsonschema exposes the full validation context on each error object, so you can map error type plus path to whatever copy your design system uses.