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 you allow. This guide covers the core Draft 7 keywords, composition and reuse with $ref, validation in code with Ajv, and the habits that keep a schema in sync with real data.
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 failed.
The ecosystem is mature. Ajv validates Draft 7 in JavaScript, every major language has an equivalent, editors like VS Code use schemas to autocomplete JSON, 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.
{
"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.
required controls which keys must be present, not what they contain. A field listed in required but absent from properties accepts any value, including null. If null is invalid, say so: `{ "type": "string" }` rejects null because null isn't a string, while `{ "type": ["string", "null"] }` allows it. Decide null-ability on purpose, because generated client types and runtime validators disagree on that distinction more than any other.
A real-world API schema
This schema covers a user-registration endpoint. Each constraint doubles as documentation, and required + additionalProperties: false make the contract strict.
{
"$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
}Each line enforces and documents 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 fails the request if the user didn't tick the box, with no branching in the handler. On failure, Ajv reports the instance path (something like /username), the keyword that failed, and the expected value, which you can often return to the caller as-is.
Arrays, enums, and combining schemas
You constrain arrays through items, 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 gives you set semantics with 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. You'll use anyOf most, for optional nulls and variant shapes. A webhook payload whose "payload" field holds 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 mutually exclusive cases or you'll spend an afternoon wondering why a valid document fails.
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 readable and keep constraints in one place: fix the email pattern once, and every endpoint referencing that shape gets the fix. The cost: a dense web of $ref takes longer to read than inline definitions and makes the validator resolve more pointers, so most teams factor out only the shapes that 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, so you can validate every request with no measurable overhead.
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 the first, and translate Ajv's error array (instancePath, keyword, message) into your API's error format at the edge instead of leaking validator internals to clients.
Best practices
Declare $schema so validators know which draft you target. Draft 7, 2019-09, and 2020-12 aren't interchangeable, and a validator that guesses wrong produces confusing failures around $ref and unevaluatedProperties. Add description and examples fields; they cost nothing and give editors and generated docs live documentation. Prefer format (email, uri, date) over hand-rolled regex, but check that your validator 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. Don't nest schemas so deep that nobody can read them; factor with $ref and definitions. Treat the schema like code: review it, version it with the API, and run it against a corpus of real payloads in CI, so a test catches drift between schema and data before an incident does.
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 an annotation, not an assertion. Ajv 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 fails. If it passes, your validator is annotating.
- Does TypeScript replace JSON Schema?
- No. The TypeScript compiler erases types, so they provide no runtime validation. For API payloads, use JSON Schema (or Zod) to validate at runtime, and generate TypeScript types from it if you want them.
- 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 that you still have to finish.
- Should a new project use Draft 7 or a 2020-12 schema?
- Draft 7 remains the safest interoperability choice, since most validators, documentation, and examples target it. The 2019-09 and 2020-12 revisions restructured $ref, moved definitions to $defs, and added vocabulary support, which some tooling still handles inconsistently. Pick the draft your validator supports best, declare it in $schema, and stay consistent across the project.
Try these tools
Related articles
What is JSON? A Complete Beginner's Guide →
JSON (JavaScript Object Notation) is the lightweight data-interchange format behind nearly every API, config file, and NoSQL database. Learn the syntax, the six data types, how parsing behaves in code, and the mistakes everyone makes hand-writing it.
The History of JSON: From 2000 to Industry Standard →
Trace JSON from Douglas Crockford's idea in 2001, through Yahoo's adoption, to becoming the ECMA-404 standard that powers 90% of modern APIs.
JSON Examples in 7 Languages (With Code) →
Production-ready JSON parsing and serialization examples across seven languages, with error handling and best practices.