JSON Schema Validation Explained: The Keywords That Matter and the Ones That Bite
A JSON Schema is a contract for a document: which keys must exist, what type each value has, which strings are dates, which numbers have limits. Validating at the boundary turns a vague failure three functions deep into a precise message at the door. This guide covers the keywords you will actually use, how they combine, what $ref really does, and the four places schemas quietly do less than their authors think.
The short version
- A schema is a set of constraints, each a keyword. A value is valid when it violates none of them. An empty schema
{}accepts everything. propertiesdescribes keys if present; it does not require them. Requiring isrequired's job, and forbidding extras isadditionalProperties: false.type: "integer"accepts1.0.format: "email"is advisory in most validators unless you turn assertion on.$refis a pointer, not an include: in draft-07 it replaces its siblings; in 2019-09 and later it combines with them.oneOffails when two branches match. UseanyOfunless exclusivity is the point, and give branches a discriminatingconst.
The mental model: constraints, not shapes
People read a schema as a drawing of the document, which is why the first surprises happen. A schema is a list of assertions, and a document is valid when every assertion holds. Consider:
{ "type": "object", "properties": { "id": { "type": "integer" } } }
This accepts {"id": 1}. It also accepts {}, because nothing says id must exist. And it accepts {"id": 1, "anythingElse": "sure"}, because nothing forbids other keys. Each of those is a separate constraint you have to add: "required": ["id"] for presence, "additionalProperties": false for closure.
The model also explains why {} and true are both valid schemas meaning "anything", and why false is a schema meaning "nothing". They are the identity and the empty set of the constraint algebra, and they appear in real schemas more than you would expect β "additionalProperties": {} is a common way of saying "extra keys are fine, any type".
Types and the integer trap
The seven primitive types are object, array, string, number, integer, boolean and null. type takes one or a list: ["string", "null"] is the standard way to express a nullable field, and the only one β JSON Schema has no nullable keyword (OpenAPI 3.0 added one, which is why you see it in API specs, and OpenAPI 3.1 removed it again in favour of the type list).
integer is defined mathematically, not lexically. A validator must accept 1.0 as an integer because its value is integral. If your producer emits 1.0 where you expect 1 and the consumer's deserialiser is strict about it, the schema will not catch the mismatch. The reverse trap is "type": "number" for an identifier: 9007199254740993 is a perfectly valid number that no JavaScript consumer can represent exactly. Identifiers beyond 253 belong in strings, and a schema can say so with "type": "string", "pattern": "^[0-9]+$".
Strings: length, pattern, format
minLength and maxLength count Unicode code points, not bytes and not UTF-16 units. "hΓ©llo" has length 5 everywhere; "π" has length 1 even though JavaScript reports 2. A validator implemented on top of str.length without correction is wrong for astral characters, which is the kind of bug that appears only when a user in a particular script hits a limit.
pattern is an ECMAScript regular expression and is unanchored. "pattern": "[0-9]{3}" accepts "abc123def". If you mean the whole string, write ^[0-9]{3}$. This is the most common schema bug in the wild, and it fails open β too permissive β so it is never caught by the happy-path tests.
format names a semantic: date-time, date, email, uri, uuid, ipv4, ipv6, hostname, regex. The specification says validators should treat it as an annotation by default and may offer assertion as an option. In practice Ajv validates formats only with the ajv-formats plugin, Python's jsonschema validates only the ones whose dependencies are installed, and .NET's JsonSchema.Net requires an explicit flag. A schema that relies on format to reject bad emails may be rejecting nothing at all. Pair it with a pattern when it matters.
Numbers: bounds and multiples
minimum and maximum are inclusive. In draft-04, exclusiveMinimum was a boolean modifier on minimum; from draft-06 it is a number in its own right. Schemas written against the old form validate differently under new validators, usually silently, because an unknown-boolean is ignored. If you inherit a schema with "exclusiveMinimum": true, that constraint is probably no longer being applied.
multipleOf is exact arithmetic on decimal values, and 0.1 is not exactly representable in binary floating point. "multipleOf": 0.01 against 0.07 passes or fails depending on how the validator computes the division. Money should be integers in the smallest unit, and then multipleOf becomes unnecessary.
Objects: the three keywords you always need together
| Keyword | Says | Without it |
|---|---|---|
properties | If this key is present, its value must satisfy this subschema | Any value is accepted for the key |
required | These keys must be present | Every key is optional |
additionalProperties | What keys not listed in properties or matched by patternProperties may be | Extra keys are accepted with any value |
additionalProperties: false is right for a request body you control and wrong for a response you consume: the producer will add a field one day, and a closed schema turns that compatible change into a validation failure on your side. Closed schemas on input, open schemas on output, is the rule that survives contact with other teams.
patternProperties applies a subschema to every key matching a regex β "^x-": {"type": "string"} for extension headers β and propertyNames constrains the keys themselves. dependentRequired (dependencies in draft-07) says "if creditCard is present, billingAddress must be too", which is the clean way to express conditional presence without if.
Arrays: items versus tuples
items with a single schema applies it to every element. In draft-07, items with an array of schemas describes a tuple β first element this, second element that β and additionalItems governs the rest. The 2020-12 draft renamed the tuple form to prefixItems and made items mean "everything after the prefix", which is clearer and also means a draft-07 tuple schema is misread by a 2020-12 validator. Check the $schema declaration before trusting either behaviour.
uniqueItems: true compares elements by deep equality, so [{"a":1},{"a":1}] fails. contains requires at least one element to match a subschema β "the roles array must include admin" β and minContains/maxContains bound how many.
Combining schemas: allOf, anyOf, oneOf, not
allOf is intersection: every branch must hold. anyOf is union: at least one. not inverts. These behave as you expect. oneOf is where schemas go wrong: it requires exactly one branch to match, so if two branches can both accept a value, that value is invalid. A classic:
"oneOf": [
{ "type": "object", "properties": { "kind": { "const": "card" } } },
{ "type": "object", "properties": { "kind": { "const": "bank" } } }
]
Neither branch requires kind, so {} matches both and fails oneOf β and {"kind":"card","extra":1} matches the first and, because the second does not forbid kind: card (it only constrains it if it isβ¦ no, it constrains it to bank, so the second fails). The point is that reasoning about oneOf requires reasoning about every branch's permissiveness. Make branches mutually exclusive with a required discriminator ("required": ["kind"] plus const), or use anyOf and accept that overlap is allowed.
if/then/else (draft-07 and later) is the readable form of conditional validation: if kind is card, then cardNumber is required. The if schema never causes failure by itself; it only selects which of then or else applies.
$ref: pointer, not include
$ref contains a URI reference. The common form is a JSON Pointer into the same document: "#/definitions/Address" in draft-07, "#/$defs/Address" from 2019-09. The validator resolves the pointer and validates against the target as if it were written in place β which is why recursive schemas (a tree whose children are trees) work: the reference is followed at validation time, not expanded at load time.
The behaviour that changed between drafts: in draft-07, a schema object containing $ref has all its other keywords ignored. {"$ref": "#/definitions/Address", "description": "Billing"} loses the description, and {"$ref": "...", "required": ["city"]} silently drops the required. From 2019-09, $ref is an ordinary keyword that combines with its siblings. If a schema is validated by two systems on different drafts, this is a place they can disagree.
Remote references β "$ref": "https://example.com/schemas/address.json" β require the validator to fetch, or to have been pre-loaded with, that document. Validators in browsers and offline tools do not fetch, for the same reason this site's validator does not: a validation step should not be a network request. Bundle the referenced schemas into $defs before shipping.
Reading validation errors
A good validator reports three things per failure: the instance path (where in the document, $.orders[2].total), the keyword that failed (type, required, pattern), and the schema path (which part of the schema asserted it). Combinator failures are the noisy ones: a failing anyOf reports the errors from every branch, because the validator cannot know which branch you meant. When the branch list is long, the first helpful step is to find the one error that is not about the discriminator.
Inferring a schema from a sample
Writing a schema from scratch for a 40-field object is tedious enough that it does not happen. Inference from a representative sample is the practical starting point: types from observed values, required for keys present in every record, a nullable type where a null was seen, formats detected from string shapes, and enums where a string field repeats a small set. The result describes the sample, not the producer's full behaviour β a key missing from all ten records in your sample may still appear in production β so treat it as a draft to prune and tighten, and keep additionalProperties open on anything you consume.
Related guides and tools
Frequently asked questions
No. properties only constrains a key's value when the key is present. To require a key, list it in the required array. To reject keys you did not describe, set additionalProperties to false. The three keywords are independent and most real schemas need all three.
Because pattern is unanchored: it succeeds if the regex matches anywhere in the string. A pattern of [0-9]{3} accepts "abc123". Anchor it with ^ and $ to constrain the whole value. This mistake fails open, so it does not show up in tests that only use valid data.
Not by default in most validators. The specification treats format as an annotation unless the validator is configured to assert it, and the popular libraries each need a plugin or flag. If an email, date or URI shape matters to your application, add a pattern alongside the format or enable format assertion explicitly and test that an invalid value is rejected.
anyOf passes when at least one branch matches. oneOf passes only when exactly one does, so a value matching two branches is invalid. Use oneOf only when branches are mutually exclusive, typically by requiring a discriminator key with a distinct const in each branch; otherwise anyOf is both safer and faster.
Draft 2020-12 if every validator in your pipeline supports it, since it has the clearest array and $ref semantics and is what OpenAPI 3.1 uses. Draft-07 remains the most widely supported and is a sound choice when an older validator is involved. Whichever you pick, declare it in $schema so tools do not guess, and avoid relying on behaviour that differs between the two: tuple items and $ref with siblings.