JSON Schema: From Basics to Production
Bottom line: There are many keywords, but only a dozen are used every day. Get type/properties/required/$ref right, handle error messages well, and you cover eighty percent of real cases.

Bottom line: pin $schema to 2020-12 first, then extract shared structures with $defs + $ref; use oneOf only when exactly one branch must match, and prefer discriminators or anyOf everywhere else.
A schema is not an appendix to the documentation; it is an asset that can be executed, tested, and generated.
1. Get the version straight first
| Version | $schema value |
Status | Notes |
|---|---|---|---|
| draft-04 | http://json-schema.org/draft-04/schema# |
Outdated | Legacy projects and some OpenAPI 2.0 tooling |
| draft-07 | http://json-schema.org/draft-07/schema# |
Widely used | The de facto basis of OpenAPI 3.0 |
| 2019-09 | https://json-schema.org/draft/2019-09/schema |
Available | Introduced $defs and the annotation vocabulary |
| 2020-12 | https://json-schema.org/draft/2020-12/schema |
Currently recommended | Introduced prefixItems, changed the semantics of items |
Use 2020-12 for new projects. Note that in 2020-12 items only constrains the "remaining elements" — tuple-style declarations moved to prefixItems.
That is the most common trap when migrating from draft-07.
2. A minimal usable schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/user.json",
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1, "maxLength": 64 },
"age": { "type": "integer", "minimum": 0, "maximum": 150 },
"tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
},
"additionalProperties": false
}
This schema covers the four most common concerns: type constraints, required fields, range limits, and array element constraints.
3. Keyword map
3.1 General
| Keyword | Purpose | Watch out for |
|---|---|---|
type |
The type; may be an array | "type": ["string", "null"] means nullable |
enum |
Enumerated values | Values must be unique |
const |
A single-value constraint | Good for fixed values such as a version number |
$ref |
References another schema | References the whole schema and does not merge with sibling keywords on the referencing object |
3.2 Numbers
multipleOf, maximum, exclusiveMaximum, minimum, exclusiveMinimum.
Note that in draft-04 exclusiveMaximum was a boolean (used together with maximum); only from draft-07 onward did it become a standalone numeric keyword —
which makes many "schemas copied from old docs" fail under strict mode.
3.3 Strings
minLength, maxLength, pattern (an ECMA-262 regular expression), and format (an annotation).
minLength counts Unicode code points, not UTF-16 units and not bytes. Validating with JavaScript's string.length will disagree with it.
3.4 Arrays
| Keyword | Purpose |
|---|---|
prefixItems |
The 2020-12 tuple-style constraint, matching items one by one by index |
items |
A uniform constraint on the remaining elements (2020-12) |
contains + minContains/maxContains |
At least/at most how many matching items must be present |
uniqueItems |
Elements must all differ (deep comparison; avoid on large arrays) |
minItems / maxItems |
Length constraints |
3.5 Objects
properties, required, patternProperties, additionalProperties, propertyNames,
minProperties, maxProperties, dependentRequired, dependentSchemas.
Three high-frequency misconceptions:
requiredonly requires the key to exist:{"name": ""}is valid. To reject an empty string you must addminLength: 1;additionalProperties: falsemagnifies compatibility problems: one extra field from a client fails the whole payload, so evolving the API forces every caller to upgrade in lockstep;propertiesdoes not restrict undeclared keys — by default they are allowed.
3.6 Logical composition
{
"oneOf": [
{ "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
{ "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
]
}
| Keyword | Semantics | Typical failure |
|---|---|---|
allOf |
All must be satisfied | Multiple branches impose conflicting constraints on the same field |
anyOf |
At least one must be satisfied | The error message is hard to trace to a specific branch |
oneOf |
Exactly one must be satisfied | Data matches two branches and fails, even though it "looks obviously correct" |
not |
Must not be satisfied | Rarely used, and the least friendly error messages |
oneOf is the keyword most likely to produce "valid data that fails validation". If the branch constraints overlap (for example, both allow extra fields),
a single record matches several branches and oneOf fails outright. In that case, switch to anyOf, or use a discriminator field with const inside the branches.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": { "properties": { "kind": { "const": "image" } } },
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
The discriminator style is more readable than oneOf, and it yields better errors.
4. Splitting and reuse
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"money": { "type": "string", "pattern": "^-?\\d+\\.\\d{2}$" },
"address": {
"type": "object",
"required": ["city"],
"properties": { "city": { "type": "string" }, "detail": { "type": "string" } }
}
},
"type": "object",
"required": ["total", "shipTo"],
"properties": {
"total": { "$ref": "#/$defs/money" },
"shipTo": { "$ref": "#/$defs/address" }
}
}
$defsis the form introduced in 2019-09; draft-07 usesdefinitions;- Cross-file references use relative
$ids, but do not rely on fetching remote schemas at runtime in production — bundle them as static files instead; - You cannot add constraints alongside a reference (
$refbehavior with sibling keywords varies by version). When you need composition, useallOf.
5. Runtime validation with Ajv
import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({
allErrors: true,
strict: true,
allowUnionTypes: true,
});
addFormats(ajv);
const validateOrder = ajv.compile(orderSchema);
export function assertOrder(input: unknown): void {
if (validateOrder(input)) {
return;
}
const detail = (validateOrder.errors ?? []).map((errorItem) => ({
path: errorItem.instancePath || '/',
keyword: errorItem.keyword,
message: errorItem.message,
params: errorItem.params,
}));
throw new Error(`Order data failed validation: ${JSON.stringify(detail)}`);
}
Key points:
- Call
compileonly once, at module top level; recompiling on every request turns a 10 ms validation into hundreds of milliseconds; strict: truesurfaces misspelled keywords early (for exampleminlengthinstead ofminLength), so it is worth enabling;- Only
ajv-formatsmakesformat: "email"andformat: "date-time"actually take effect; - 2020-12 requires the
ajv/dist/2020entry point — don't use the default export.
6. How to design error messages
What the validator gives you is "which keyword failed"; what the user wants is "which input box is wrong". It is worth adding a mapping layer:
const FIELD_LABEL: Record<string, string> = {
'/name': 'Name',
'/age': 'Age',
'/tags': 'Tags',
};
/**
* Convert Ajv errors into display-ready messages.
* @param errors The list of Ajv errors
* @returns An array of display messages
*/
function toMessages(errors: Array<{ instancePath: string; keyword: string; params: unknown }>): string[] {
return errors.map((errorItem) => {
const label = FIELD_LABEL[errorItem.instancePath] ?? errorItem.instancePath;
if (errorItem.keyword === 'required') {
const missing = (errorItem.params as { missingProperty: string }).missingProperty;
return `${label} is missing the required field ${missing}`;
}
if (errorItem.keyword === 'minLength') {
return `${label} is too short`;
}
return `${label} failed validation (${errorItem.keyword})`;
});
}
On the API side, keep a machine-readable structure so the front end can decide how to present it:
{
"type": "https://bbdrug.com/problems/validation",
"title": "Request data validation failed",
"status": 422,
"errors": [
{ "path": "/items/0/price", "keyword": "pattern", "message": "Must be a monetary string with two decimal places" }
]
}
7. Performance and traps
- Validation cost grows with schema complexity and data size; multiple
oneOfbranches cause combinatorial explosion, which becomes very noticeable with thousands of records; uniqueItemsperforms an O(n²) deep comparison over the array; for large arrays, deduplicate in the business layer instead;- Avoid catastrophic backtracking patterns in
pattern(such as(a+)+$); this is the classic entry point for ReDoS; - When
additionalProperties: falseis combined withallOf, fields declared in a subschema are judged "additional" by the parent, so you need to merge them into a single schema or useunevaluatedProperties.
8. Checklist
- [ ] Pin 2020-12 and declare
$schemaat the top of the file; - [ ] Extract shared structures with
$defs+$refinstead of copy-pasting; - [ ] Prefer discriminators over
oneOf; - [ ] Reuse the validator's
compileresult and enable strict mode; - [ ] Document clearly whether
formatis enforced; - [ ] Error responses provide the path, the keyword, and a user-facing message;
- [ ] Put the schema in CI: run the JSON Schema Test Suite cases as regression tests.
FAQ
How should I pick a JSON Schema draft?
Use 2020-12 for new projects and declare $schema at the top of the file. If the schema goes into OpenAPI 3.1, it must also be 2020-12, because 3.1 embeds that version directly; keep draft-07 only for projects still tied to older toolchains. The two behave differently for items and for $ref sibling keywords, so do not mix them in one schema set.
Why does additionalProperties: false magnify compatibility problems?
It treats every undeclared key as an error, so one extra field from a client fails the whole payload. Every caller must then upgrade in lockstep whenever the API evolves, or old clients get rejected. Config-style schemas should close extra fields explicitly, while public APIs need to weigh the compatibility cost.
Should I use oneOf or anyOf?
Use oneOf only when the data must match exactly one branch. If branch constraints can hold at the same time, one record matches several branches and oneOf fails outright; switch to anyOf, or add a const discriminator inside each branch.