JSON Schema describes what valid JSON looks like: which fields exist, their types, which are required and what values are allowed. Validators then check data against it, so bad requests are rejected with a clear message instead of failing somewhere deep in your code. The same schema documents your API and can generate types and forms.
This guide uses the current version, draft 2020-12, which OpenAPI 3.1 also uses.
A first schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1 },
"age": { "type": "integer", "minimum": 0 }
},
"required": ["name"],
"additionalProperties": false
} {"name": "Ada", "age": 36} is valid. {"age": -1} fails twice (no name, negative age), and {"name": "Ada", "nickname": "A"} fails because additionalProperties: false forbids unknown fields.
Generate a starting schema from a sample with the JSON Schema generator, then tighten it by hand.
Strings
{
"username": { "type": "string", "minLength": 3, "maxLength": 20, "pattern": "^[a-zA-Z0-9_]+$" },
"email": { "type": "string", "format": "email" },
"status": { "enum": ["draft", "active", "archived"] }
} One surprise: in 2020-12, format is an annotation by default. Many validators only enforce it when you switch format checking on (for example the ajv-formats plugin in Ajv). Use pattern when the check must always run.
Numbers
{
"price": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01 },
"quantity": { "type": "integer", "minimum": 1, "maximum": 100 }
} integer rejects 1.5; number accepts both. Note that multipleOf: 0.01 can misjudge some decimals in validators that use floating point, so prices are often sent as integer cents instead.
Arrays
{
"tags": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 5, "uniqueItems": true },
"point": { "type": "array", "prefixItems": [{ "type": "number" }, { "type": "number" }], "items": false }
} items applies to every element. For tuples (fixed positions), 2020-12 uses prefixItems; "items": false then forbids extra elements.
Conditional rules
{
"if": { "properties": { "country": { "const": "US" } }, "required": ["country"] },
"then": { "properties": { "postcode": { "pattern": "^[0-9]{5}$" } } },
"else": { "properties": { "postcode": { "type": "string" } } }
} Put required inside if; otherwise the condition is true whenever country is missing, which is rarely what you want.
Reusing definitions with $defs and $ref
{
"$defs": {
"address": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
},
"properties": {
"billing": { "$ref": "#/$defs/address" },
"shipping": { "$ref": "#/$defs/address" }
}
} Combine schemas with allOf (must match all), anyOf (at least one) and oneOf (exactly one). oneOf errors are hard to read, so prefer anyOf unless exclusivity matters.
Practical tips
- Add
titleanddescriptionto fields: they become documentation and better error messages. - Decide deliberately about
additionalProperties:falsecatches typos, but breaks older clients when you add fields. - Give each schema an
$idand version it like code. - Test the schema with valid and invalid examples; the validator shows the path of every error.
Try it: JSON Schema Generator & Validator · JSON Validator. Free, no sign-up.