Your API accepted {"age": "twenty-three"} and nobody caught it until it hit the database constraint at 3 AM on a Saturday. Or worse — it didn’t hit any constraint, silently poisoned a report, and you found out two weeks later when someone asked why the average user age was 0.
JSON Schema is the answer to this class of problem. It’s been around since 2009, it’s the backbone of OpenAPI specs, and yet most teams use maybe 20% of its capabilities — often the wrong 20%, from a draft that their tools no longer fully support.
This article covers the full picture: what the draft versions actually changed, which validators are worth using in 2026, and the specific gotchas that catch even experienced engineers off-guard.
Official spec and tooling hub: https://json-schema.org
The Draft Mess, Explained Once and for All
JSON Schema has a versioning problem — one that’s more psychological than technical. The naming scheme changed mid-history, which means you’ll see "Draft-07", "Draft 2019-09", and "Draft 2020-12" thrown around interchangeably in docs, issues, and Stack Overflow answers without any clear indication of which one applies to the code being discussed.
Here’s the full timeline in plain terms:
- Draft-04 (2013) — the version most people learned from. Still seen in older OpenAPI 2.0/Swagger specs. Missing a lot.
- Draft-06 (2016) — added
const,contains,propertyNames. Minor but useful. - Draft-07 (2018) — added
if/then/else,readOnly/writeOnly,$comment. This is the sweet spot a lot of teams settled on and never left. - Draft 2019-09 (formerly known as Draft-08) — big restructuring. Introduced
$vocabulary, splitdefinitionsinto$defs, addedunevaluatedProperties. Tooling support was inconsistent for years. - Draft 2020-12 (current stable) — refined 2019-09. Fixed
$refinteractions, addedprefixItems, made$dynamicRef/$dynamicAnchorthe proper replacement for the old recursive ref hack. OpenAPI 3.1 adopted this draft.
The key practical takeaway: Draft-07 and Draft 2020-12 are the two versions you’ll actually deal with today. Everything between them was transitional. If you’re starting a new project, use 2020-12. If you’re maintaining something old, stay on Draft-07 until you have a real reason to migrate — the differences are real and migration isn’t automatic.
What Draft 2020-12 Actually Changed (That Matters)
prefixItems replaces tuple-style items
In Draft-07, you could use items as an array to validate a tuple:
{
"type": "array",
"items": [
{ "type": "string" },
{ "type": "number" }
]
}
In 2020-12, that’s gone. Use prefixItems for positional validation:
{
"type": "array",
"prefixItems": [
{ "type": "string" },
{ "type": "number" }
],
"items": false
}
Note "items": false — this blocks additional elements. Without it, extra items pass validation silently.
unevaluatedProperties is the stricter additionalProperties
This one trips people up constantly (see Gotchas below). Short version: additionalProperties only looks at the properties defined in the same schema object. unevaluatedProperties looks at what’s been validated by the entire schema, including allOf, anyOf, if/then/else. Use unevaluatedProperties: false when you actually want to close your schema.
$ref is no longer a stop sign
In Draft-07 and earlier, $ref caused the rest of the sibling keywords to be ignored. Validators were spec-compliant in ignoring "description" or "title" next to a $ref. That was a constant source of confusion.
In 2020-12, $ref is just a keyword like any other. You can put a $ref alongside description, deprecated, or even additional validation keywords, and all of them will apply.
$dynamicRef / $dynamicAnchor for recursive schemas
The old way to do recursive schemas used $ref: "#" — functional but fragile when composing schemas across files. The new mechanism with $dynamicRef lets you define extension points in base schemas that concrete schemas can override. It’s mainly relevant when you’re building schema libraries rather than single-file validators, but it’s what makes polymorphic schemas composable without hacks.
Tooling in 2026: What’s Actually Worth Using
JavaScript / TypeScript
Ajv remains the gold standard. It supports Draft-04 through 2020-12, compiles schemas to JS functions (so validation is fast), and has plugins for formats. Use ajv with ajv-formats for standard formats like date-time and email.
npm install ajv ajv-formats
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const schema = {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {
username: { type: "string", minLength: 3 },
age: { type: "integer", minimum: 0, maximum: 150 },
email: { type: "string", format: "email" }
},
required: ["username", "email"],
unevaluatedProperties: false
};
const validate = ajv.compile(schema);
const data = { username: "nikita", age: 31, email: "[email protected]" };
if (!validate(data)) {
console.error(validate.errors);
}
For teams that prefer a TypeScript-native approach, Zod is popular for in-code schema definition and can export to JSON Schema via zod-to-json-schema. The catch: Zod’s primary output isn’t JSON Schema — you’re generating it, not authoring it. That’s fine for API docs but bad for schema-first workflows.
Valibot has gained traction for its tree-shakeable, smaller bundle size. Worth considering for browser-heavy apps. Full JSON Schema export is less mature than Zod’s.
Python
jsonschema (by Julian Berman) is the canonical Python library:
pip install jsonschema
import jsonschema
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {"type": "string"},
"score": {"type": "number", "minimum": 0}
},
"required": ["name", "score"],
"unevaluatedProperties": False
}
# Draft 2020-12 requires explicit validator class
from jsonschema import Draft202012Validator
v = Draft202012Validator(schema)
errors = list(v.iter_errors({"name": "Alice", "score": -1}))
for e in errors:
print(e.message)
Pydantic v2 is the practical alternative for most Python backend work. Its JSON Schema output (via model_json_schema()) targets Draft 2020-12 and integrates well with FastAPI. If you’re already using Pydantic, lean on it — maintaining a separate JSON Schema file is redundant overhead.
Go
santhosh-tekuri/jsonschema is the most actively maintained Go library as of 2026, with 2020-12 support. The older gojsonschema is at Draft-07 and hasn’t been meaningfully updated.
go get github.com/santhosh-tekuri/jsonschema/v6
import (
"github.com/santhosh-tekuri/jsonschema/v6"
"strings"
)
compiler := jsonschema.NewCompiler()
// Add schema from string
if err := compiler.AddResource("schema.json", strings.NewReader(`{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"port": { "type": "integer", "minimum": 1, "maximum": 65535 }
},
"required": ["port"]
}`)); err != nil {
panic(err)
}
schema, _ := compiler.Compile("schema.json")
if err := schema.Validate(map[string]any{"port": 9000}); err != nil {
// handle validation error
}
Rust
jsonschema-rs (jsonschema crate) supports 2020-12 and is genuinely fast — relevant if you’re validating at high throughput in a Rust service or using it via Python bindings (jsonschema on PyPI wraps it).
Java / JVM
networknt/json-schema-validator is the most complete JVM option with 2020-12 support. The older Everit library is Draft-04 only — avoid it for new code.
Gotchas
additionalProperties doesn’t see through allOf
This is the most common validation bug I’ve seen in production schemas. Suppose you do this:
{
"allOf": [{ "$ref": "#/$defs/Base" }],
"properties": {
"extra": { "type": "string" }
},
"additionalProperties": false
}
Because additionalProperties only sees the properties defined in the same object, the properties defined inside Base are invisible to it. An object with only Base properties will fail validation because additionalProperties: false sees them as "additional."
Fix: use unevaluatedProperties: false instead. It evaluates the full composition tree first.
format validation is opt-in — most validators don’t enable it by default
This one burns teams. You write "format": "email" expecting it to catch "not-an-email", but the default behavior for most validators is to treat format as a pure annotation — no actual validation happens.
In Ajv, you need ajv-formats AND to add it explicitly. In Python’s jsonschema, pass format_checker=jsonschema.FormatChecker(). Without this, your email format constraint is just a fancy comment.
required doesn’t enforce type — null will sneak past
{
"type": "object",
"properties": {
"name": { "type": "string" }
},
"required": ["name"]
}
This requires that name is present. It does NOT prevent {"name": null}. If your downstream code does name.upper(), you’ll get an exception. If you want to disallow null, add "type": "string" (no union with null) and you’re covered. If you need to allow null for optional fields in some contexts, use "type": ["string", "null"] explicitly. Don’t leave it ambiguous.
Integer vs number: JSON has no native integer type
JSON itself only has number. The "type": "integer" keyword in JSON Schema rejects floats like 1.5, but 1.0 — depending on the validator and how the JSON was parsed — might pass as an integer. Don’t rely on this distinction for business logic boundary enforcement. Validate range (minimum, maximum) and do a proper integer check in application code if the distinction matters critically.
Draft mismatch between tools
If your OpenAPI spec declares openapi: "3.1.0", it uses Draft 2020-12. But if you’re generating client code with an older tool or validating request bodies with a Draft-07 validator, you’ll get silent inconsistencies. The schema might be valid 2020-12 but the validator treats unknown keywords as annotations and skips them. Always align the draft version between your schema source and your validator. Declare $schema explicitly in every schema file — don’t rely on toolchain defaults.
$defs vs definitions
Draft-07 used definitions. Draft 2019-09+ uses $defs. Many tools accept both. But if you mix them in the same project, you will confuse people and eventually yourself. Pick one and stick to it. For new projects: $defs.
Production-Ready Patterns
Always declare $schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://your-domain.com/schemas/user.json"
}
The $id URI doesn’t need to be resolvable — it’s an identifier, not a fetch URL. But using a consistent URI pattern (https://schemas.yourcompany.com/...) makes it possible to register schemas in a registry and resolve $ref across files without path manipulation hacks.
Keep schemas in a central directory, reference via $ref
Don’t inline large schemas in code. Keep them in schemas/ as .json files, load them at startup, compile once, reuse the compiled validator. Ajv, jsonschema, and most other libraries cache compiled schemas — you’re not paying per-validation.
schemas/
user.json
product.json
common/
address.json
pagination.json
Reference shared definitions:
{
"$ref": "common/address.json"
}
Validate in CI
Use the check-jsonschema CLI tool to validate that your schema files are syntactically valid and that your test fixtures conform to them:
pip install check-jsonschema
# Validate a document against a schema
check-jsonschema --schemafile schemas/user.json test-fixtures/valid-user.json
# Validate the schema itself against the meta-schema
check-jsonschema --check-metaschema schemas/user.json
Add this to your CI pipeline. Schema drift is real — someone will edit a schema, forget to update a fixture, and the breakage will surface in production.
Close your schemas deliberately
An open schema (additionalProperties not set) will silently accept any extra fields. That’s fine during early development. But once a schema is shared with external consumers or used to validate inbound data from untrusted sources, you want it closed:
{
"type": "object",
"properties": { ... },
"required": ["id", "name"],
"unevaluatedProperties": false
}
The discipline of closing schemas also forces you to think about what you’re actually guaranteeing. Open schemas are contracts with invisible fine print.
Version your schemas
If external systems consume your schemas, treat them like APIs. Breaking changes (removing properties, tightening constraints, changing types) require a version bump. Non-breaking changes (adding optional properties, loosening constraints) can be backwards-compatible. A common pattern:
schemas/
v1/
user.json
v2/
user.json
And expose them at versioned URIs if you have a schema registry.
A Complete Real-World Example
Here’s a schema for an API endpoint that creates a user account — production-grade, with common pitfalls avoided:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.example.com/v1/create-user-request.json",
"title": "CreateUserRequest",
"type": "object",
"properties": {
"username": {
"type": "string",
"minLength": 3,
"maxLength": 32,
"pattern": "^[a-z0-9_-]+$",
"description": "Lowercase alphanumeric, underscores and hyphens only"
},
"email": {
"type": "string",
"format": "email",
"maxLength": 254
},
"password": {
"type": "string",
"minLength": 12,
"writeOnly": true
},
"role": {
"type": "string",
"enum": ["user", "admin", "moderator"],
"default": "user"
},
"profile": {
"$ref": "#/$defs/UserProfile"
}
},
"required": ["username", "email", "password"],
"unevaluatedProperties": false,
"$defs": {
"UserProfile": {
"type": "object",
"properties": {
"display_name": { "type": "string", "maxLength": 64 },
"bio": { "type": "string", "maxLength": 500 },
"website": { "type": "string", "format": "uri" }
},
"unevaluatedProperties": false
}
}
}
What this gets right: explicit draft declaration, $id, writeOnly on password (hint to doc generators), enum for role, unevaluatedProperties: false at both levels, format constraints that actually need to be enabled in the validator, and pattern for the username to catch subtle issues before they reach the database.
The OpenAPI 3.1 Angle
If you’re writing OpenAPI 3.1 specs, you’re writing JSON Schema 2020-12 — that’s the spec alignment OpenAPI made in 3.1. This is genuinely good news: previously, OpenAPI 3.0 used a restricted dialect of Draft-07 with a pile of custom extensions (nullable, vendor-specific discriminator behavior). Now it’s just standard JSON Schema.
The catch: OpenAPI 3.1 tooling support was patchy in 2023-2024. By 2026 it’s mostly solid, but verify your specific toolchain — code generators, mock servers, and documentation tools all had to update, and not all did at the same pace.
Final Thoughts
JSON Schema is mature, well-specified, and supported everywhere that matters. The draft fragmentation was a real problem for a few years, but 2020-12 has been stable long enough that tooling support is solid across all major languages.
The discipline of writing good schemas pays off in proportion to how much untrusted data your system handles. Internal microservices with strong typing on both ends — maybe the overhead isn’t worth it. Public-facing APIs, ETL pipelines ingesting third-party data, webhook receivers — schemas are non-negotiable.
Pick a draft (2020-12 unless you have a specific reason not to), pick a validator appropriate for your language, close your schemas deliberately, and validate in CI. That’s 90% of what you need.