OnlineTextLab
Esc

What Is JSON Schema? A Worked Example and When It Is Worth Using

Updated 6 min read How we check our guides

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:

VersionWorth knowing
Draft-04Still common in old tooling. id lacks the $ prefix
Draft-06, Draft-07Added const, if/then/else. Draft-07 remains widely supported
2019-09Renamed definitions to $defs, split dependencies into dependentRequired and dependentSchemas
2020-12Current. 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_date after start_date, using standard keywords.
  • Validate the sum of line items against a total.
  • Express business logic without becoming unreadable. Long nested if blocks 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 gave id the type ["string", "integer"] and left email out of required, 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.
  • 1 comes out as integer and 1.5 as number. If a price happens to be whole in your sample, the schema will reject 19.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.