JSON Schema — check your data before it breaks something
Write a schema that catches bad data, with the keywords that matter and the traps that waste a day.
A type in your program checks your own code. A JSON Schema checks the data that arrives from outside it. That is a different job, and it is the one that saves you at three in the morning.
The smallest useful schema
{
"type": "object",
"required": ["id", "email"],
"properties": {
"id": { "type": "integer" },
"email": { "type": "string" }
}
}
This says: the value is an object, it must have id and email, id is a
whole number and email is text. Any other key is allowed, because JSON
Schema allows what it does not forbid.
That last sentence is the one people learn the hard way. A schema permits by
default. If you write only {"type": "object"}, every object passes.
The keywords worth knowing
For every value
| Keyword | Meaning |
|---|---|
type |
object, array, string, number, integer, boolean, null |
enum |
One of this list of values |
const |
Exactly this value |
type also takes a list: "type": ["string", "null"] allows either.
For text
{
"type": "string",
"minLength": 1,
"maxLength": 100,
"pattern": "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$"
}
pattern is a regular expression. Remember to escape the backslash, because
the schema is itself JSON.
For numbers
{
"type": "number",
"minimum": 0,
"exclusiveMaximum": 100,
"multipleOf": 0.5
}
For lists
{
"type": "array",
"minItems": 1,
"maxItems": 50,
"uniqueItems": true,
"items": { "type": "string" }
}
items describes every item. prefixItems describes the first few by
position, which is how you type a pair such as [latitude, longitude].
For objects
{
"type": "object",
"required": ["id"],
"properties": { "id": { "type": "integer" } },
"patternProperties": { "^x-": { "type": "string" } },
"additionalProperties": false,
"minProperties": 1
}
"additionalProperties": false is the line that catches typing mistakes.
Without it, {"emial": "..."} passes a schema that requires email as long
as email is not in required. With it, the unknown key is reported.
Be careful: it also refuses a new field that a newer version of the API adds. Use it on data you own. Think twice on data from somebody else.
Combining
{ "anyOf": [ {"type": "string"}, {"type": "number"} ] }
allOf— every one must pass.anyOf— at least one must pass.oneOf— exactly one must pass.not— it must fail.
oneOf is the one people get wrong. If two of your options can both match,
oneOf fails even though the data looks right. Use anyOf unless you really
need "exactly one".
Conditions
{
"if": { "properties": { "kind": { "const": "card" } }, "required": ["kind"] },
"then": { "required": ["cardNumber"] },
"else": { "required": ["accountNumber"] }
}
This is how you say "if it is a card payment, a card number is required".
Reuse
{
"$defs": {
"money": {
"type": "object",
"required": ["amount", "currency"],
"properties": {
"amount": { "type": "number" },
"currency": { "type": "string", "minLength": 3, "maxLength": 3 }
}
}
},
"properties": {
"price": { "$ref": "#/$defs/money" },
"tax": { "$ref": "#/$defs/money" }
}
}
Write a shape once and point at it. $ref with a # is a pointer inside the
same document.
Testing a schema here
Open your data in the viewer, then Tools → Check against a JSON Schema. Paste the schema and press Check.
Every place that does not fit appears in a list with its path and a plain message, and each place is marked in the tree. Click a line and the tree moves to it.
This is the fast way to write a schema: start with {"type": "object"}, add a
rule, press Check, and watch what it catches. A schema written against
real data is right the first time, and a schema written from imagination
usually is not.
The check runs in your browser, so neither the data nor the schema is
uploaded. It does not fetch a $ref that points at another file, for the same
reason, and it says so.
The traps
A schema permits what it does not forbid. Say this to yourself twice.
required is separate from properties. Describing a key does not make
it required, and requiring a key does not describe it. You usually need both.
"type": "integer" means a whole number, so 1.0 passes and 1.5 does
not.
format is usually only a hint. {"format": "email"} is ignored by many
validators unless you turn the check on. Use pattern when it must be
enforced.
A number in a schema is a JavaScript number. An identifier of 20 digits
cannot be checked exactly by minimum or maximum. Treat such an identifier
as text, in your schema and in your data.
Say which draft you use. Put "$schema": "https://json-schema.org/draft/2020-12/schema" at the top. Draft 7 and
2020-12 differ in how lists are described, and a validator that guesses wrong
gives a confusing message.
Where to put the check
- At the edge of your service. Check the body of every request before anything else touches it.
- In your tests. Keep a real answer and check it against the schema, so a change in the API breaks a test and not production.
- In your build. Check your own configuration files. A typing mistake in a configuration file is caught in one second instead of one deploy.
Where to go next
- The specification: json-schema.org
- Validators:
ajvfor JavaScript,jsonschemafor Python,everitfor Java. - If you work in TypeScript, consider
zod, which gives you the check and the type from one description. See JSON to TypeScript.
Try it on your own file. The tool runs in your browser. Nothing is uploaded, and nothing is stored on a server.
Open the JSON ViewerRead next
Why big numbers break in JSON, and what to do
An identifier of 19 digits goes into a parser and comes out changed. Here is why, and the three ways to stop it.
Is it safe to paste JSON into an online viewer?
What really happens to your data in a web tool, how to tell an upload from local work, and when to say no.