JSON Schema Validation in 2026: Drafts Explained, Tooling That Works, and Gotchas That Will Bite You

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, split definitions into $defs, added unevaluatedProperties. Tooling support was inconsistent for years.
  • Draft 2020-12 (current stable) — refined 2019-09. Fixed $ref interactions, added prefixItems, made $dynamicRef/$dynamicAnchor the 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.

👁 Views: 112,742 · Unique visitors: 45,402