jsonpost
JSON SchemaValidationAPI

JSON Schema Deep Dive: Validation Beyond the Basics

A practical tour of JSON Schema validation covering types, required fields, formats, numeric and array constraints, composition keywords, $ref reuse, and drafts.

JSONPost··6 min read
JSON Schema Deep Dive

Most developers meet JSON Schema as a quick way to say "this field is a string and that one is required." That covers maybe ten percent of what the language can do. Once your API contracts grow, you need conditional shapes, reusable definitions, precise numeric bounds, and array rules that actually catch bad data before it reaches your database. This post walks through the validation keywords that matter in production, with schemas you can paste into the JSON Schema Validator to see exactly what passes and what fails.

What a schema actually is

A JSON Schema is itself a JSON document. It describes a set of constraints, and a validator checks whether some instance document satisfies all of them. The most important fact about validation: keywords that don't apply to a given value are simply ignored. A minimum constraint does nothing to a string, and a required list does nothing to an array. This "constraints only restrict, never assert type by themselves" model trips up newcomers constantly.

Here is a minimal schema describing a user object.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["id", "email"],
  "properties": {
    "id": { "type": "integer" },
    "email": { "type": "string", "format": "email" },
    "name": { "type": "string" }
  }
}

Types and required

The type keyword accepts string, number, integer, boolean, object, array, and null. It can also be an array of types when a value may be one of several.

{
  "type": ["string", "null"]
}

The required keyword lists property names that must be present. A subtle point: required only checks presence, not value. A property set to null still counts as present. If you want to forbid null, constrain the type too.

This instance passes the user schema above:

{ "id": 42, "email": "ada@example.com" }

This one fails twice: email is missing, and id is a string, not an integer.

{ "id": "42", "name": "Ada" }

A good validator reports both errors with paths like /id must be integer and instance requires property "email".

String formats

The format keyword annotates strings with a semantic meaning. The commonly supported values include email, uri, date-time, date, time, uuid, hostname, and ipv4.

{
  "type": "object",
  "properties": {
    "homepage": { "type": "string", "format": "uri" },
    "createdAt": { "type": "string", "format": "date-time" }
  }
}

A value like 2026-04-02T09:30:00Z satisfies date-time, while April 2nd does not. One caveat worth knowing: in draft-07 format is assertive by default in many validators, but in later drafts it is annotation-only unless you opt in. If format enforcement matters to you, confirm your validator treats it as a hard check.

Numeric constraints

Numbers support minimum, maximum, exclusiveMinimum, exclusiveMaximum, and multipleOf. Use them to express real business rules instead of validating ranges in application code.

{
  "type": "object",
  "properties": {
    "age": { "type": "integer", "minimum": 0, "maximum": 120 },
    "discount": {
      "type": "number",
      "minimum": 0,
      "exclusiveMaximum": 1,
      "multipleOf": 0.05
    }
  }
}

Here age of 130 fails the maximum, and a discount of 0.07 fails multipleOf because it is not a clean multiple of 0.05. A discount of 0.15 passes.

Array constraints

Arrays are where schemas earn their keep. The core keywords are items, minItems, maxItems, and uniqueItems.

{
  "type": "object",
  "properties": {
    "tags": {
      "type": "array",
      "items": { "type": "string" },
      "minItems": 1,
      "maxItems": 10,
      "uniqueItems": true
    }
  }
}

This passes:

{ "tags": ["json", "schema", "api"] }

This fails on uniqueItems because json repeats, and a value with zero entries fails minItems.

{ "tags": ["json", "json"] }

The items keyword applies the same subschema to every element. In draft 2020-12, positional tuple validation moved to a separate prefixItems keyword, which is a breaking rename from older drafts where items accepted an array.

Composition keywords

Composition is what makes JSON Schema expressive. Four keywords combine subschemas.

allOf requires every subschema to match. It is the closest thing to "extends" and is handy for layering a base shape with extra fields.

anyOf requires at least one subschema to match. Use it when several shapes are acceptable.

oneOf requires exactly one subschema to match, which is stricter than anyOf and ideal for discriminated unions.

not requires the subschema to fail.

{
  "type": "object",
  "properties": {
    "payment": {
      "oneOf": [
        {
          "type": "object",
          "required": ["card"],
          "properties": { "card": { "type": "string" } }
        },
        {
          "type": "object",
          "required": ["paypal"],
          "properties": { "paypal": { "type": "string" } }
        }
      ]
    }
  }
}

A payment object with only card passes. An object with both card and paypal fails oneOf because two subschemas match. That is a feature: it forces callers to pick exactly one method.

The not keyword is great for blocklists. This forbids the literal string admin:

{
  "type": "string",
  "not": { "const": "admin" }
}

Reuse with $ref

Real schemas repeat structures: addresses, money amounts, pagination wrappers. The $ref keyword lets you define a shape once and reference it anywhere. Definitions live under $defs in modern drafts, or definitions in draft-07.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "address": {
      "type": "object",
      "required": ["city", "country"],
      "properties": {
        "city": { "type": "string" },
        "country": { "type": "string" }
      }
    }
  },
  "type": "object",
  "properties": {
    "billing": { "$ref": "#/definitions/address" },
    "shipping": { "$ref": "#/definitions/address" }
  }
}

Both billing and shipping now share one definition. Change the address rules once and every reference updates. References can also point at external files or URLs, which is how large API specs stay maintainable.

draft-07 versus 2020-12

The two drafts you will encounter most are draft-07 and 2020-12. The differences that bite in practice:

draft-07                    2020-12
--------                    -------
definitions                 $defs
items (array form)          prefixItems
format mostly assertive     format annotation-only by default
no $dynamicRef              $dynamicRef / $dynamicAnchor

Tooling matters here. OpenAPI 3.1 aligned with 2020-12, while many older code generators still assume draft-07. Always declare your draft with $schema so validators apply the right rules rather than guessing.

Generating a starting schema

Writing schemas from scratch is tedious. Paste a representative document into the JSON Schema Generator and it infers types, properties, and required fields for your chosen draft. Treat the output as a first draft: tighten the formats, add numeric bounds, and decide which fields are truly required. Then run real payloads through the JSON Schema Validator to confirm the contract holds.

Conclusion

JSON Schema is far more than type tags. With formats, numeric and array constraints, composition, and $ref reuse, you can encode genuine business rules into a contract that runs in CI and catches malformed data before it spreads. Start by generating a schema from a real response, refine it, then validate every payload against it. Try the JSON Schema Generator to bootstrap your first contract today, and explore the rest of the JSONPost tools for diffing, mocking, and transforming JSON.

Keep reading