Skip to content
jsonforge.app
Back to blog
Tutorial10 min read

The Complete JSON Schema Guide (Draft 7)

JSON Schema is a contract for your data: it declares which properties must exist, what types they have, and which values are allowed. This guide covers the core Draft 7 keywords, composition and reuse with $ref, how to run validation in code with Ajv, and the habits that keep a schema describing reality instead of drifting away from it.

What is JSON Schema?

A JSON Schema is itself a JSON document that describes the shape of other JSON data. Use it to validate API payloads, document expected formats, generate code (TypeScript, Go), and catch bad data before it reaches your logic.

It earns its keep at boundaries: the request arriving at your server, the config your service reads at startup, the message pulled off a queue. A schema makes the assumption explicit and executable — either the data conforms, or you get a precise error naming the field that doesn't.

The ecosystem is mature. Ajv validates Draft 7 in JavaScript, every major language has an equivalent, editors like VS Code autocomplete JSON driven by schemas, and API description formats (OpenAPI, AsyncAPI) embed JSON Schema as their payload vocabulary.

Core keywords

The type keyword constrains a value to string, number, boolean, null, object, or array. properties and required describe object shape; items describes array elements.

An object schema with required fields and type constraints.
json
{
  "type": "object",
  "properties": {
    "name": { "type": "string", "minLength": 1 },
    "age": { "type": "number", "minimum": 0, "maximum": 150 },
    "email": { "type": "string", "format": "email" }
  },
  "required": ["name", "email"]
}

Beyond type: enum restricts a value to a fixed set, const requires one exact value, pattern enforces a regex, minLength/maxLength bound string length, and additionalProperties: false forbids unknown keys. Numbers get minimum/maximum (plus their exclusive variants); arrays get items together with minItems, maxItems, and uniqueItems.

Note what required actually means: it controls which keys must be present, not what they contain. A field listed in required but absent from properties is satisfied by any value, including null. If null is invalid, say so explicitly — `{ "type": "string" }` rejects null because null isn't a string, while `{ "type": ["string", "null"] }` allows it. Being deliberate about null-ability is one of the highest-value habits in schema design, because it's exactly the distinction that generated client types and runtime validators disagree on most often.

A real-world API schema

Here's a schema for a user-registration endpoint. Notice how each constraint doubles as documentation, and how required + additionalProperties: false make the contract strict.

User registration schema — strict by default.
json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "username": { "type": "string", "pattern": "^[a-zA-Z0-9_]{3,30}$" },
    "email": { "type": "string", "format": "email" },
    "password": { "type": "string", "minLength": 8 },
    "acceptTerms": { "type": "boolean", "const": true }
  },
  "required": ["username", "email", "password", "acceptTerms"],
  "additionalProperties": false
}

Every line is doing enforcement and documentation at once. The pattern on username rejects spaces and symbols without a separate validation function; minLength: 8 on password is policy-as-code; const: true on acceptTerms means the request fails validation outright if the box wasn't ticked — no branching in the handler. When validation fails, Ajv reports the instance path (something like /username), the keyword that failed, and the expected value, which is usually specific enough to return to the caller as-is.

Arrays, enums, and combining schemas

Arrays are constrained through items, which is itself a schema applied to every element. A list of invite IDs is `{ "type": "array", "items": { "type": "string", "format": "uuid" }, "minItems": 1, "uniqueItems": true }` — and uniqueItems gets you set semantics for free, no hand-rolled dedupe check after parsing. For a fixed set of allowed values, enum replaces scattered if/else checks: `{ "enum": ["pending", "active", "closed"] }` on a status field.

Composition keywords combine schemas: allOf requires every sub-schema to match (intersection), anyOf requires at least one (union), oneOf requires exactly one (XOR), and not inverts. The workhorse is anyOf for optional nulls and variant shapes — a webhook payload whose "payload" field is either an invoice object or a refund object is two schemas under anyOf. oneOf looks similar but fails when a value matches both branches, so reserve it for cases that are genuinely mutually exclusive or you'll spend an afternoon wondering why a valid document rejects.

Reusing schemas with $ref and definitions

Real APIs repeat themselves: the same user object appears in ten responses, the same address object inside the user. $ref lets you define each shape once and reference it everywhere. Park shared shapes under "definitions" (Draft 7's keyword; the 2019-09 revision renamed it to $defs) and point at them with `{ "$ref": "#/definitions/address" }` — the fragment is a JSON Pointer into the current document.

References keep large schemas navigable and keep constraints in one place: fix the email pattern once, and every endpoint referencing that shape inherits the fix. The trade-off is that heavily-woven $ref graphs are slower to read than inline definitions and add pointer-resolution work for the validator, so most teams factor only the shapes that genuinely repeat and leave one-off constraints inline.

Running validation in code with Ajv

In JavaScript runtimes, Ajv is the standard validator: it compiles schemas into functions at startup, which makes validation fast enough to run on every request with no measurable overhead.

Compile once at startup, validate per request, map errors at the edge.
typescript
import Ajv from "ajv";

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(userRegistrationSchema);

app.post("/register", (req, res) => {
  if (!validate(req.body)) {
    res.status(400).json({
      error: "VALIDATION_ERROR",
      details: validate.errors?.map((e) => ({
        field: e.instancePath.slice(1) || e.params?.missingProperty,
        message: e.message,
      })),
    });
    return;
  }
  // req.body now safely matches the registration contract
});

Compile once and reuse — compiling per request is the classic performance mistake. Turn on allErrors during development to see every failure at once instead of only the first, and translate Ajv's error array (instancePath, keyword, message) into your API's error format at the edge rather than leaking validator internals to clients.

Best practices

Always declare $schema so validators know which draft you target — Draft 7, 2019-09, and 2020-12 are not interchangeable, and a validator guessing wrong produces confusing failures around $ref and unevaluatedProperties. Add description and examples fields; they cost nothing and become live documentation in editors and generated docs. Prefer format (email, uri, date) over hand-rolled regex, but verify your validator actually enforces it — in Draft 7, format is an annotation by default and Ajv needs the ajv-formats package plus an explicit option to assert it.

Use additionalProperties: false for strict APIs where unknown keys should fail, and leave it off where extensibility matters. Avoid schemas nested so deep they're unreadable — factor with $ref and definitions. And treat the schema like code: review it, version it with the API, and run it against a corpus of real payloads in CI, so drift between the schema and the actual data is caught by a test instead of an incident.

FAQ

Which JSON Schema draft does this guide cover?
Draft 7, the most widely supported version. It covers type, properties, required, items, enum, const, minimum/maximum, minLength/maxLength, pattern, format, additionalProperties, minItems/maxItems, and uniqueItems.
Does Ajv validate format like email or uri out of the box?
Not by default. In Draft 7, format is defined as an annotation, not an assertion — Ajv only notes that a format exists unless you enable enforcement with the ajv-formats package and the right options. If you rely on format for rejection, test that a malformed value actually fails; if it passes, your validator is annotating, not asserting.
Does TypeScript replace JSON Schema?
No. TypeScript types are erased at compile time and provide no runtime validation. For API payloads, use JSON Schema (or Zod) to validate at runtime, and optionally generate TypeScript types from it.
Can I auto-generate a schema from JSON?
Yes — infer a schema from sample data, then refine it by adding constraints (required, pattern, length limits). Generation gives you a starting point, not a finished contract.
Should a new project use Draft 7 or a 2020-12 schema?
Draft 7 remains the safest interoperability choice: it's what most validators, documentation, and examples target. The 2019-09 and 2020-12 revisions restructured $ref, moved definitions to $defs, and added vocabulary support, which some tooling still handles inconsistently. Pick whichever draft your validator supports best, declare it in $schema, and stay consistent across the project.

Try these tools

Related articles