JSON Schema is a JSON document that describes the shape of other JSON documents: which fields must exist, what type each one has, and what values are allowed. A validator takes the schema and some data and tells you whether the data fits and, if not, where. It catches the difference between “this is valid JSON” and “this is the JSON my program expects”, which ordinary parsing can’t do.
{"total": "lots"} parses without complaint. Whether total should be a number is a question only a schema can answer.
A worked example
Here is a schema for an order. It uses the current version of the specification, 2020-12:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/order.schema.json",
"title": "Order",
"type": "object",
"properties": {
"id": { "type": "string", "pattern": "^ord_[a-z0-9]{8}$" },
"email": { "type": "string", "format": "email" },
"total": { "type": "number", "minimum": 0 },
"currency": { "enum": ["USD", "EUR", "GBP"] },
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer", "minimum": 1 }
},
"required": ["sku", "qty"],
"additionalProperties": false
}
}
},
"required": ["id", "total", "currency", "items"],
"additionalProperties": false
}
We compiled this with Ajv 8.20 and tested it on this bad record:
{
"id": "ord_1",
"total": -5,
"currency": "JPY",
"items": [{ "sku": "A1", "qty": 1.5 }],
"extra": true
}
With allErrors on, Ajv returned all five problems:
(root) additionalProperties must NOT have additional properties
/id pattern must match pattern "^ord_[a-z0-9]{8}$"
/total minimum must be >= 0
/currency enum must be equal to one of the allowed values
/items/0/qty type must be integer
Those paths are the main practical benefit. “/items/0/qty must be integer” is an error message you can return to an API client unchanged, and you did not write a line of checking code to get it.
Four surprises in that schema
Fields are optional by default. properties only describes a field if it is present. Without a required list, an empty object {} passes. New users often assume the opposite.
Extra fields are allowed by default. Without additionalProperties: false, a typo such as "totl": 5 passes without comment, and the record merely lacks total if that field isn’t required. Decide this on purpose. Being strict catches typos, but it means an old client breaks if a new field is added, which is why many public APIs leave it open for responses and strict for requests.
format usually does nothing. A record with "email": "not-an-email" and otherwise valid fields passed validation (Ajv with its strict mode off). In the 2020-12 specification, format is an annotation by default, and validators are not required to enforce it. Ajv also needs the separate ajv-formats package. If you need it enforced, install that, or use pattern.
Integer and number differ. 1.5 fails integer. JSON itself has only one number type, so this is a rule the schema adds.
Rules that depend on other fields
A schema can express conditions, such as “card payments need a card number, invoices need a PO number”:
{
"type": "object",
"properties": {
"method": { "enum": ["card", "invoice"] },
"card_number": { "type": "string" },
"po_number": { "type": "string" }
},
"required": ["method"],
"if": { "properties": { "method": { "const": "card" } } },
"then": { "required": ["card_number"] },
"else": { "required": ["po_number"] }
}
We tested this too. {"method":"card"} fails with “must have required property ‘card_number’”, and {"method":"invoice"} fails on po_number. For simpler cases, dependentRequired says “if this field is present, that one must be too”. Reusable pieces go under $defs, and you point to them with $ref.
Which draft to use
The official site lists these published versions:
| Version | Worth knowing |
|---|---|
| Draft-04 | Still common in old tooling. id lacks the $ prefix |
| Draft-06, Draft-07 | Added const, if/then/else. Draft-07 remains widely supported |
| 2019-09 | Renamed definitions to $defs, split dependencies into dependentRequired and dependentSchemas |
| 2020-12 | Current. Array-form items became prefixItems |
Use 2020-12 for new work unless a tool in your chain supports only an older version. Always set $schema, because that’s how a validator knows which rules to apply. In Ajv the import differs too: the default build targets Draft-07, and 2020-12 needs ajv/dist/2020. An older schema that used array-form items for tuples means something different under 2020-12, so test when you upgrade.
What it cannot do
JSON Schema checks one document against fixed rules. It cannot:
- Check whether a customer ID exists in your database.
- Compare two fields numerically, such as
end_dateafterstart_date, using standard keywords. - Validate the sum of line items against a total.
- Express business logic without becoming unreadable. Long nested
ifblocks are a signal to move that rule into code.
Treat it as the first gate. It rejects malformed input cheaply, and your code handles the rules that need context.
When it is worth using
Worth it:
- API boundaries. Request validation with useful errors comes free, and OpenAPI 3.1 uses JSON Schema for its data models, so one definition can drive docs and validation.
- Configuration files. Editors such as VS Code use a schema to give autocomplete and inline errors in JSON files, and in YAML files with an extension. This also catches the misplaced key problem described in our YAML guide.
- Data shared between teams or languages. The schema is a contract that no single codebase owns, and code generators can produce types from it.
- Pipelines ingesting third party data. You find out a vendor changed their format at the door, not three steps later as a corrupted report.
Not worth it:
- A one-off script with data you control.
- Internal functions already covered by a type system, where TypeScript or Python type checks do the job at no runtime cost.
- A prototype whose shape changes daily, where the schema would just be one more thing to edit.
A question to ask: if bad data got through, how far would it travel, and how much would it cost to find out? If it would fail immediately and visibly, you can skip the schema.
Generating a first draft
Writing a schema by hand from a blank page is tedious, so start from an example. Our JSON to JSON Schema generator infers a 2020-12 schema from a sample document. Treat the output as a draft that you must tighten, because a sample can only show so much:
- Fields present in the sample are marked
required, including those that are optional in practice. Give it an array of several real records rather than one, since it merges them. We ran[{"id":1,"email":"a@b"},{"id":"2"}]through it. It gaveidthe type["string", "integer"]and leftemailout ofrequired, which is an accurate reading of that data, and shows that your source disagrees with itself. - A null in the sample yields
"type": "null", which then rejects real values. Add the real type next to it. 1comes out asintegerand1.5asnumber. If a price happens to be whole in your sample, the schema will reject19.99.
It adds no patterns, ranges, enums or additionalProperties, because a sample can’t reveal those rules. They are yours to add.
Our JSON validator checks syntax only and does not take a schema, so to run validation use Ajv in JavaScript, jsonschema in Python, or the equivalent in your language. If the target is TypeScript types rather than runtime checks, JSON to TypeScript produces interfaces from a sample instead.