Writing a JSON Schema
A JSON Schema is itself a JSON document that describes the shape another document must have. You build one by declaring a type, listing the properties, marking which are required, and then tightening each property until invalid data cannot pass.
Most schemas are written after something has already gone wrong — a field arrived as a string when the code expected a number, or an optional key turned out not to be optional. The useful mental model is that a schema is a contract you can execute, and the reason to write one is that the alternative is discovering the contract from a stack trace in production.
A first schema
Start with the document you actually have. Declare its type, name the properties, and say which of them must be present. Everything after this is narrowing.
{
"id": 4821,
"email": "ada@example.com",
"active": true,
"tags": ["admin", "billing"]
}{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": { "type": "integer" },
"email": { "type": "string", "format": "email" },
"active": { "type": "boolean" },
"tags": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["id", "email"],
"additionalProperties": false
}Narrowing the values
A type alone rarely expresses the real rule. These are the keywords that carry most of the weight in practice, grouped by what they apply to.
| Applies to | Keyword | What it constrains |
|---|---|---|
| string | minLength / maxLength | Character count |
| string | pattern | A regular expression the value must match |
| string | format | A named shape: email, uri, date-time, uuid, ipv4 |
| string | enum | One of a fixed list of permitted values |
| number | minimum / maximum | Inclusive bounds |
| number | exclusiveMinimum | A bound the value must exceed |
| number | multipleOf | Divisibility, useful for currency steps |
| array | minItems / maxItems | Length |
| array | uniqueItems | No duplicates |
| object | required | Which properties must be present |
| object | additionalProperties | Whether unlisted keys are allowed |
| any | const | Exactly one permitted value |
One caution about format: by default it is an annotation rather than an assertion, and several validators do not enforce it unless you switch it on. If a malformed address is passing your email check, that is usually why rather than a bug in the regular expression you did not write.
Nesting and arrays of objects
Schemas compose by substitution: anywhere a schema is expected, a whole schema can appear. An array of objects is simply an array whose items is an object schema.
{
"type": "object",
"properties": {
"order": {
"type": "object",
"properties": {
"reference": { "type": "string", "pattern": "^ORD-[0-9]{6}$" },
"lines": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 }
},
"required": ["sku", "quantity"]
}
}
},
"required": ["reference", "lines"]
}
},
"required": ["order"]
}Reuse with $defs and $ref
The same shape usually appears more than once — an address on a customer and on a delivery, a money amount on every line. Define it once under $defs and point at it. This is not only shorter; it means a change to the rule happens in one place, which is the entire reason schemas beat hand-written checks.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"billing": { "$ref": "#/$defs/address" },
"delivery": { "$ref": "#/$defs/address" }
},
"$defs": {
"address": {
"type": "object",
"properties": {
"line1": { "type": "string" },
"postcode": { "type": "string" },
"country": { "type": "string", "minLength": 2, "maxLength": 2 }
},
"required": ["line1", "postcode", "country"]
}
}
}Conditional rules
Real contracts have branches: a card payment needs a card number, a transfer needs an account. Three combinators cover almost everything — oneOf for mutually exclusive shapes, anyOf where overlap is acceptable, and if/then when one field decides the rules for another.
{
"type": "object",
"properties": {
"method": { "enum": ["card", "transfer"] },
"cardNumber": { "type": "string" },
"iban": { "type": "string" }
},
"required": ["method"],
"if": { "properties": { "method": { "const": "card" } } },
"then": { "required": ["cardNumber"] },
"else": { "required": ["iban"] }
}The draft version is not a detail
JSON Schema has several published drafts, and they are not interchangeable. Declaring the wrong one in $schema does not usually produce an error — it produces a validation run against the wrong metaschema, which reports success while enforcing far less than you wrote. That is worse than a crash, because nothing tells you it happened.
- draft-07 is still the most widely supported and uses
definitions,idsemantics that differ from later drafts, and no$defs. - 2019-09 introduced
$defsanddependentRequired, and split some keywords into vocabularies. - 2020-12 changed array handling:
itemsnow applies to every element andprefixItemshandles the fixed-position tuple form. - Always set
$schemaexplicitly. A schema without it is interpreted at the validator’s discretion, which varies by library and by version.
The array change is the one that bites hardest on upgrade. A draft-07 schema using an array-valued items to describe a tuple means something different under 2020-12, and the document that used to fail now passes.
Validate a document against a schemaDraft-06 through 2020-12, entirely in your browser.
Questions
How do I make a field optional in JSON Schema?
Leave it out of the required array. Every property is optional by default, which is the opposite of what most people expect and the reason a schema that looks strict often accepts an empty object.
What is the difference between $defs and definitions?
They are the same idea under different names. definitions is the draft-07 spelling and $defs replaced it from 2019-09 onward. Use whichever matches the draft you declared in $schema, and do not mix them.
Why does my schema accept a document with a misspelled key?
Because unlisted properties are permitted unless you forbid them. Add additionalProperties: false to the object, and the typo becomes an error instead of an ignored field.
Which draft should I use for a new schema?
2020-12 unless a tool in your pipeline requires otherwise, in which case draft-07 remains the safest widely supported choice. What matters more than the choice is declaring it: a schema without $schema is validated against whatever the library defaults to.