Skip to content

JSON Schema for Beginners

A practical walkthrough of JSON Schema — types, required, constraints, refs, oneOf — with copy-pasteable examples and tool recommendations.

JSON itself has no schema. Two documents can both be valid JSON and have completely different shapes — that flexibility is the format's strength and its biggest production liability. JSON Schema is the standard for describing the shape of a JSON document so you can validate it mechanically, generate types from it, and document it for consumers.

This guide is the practical introduction: enough to write your first schema, validate against it, and understand the four or five operators that cover 95% of real-world use.

What JSON Schema is

A JSON Schema is itself a JSON document. It describes what a valid JSON document looks like — required fields, allowed types, value ranges, shapes of nested objects.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "id": { "type": "string" },
    "email": { "type": "string", "format": "email" }
  },
  "required": ["id", "email"]
}

A validator is a library that takes a schema and a document, and tells you whether the document matches and, if not, why. The two mainstream JavaScript validators are ajv (fast, draft-spec-compliant) and zod (TypeScript-native but has its own schema language; you can convert between zod and JSON Schema with zod-to-json-schema).

Your first schema

The minimum a useful schema needs is type, properties, and required:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "age": { "type": "integer" }
  },
  "required": ["name"]
}

Validating with ajv:

import Ajv from "ajv";
const ajv = new Ajv();
const validate = ajv.compile(schema);

console.log(validate({ name: "Ada", age: 36 })); // true
console.log(validate({ age: 36 })); // false
console.log(validate.errors); // [{ instancePath: "", message: "must have required property 'name'" }]

The seven core types are string, number, integer, boolean, object, array, null. type can be a single string or an array of strings to allow multiple types.

String and number constraints

For strings:

  • minLength, maxLength — character counts (code units, not graphemes).
  • pattern — a regular expression the string must match.
  • format — a named format like email, uri, date-time, uuid. Format checks are off by default in ajv; enable them with ajv-formats.
{
  "type": "string",
  "minLength": 8,
  "maxLength": 64,
  "pattern": "^[A-Za-z0-9_]+$"
}

For numbers:

  • minimum, maximum — inclusive bounds.
  • exclusiveMinimum, exclusiveMaximum — exclusive bounds.
  • multipleOf — value must be a multiple of this number.
{
  "type": "number",
  "minimum": 0,
  "exclusiveMaximum": 100,
  "multipleOf": 0.01
}

Arrays and nested objects

Use items to describe array elements, and nest schemas freely:

{
  "type": "object",
  "properties": {
    "tags": {
      "type": "array",
      "items": { "type": "string" },
      "minItems": 1,
      "uniqueItems": true
    },
    "address": {
      "type": "object",
      "properties": {
        "street": { "type": "string" },
        "city": { "type": "string" }
      },
      "required": ["street", "city"]
    }
  }
}

For repeated subschemas, use $defs (formerly definitions) and $ref:

{
  "$defs": {
    "address": {
      "type": "object",
      "properties": {
        "street": { "type": "string" },
        "city": { "type": "string" }
      },
      "required": ["street", "city"]
    }
  },
  "type": "object",
  "properties": {
    "billing": { "$ref": "#/$defs/address" },
    "shipping": { "$ref": "#/$defs/address" }
  }
}

enum, const, and the choice operators

For closed sets of values, use enum or const:

{
  "type": "string",
  "enum": ["pending", "active", "archived"]
}

For "one of these shapes" — discriminated unions — use oneOf or anyOf:

{
  "oneOf": [
    {
      "type": "object",
      "properties": { "kind": { "const": "email" }, "address": { "type": "string", "format": "email" } },
      "required": ["kind", "address"]
    },
    {
      "type": "object",
      "properties": { "kind": { "const": "phone" }, "number": { "type": "string" } },
      "required": ["kind", "number"]
    }
  ]
}

oneOf requires exactly one schema to match; anyOf requires at least one. Prefer oneOf with a discriminator field — the error messages are much better.

additionalProperties and the strictness dial

By default, JSON Schema allows extra properties that are not listed in properties. This is usually wrong for API request bodies — a typo in a field name should be a 400, not a silently-ignored field.

Set additionalProperties: false to make the schema strict:

{
  "type": "object",
  "properties": { "name": { "type": "string" } },
  "required": ["name"],
  "additionalProperties": false
}

For payloads you control on both sides, strict is the right default. For public APIs where you want to evolve without breaking old clients, default to permissive and forbid additional properties on a per-version basis.

Validating in practice

The two-step pipeline for a server endpoint:

  1. JSON.parse the request body.
  2. Validate the parsed value against the schema.
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);

app.post("/users", (req, res) => {
  if (!validate(req.body)) {
    return res.status(400).json({ errors: validate.errors });
  }
  // req.body is now known to match the schema
});

For client-side validation and ad hoc checks, the JSON Validator tool accepts a schema and document and shows you exactly which assertion failed.

Where to go next