Skip to content
devtoolbox.cc

JSON Schema explained: validate JSON data step by step

· Updated · 7 min read

Open the JSON Schema tool

Every API integration eventually hits the same bug. A field that was always a number arrives as a string, or a required field is simply missing, and the error surfaces three services later as TypeError: cannot read properties of undefined. JSON itself can’t prevent this, because “valid JSON” only means the text parses.

JSON Schema is the standard way to describe what a document must look like and to check it automatically. This article builds a schema from scratch, explains the keywords you will use every day, and shows the mistakes that make a schema accept data it should reject. Every example can be pasted into the JSON Schema validator, which runs entirely in your browser.

A first schema

Take a user object returned by an API:

{
  "id": 42,
  "name": "Ann",
  "email": "ann@example.com",
  "roles": ["admin"],
  "createdAt": "2026-09-29T10:00:00Z"
}

A schema that describes it:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["id", "name", "email"],
  "properties": {
    "id": { "type": "integer", "minimum": 1 },
    "name": { "type": "string", "minLength": 1 },
    "email": { "type": "string", "format": "email" },
    "roles": {
      "type": "array",
      "items": { "enum": ["admin", "editor", "viewer"] },
      "uniqueItems": true
    },
    "createdAt": { "type": "string", "format": "date-time" }
  },
  "additionalProperties": false
}

Read it top to bottom and it almost explains itself. $schema says which version of the spec the schema follows. type constrains the value. properties gives a subschema for each known field. required lists the fields that must be present, and additionalProperties: false rejects any field that isn’t listed.

Now break the data: set id to 0, email to "nope" and add "extra": 1. The validator reports three errors, each with the path to the failing value and the keyword that failed:

Path Problem Keyword
$.id 0 is less than 1 minimum
$.email String does not match format “email” format
$.extra Property “extra” is not allowed additionalProperties

The keywords you will use every day

Most real schemas use a dozen keywords. Grouped by what they check:

Checks Keywords
Type type (string, number, integer, boolean, object, array, null)
Fixed values enum, const
Numbers minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf
Strings minLength, maxLength, pattern, format
Arrays items, prefixItems, minItems, maxItems, uniqueItems, contains
Objects properties, required, additionalProperties, patternProperties, propertyNames
Combining allOf, anyOf, oneOf, not, if / then / else
Reuse $defs, $ref

type accepts an array, which is the usual way to allow null: "type": ["string", "null"].

integer is not a separate JSON type. It means “a number with no fractional part”, so 1.0 is a valid integer from draft 6 onwards.

Reusing definitions with $defs and $ref

When the same shape appears twice (an address for billing and shipping, say), define it once:

{
  "$defs": {
    "address": {
      "type": "object",
      "required": ["city", "country"],
      "properties": {
        "city": { "type": "string" },
        "country": { "type": "string", "pattern": "^[A-Z]{2}$" }
      }
    }
  },
  "type": "object",
  "properties": {
    "billing": { "$ref": "#/$defs/address" },
    "shipping": { "$ref": "#/$defs/address" }
  }
}

#/$defs/address is a JSON Pointer into the same document. Draft-07 and older use definitions instead of $defs; both work the same way.

A $ref can also point at another file by URL. Validators disagree on whether to download it. The validator on this site never fetches URLs (it’s a privacy promise: nothing leaves the browser), so for testing, paste the referenced schema into $defs and point at it locally.

Drafts: which version are you writing?

JSON Schema has gone through several drafts, and they are not fully compatible:

Draft Year Notable changes
draft-04 2013 exclusiveMinimum is a boolean that modifies minimum
draft-06 2017 exclusiveMinimum becomes a number; const, contains, propertyNames
draft-07 2018 if / then / else; still the most widely supported draft
2019-09 2019 $defs, unevaluatedProperties, dependentRequired
2020-12 2020 prefixItems replaces array-form items; $dynamicRef

The draft is picked by $schema. Without it, a validator falls back to its default, which may not be the draft you wrote for. The classic surprise is exclusiveMinimum: {"minimum": 5, "exclusiveMinimum": true} is valid draft-04, but in draft-06 and later exclusiveMinimum must be a number, so the same schema is itself invalid.

If you inherit a schema without $schema, add one. OpenAPI 3.0 uses its own dialect based on an older draft, while OpenAPI 3.1 aligns with 2020-12.

Mistakes that let bad data through

A schema that is too loose passes every test and protects nothing. These are the usual holes.

required inside a property

{ "properties": { "id": { "type": "integer", "required": true } } }

This is draft-03 syntax. In every later draft, required is an array on the parent object, and the boolean above is an unknown keyword that validators silently ignore. A document with no id passes.

Forgetting that properties doesn’t require anything

properties only says “if this field exists, it must look like this”. Without required, an empty object {} is valid against almost any object schema.

additionalProperties: false with allOf

A common way to extend a base type:

{
  "allOf": [{ "$ref": "#/$defs/base" }],
  "properties": { "extra": { "type": "string" } },
  "additionalProperties": false
}

additionalProperties only sees properties declared in the same schema object, not the ones inside allOf. Every field from base is now rejected as “additional”. Draft 2019-09 added unevaluatedProperties for exactly this case: it knows about properties that any subschema already checked.

Expecting format to validate

In drafts 2019-09 and 2020-12, format is an annotation by default. A validator may check it, but it doesn’t have to, so "format": "email" alone doesn’t guarantee an email. Most libraries have an option to turn assertion on (Ajv uses a separate ajv-formats package). The validator here checks formats by default and has a switch to turn that off, so you can see both behaviours.

A schema with a typo

{"type": "strin"} or {"minLenght": 3} look fine at a glance. An unknown keyword is ignored, so the misspelled minLenght never runs. Validating the schema itself against the official meta-schema catches the invalid type value. The JSON Schema validator does this first and reports “Schema is invalid” before it looks at your data. Misspelled keywords are harder, because the spec allows extra keywords, so a strict-mode linter is worth adding in CI.

Validating in code

The validator you pick depends on the language:

// JavaScript: Ajv
import Ajv from 'ajv/dist/2020';
const validate = new Ajv({ allErrors: true }).compile(schema);
if (!validate(data)) console.log(validate.errors);
# Python: jsonschema
from jsonschema import Draft202012Validator
for error in Draft202012Validator(schema).iter_errors(data):
    print(error.json_path, error.message)

One practical note for front-end use: Ajv compiles schemas into JavaScript functions with new Function. That’s fast, but a strict Content Security Policy without 'unsafe-eval' blocks it. Ajv can precompile validators at build time (“standalone code”) to avoid that, or you can use an interpreting validator such as @cfworker/json-schema, which is what the validator on this site uses.

Big numbers and other edge cases

Validators work on parsed values, so a JSON parser’s limits become the validator’s limits. In JavaScript, 9007199254740993 is parsed as 9007199254740992 before the schema sees it, and "maximum": 9007199254740992 passes a value that is actually larger. If your ids are that big, send them as strings and validate them with pattern.

It’s also easy to forget that const and enum compare values, not text. 1, 1.0 and 1e0 are all equal to {"const": 1}.

Where to go from here

A good workflow is to write the schema next to a few real documents, including some you expect to fail. Open the JSON Schema validator, put the schema on the left and a sample on the right, then break the sample one field at a time and check that each break produces an error. If the sample itself is messy, tidy it in the JSON formatter first.

For a full worked example, the JSON Schema validation case study builds a schema for an order webhook one failure at a time. If you’re picking a library or CLI, JSON Schema validators compared shows where their defaults differ.

← All posts