{}JSONLint.app

JSON Schema Validation in Practice: Shape, Not Syntax

9 min read
Share:

A payload can parse cleanly as JSON and still be completely wrong for your code: a missing field, a number sent as a string, a typo'd key that your handler silently ignores. JSON Schema is the contract that turns "is this valid JSON?" into "is this the shape my code expects?". This guide builds a real schema from scratch, runs broken payloads through it, and shows working validators in three languages.

The payload that looks fine but isn't

Here is an order object. It parses. Every brace and quote is in the right place.

{
  "id": "ord_1042",
  "customer": { "name": "Dana Lee", "emial": "dana@example.com" },
  "items": [
    { "sku": "TS-001", "quantity": "2", "price": 19.99 }
  ],
  "totl": 39.98
}

A JSON parser is delighted with this. Your code, however, will break in three ways: the customer key is emial (typo) so email is missing, quantity is the string "2" instead of an integer, and the order total is totl instead of total. None of these are syntax errors. They are shape errors, and only a schema catches them before they reach production.

Building the schema, one keyword at a time

Start with the absolute minimum — assert the top-level type.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object"
}

This accepts any object, including {}. Next, declare what must be present with required, and describe each field under properties.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["id", "customer", "items", "total"],
  "properties": {
    "id": { "type": "string" },
    "customer": { "type": "object" },
    "items": { "type": "array" },
    "total": { "type": "number" }
  }
}

Now the schema knows total is required — so the totl typo will surface as a missing required field. But there's a trap: by default the typo'd totl key is also allowed to sit there silently, because additional properties default to permitted.

Lock the door: additionalProperties: false

This is the single highest-value keyword for catching real bugs. JSON Schema defaults additionalProperties to true, meaning any extra key you didn't define is accepted without complaint. That default is why typos slip through: totl is just "an extra property," and the schema shrugs. Set it to false and every undeclared key becomes an error.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["id", "customer", "items", "total"],
  "additionalProperties": false,
  "properties": {
    "id": { "type": "string" },
    "customer": { "type": "object" },
    "items": { "type": "array" },
    "total": { "type": "number" }
  }
}

With this one line, totl no longer hides as "extra" — it's flagged, and total is flagged as missing. Two problems, one keyword.

Describe nested objects and arrays

customer and items are still loosely typed. Tighten them. For the nested customer object, add its own properties, required, and additionalProperties: false. For items, use items (the array keyword) to describe each element.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["id", "customer", "items", "total"],
  "additionalProperties": false,
  "properties": {
    "id": { "type": "string", "pattern": "^ord_[0-9]+$" },
    "customer": {
      "type": "object",
      "required": ["name", "email"],
      "additionalProperties": false,
      "properties": {
        "name": { "type": "string", "minLength": 1 },
        "email": { "type": "string", "format": "email" }
      }
    },
    "items": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["sku", "quantity", "price"],
        "additionalProperties": false,
        "properties": {
          "sku": { "type": "string" },
          "quantity": { "type": "integer", "minimum": 1 },
          "price": { "type": "number", "minimum": 0 }
        }
      }
    },
    "total": { "type": "number", "minimum": 0 }
  }
}

Several keywords are now pulling their weight:

  • pattern uses a regex (^ord_[0-9]+$) so an id like "customer-7" is rejected.
  • format with "email" validates the address shape (more on its caveats below).
  • integer vs numberquantity is integer, so "2" (a string) and 2.5 both fail; price is number, so decimals pass.
  • minimum and minItems enforce sane bounds: no negative prices, at least one line item.

Constrained values with enum

Suppose orders carry a status from a fixed set. enum restricts a value to an explicit list:

"status": {
  "type": "string",
  "enum": ["pending", "paid", "shipped", "cancelled"]
}

Anything outside the list fails, and the error names the allowed values — far better than a free-text string that lets "shippd" through.

Dates and reuse with format, $ref, and $defs

For a created_at timestamp, use "format": "date-time" (an RFC 3339 / ISO 8601 instant). When the same shape repeats — say a money amount appears in both line items and totals — define it once in $defs and reference it with $ref:

{
  "$defs": {
    "money": { "type": "number", "minimum": 0, "multipleOf": 0.01 }
  },
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "created_at": { "type": "string", "format": "date-time" }
  }
}

$ref keeps the schema DRY: change money once and every reference updates. multipleOf: 0.01 even enforces two-decimal currency precision.

Walkthrough: running the broken payload

Feed the original broken order through the full schema. A good validator collects all errors, not just the first. The output reads roughly like this:

/customer       -> required property 'email' is missing
/customer        -> additional property 'emial' is not allowed
/items/0/quantity -> "2" is not of type "integer"
/                -> required property 'total' is missing
/                -> additional property 'totl' is not allowed

Every failure maps to a concrete fix. The corrected payload:

{
  "id": "ord_1042",
  "customer": { "name": "Dana Lee", "email": "dana@example.com" },
  "items": [
    { "sku": "TS-001", "quantity": 2, "price": 19.99 }
  ],
  "total": 39.98
}

Before: typo'd emial, string "2", typo'd totl. After: correct email, integer 2, correct total. The schema turned three silent bugs into five precise, line-addressed error messages.

Validators with real code

JavaScript — Ajv

Ajv is the de facto JSON Schema validator for Node and the browser. Use allErrors: true to collect every problem in one pass, and add ajv-formats because Ajv does not ship format validators by default.

import Ajv from "ajv";
import addFormats from "ajv-formats";

const ajv = new Ajv({ allErrors: true });
addFormats(ajv); // enables "email", "date-time", etc.

const validate = ajv.compile(schema);
const ok = validate(payload);

if (!ok) {
  for (const e of validate.errors) {
    console.log(`${e.instancePath || "/"} ${e.message}`);
  }
}

Two things to know. First, ajv.compile precompiles the schema to a fast function — compile once, validate many. Second, Ajv runs in strict mode by default and will throw if your schema has unknown keywords or ambiguous constructs; that strictness catches schema mistakes early, but you can relax it with new Ajv({ strict: false }) if you're consuming third-party schemas.

Python — jsonschema

The jsonschema library lets you pick a draft explicitly. iter_errors yields every violation, and each error exposes a human message plus a JSON path to the offending value.

from jsonschema import Draft202012Validator

validator = Draft202012Validator(schema)

errors = sorted(validator.iter_errors(payload), key=lambda e: e.json_path)
for error in errors:
    print(f"{error.json_path}: {error.message}")

Output:

$.customer: 'email' is a required property
$.customer: Additional properties are not allowed ('emial' was unexpected)
$.items[0].quantity: '2' is not of type 'integer'
$: 'total' is a required property

Note: by default jsonschema treats format as an annotation only and does not assert it. To make format actually validate, attach a format checker: Draft202012Validator(schema, format_checker=Draft202012Validator.FORMAT_CHECKER).

Go — santhosh-tekuri/jsonschema

In Go, github.com/santhosh-tekuri/jsonschema/v5 compiles a schema and validates decoded data. The returned error is a tree you can walk or print.

import "github.com/santhosh-tekuri/jsonschema/v5"

compiler := jsonschema.NewCompiler()
schema, err := compiler.Compile("order.schema.json")
if err != nil {
    log.Fatal(err)
}

if err := schema.Validate(data); err != nil {
    if ve, ok := err.(*jsonschema.ValidationError); ok {
        // ve.DetailedOutput() returns a structured tree of causes
        fmt.Println(ve)
    }
}

data here is the result of json.Unmarshal into an interface{}. The ValidationError carries nested Causes, so you can render the full set of failures (missing total, wrong-typed quantity, disallowed emial) rather than just the first.

Gotchas worth memorizing

  • additionalProperties defaults to true. Without false, typo'd and extra keys pass silently. This is the most common reason a schema "doesn't catch the bug." Add it to every object you control.
  • format is annotation-only by default. In Draft 2020-12 (and in jsonschema/Ajv-without-formats), format may be collected as metadata but not enforced. You must opt into format assertion. Don't assume "format": "email" rejects bad emails until you've wired up the checker.
  • integer vs number. integer rejects 2.5 and the string "2"; number accepts decimals. Pick deliberately — quantities are integers, money is a number.
  • Nullable fields use a type array. There is no nullable: true in standard JSON Schema (that's OpenAPI). To allow null, write "type": ["string", "null"].
  • Draft versions differ. Draft 7 used definitions and id; Draft 2020-12 uses $defs and $id, and changes how items/additionalItems work for tuples. Always set $schema so validators agree on the dialect, and don't mix Draft 7 examples into a 2020-12 schema.

Putting schemas to work in CI

A schema is most valuable when it runs automatically. A few patterns:

  • Validate fixtures and API responses in tests. Keep your schema in the repo and assert that recorded responses still match it. When an upstream API drops a field or changes a type, the test fails loudly instead of your parser failing quietly in production.
  • Add a pre-commit hook. Validate config files or seed data against their schemas before each commit, so malformed JSON never lands on the branch.
  • Gate deploys on contract checks. If a service publishes a schema, validate sample payloads against it in the pipeline so both producer and consumer stay in agreement.

You don't have to write the first schema by hand. Generate a starter schema from a representative payload, then tighten it — add required, flip additionalProperties to false, narrow number to integer, and introduce enums where values are fixed. You can do all of this in the browser before wiring it into code: generate a schema from a sample, paste a payload, and read the errors interactively. The tools below cover that whole loop client-side, so your data never leaves the page.

Tools

  • JSON Schema Validator — Paste a schema and a payload to see readable, path-addressed errors in the browser.
  • JSON Schema Generator — Produce a starter schema from a sample payload, then tighten it by hand.
  • JSON Validator — Confirm your payload is syntactically valid JSON before checking its shape against a schema.
  • JSON to TypeScript — Turn the same shape into compile-time types so your editor enforces it too.

Learn More

  • JSON Format Rules — The syntax foundation that schema validation layers structure on top of.
  • JSON vs XML — How JSON Schema compares to XSD and why contracts matter in both formats.
  • Common JSON Mistakes — Many of these typo and type bugs are exactly what a schema catches.
  • Working with JSON in Python — Pair jsonschema validation with idiomatic parsing and serialization in Python.

Related Articles