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.
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
Validating JSON with JSON Schema
Learn how JSON Schema works, how to generate a schema from sample data, and how to validate documents with clear, path-based error messages.
JSON vs JSON5 vs JSONC — What's the Difference?
Comments, trailing commas, and unquoted keys — understand JSON5 and JSONC, where each is used, and how to convert them to strict JSON.
How to Format JSON (and Why It Matters)
A practical guide to beautifying, indenting, and minifying JSON — and when to use each, with tips for debugging messy API responses.