What Changed in JSON Schema Draft 2020-12 (And What Silently Breaks)
items became prefixItems, $ref stopped replacing siblings, and unevaluatedProperties arrived. What silently changed.
Table of contents
Most schema changes between drafts announce themselves — a validator rejects the document and you fix it. Three of the changes in draft 2020-12 do the opposite. The schema still loads, still validates, and quietly checks something different from what it checked before.
Key takeaways#
itemsno longer takes an array. Tuple validation moved toprefixItems, and an array-valueditemsis ignored by a 2020-12 validator rather than rejected.$refno longer replaces its siblings. Keywords next to a$refused to be discarded; now they apply.unevaluatedPropertiesarrived, and solves theallOfcomposition problem thatadditionalPropertiesnever could.definitionsbecame$defs, though old pointers still resolve because resolution is by JSON Pointer.- Declaring
$schemais what tells a validator which set of rules to apply. Omitting it means the validator guesses.
items no longer means what it did#
In draft-07, items did two jobs depending on what you passed it. An object meant "every element matches this". An array meant "element 0 matches the first schema, element 1 the second" — tuple validation.
Draft 2020-12 splits them. items is now only the first job. Tuples use prefixItems.
// draft-07 — a tuple of [string, number]
{ "items": [{ "type": "string" }, { "type": "number" }] }
// 2020-12 — the same intent
{ "prefixItems": [{ "type": "string" }, { "type": "number" }] }The dangerous part is the failure mode. A 2020-12 validator handed an array-valued items does not error — the keyword's value is the wrong type for what it now expects, so the constraint contributes nothing. Your tuple schema becomes a schema that accepts any array at all.
Nothing in the output says so unless the validator goes out of its way to warn. The JSON Schema validator flags it explicitly, because silently accepting everything is the worst possible outcome for a validation tool.
In 2020-12 the two combine naturally: prefixItems covers the leading positions, and items constrains whatever comes after.
{
"prefixItems": [{ "type": "string" }, { "type": "number" }],
"items": { "type": "boolean" }
}To close the tuple so nothing may follow, set "items": false.
$ref stopped swallowing its siblings#
Under draft-07, $ref was exclusive: any keyword sitting alongside it was ignored. This did nothing at all beyond the reference:
{ "$ref": "#/$defs/positiveInt", "maximum": 100 }Draft 2020-12 changed $ref into an ordinary applicator. Siblings now apply, and that same schema means "matches positiveInt and is at most 100".
The change is almost always an improvement — the old behaviour meant a description next to a $ref vanished, and extending a referenced schema required wrapping it in an allOf. But it flips the meaning of any schema that relied on siblings being inert, and a schema that was over-specified as documentation now enforces those extra constraints.
Worth grepping for. A $ref with a validation keyword beside it is either newly meaningful or newly wrong, and only reading it tells you which.
unevaluatedProperties, and the problem it solves#
additionalProperties has a limitation that only becomes obvious when composing schemas: it can only see properties and patternProperties in its own schema object. It cannot see through allOf.
So this rejects a perfectly intended document:
{
"allOf": [{ "properties": { "name": { "type": "string" } } }],
"properties": { "age": { "type": "integer" } },
"additionalProperties": false
}additionalProperties sees only age. The name property inside the allOf is invisible to it, so { "name": "Ada", "age": 36 } fails. The traditional workaround was to duplicate every property name at the top level, which drifts the moment anyone edits one branch.
unevaluatedProperties is defined in terms of what any subschema evaluated, including through allOf, anyOf, if/then, and $ref:
{
"allOf": [{ "properties": { "name": { "type": "string" } } }],
"properties": { "age": { "type": "integer" } },
"unevaluatedProperties": false
}Now name and age are both accepted and anything else is rejected. This is what makes schema composition — a base schema plus extensions — actually work.
unevaluatedItems does the same for arrays, covering positions no prefixItems or contains accounted for.
Declare $schema#
The $schema keyword names the dialect a schema is written in:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object"
}Without it a validator applies a default, and different validators default differently. A schema that behaves one way in your test suite and another in a gateway is usually a schema with no $schema being read by two libraries with different assumptions.
It costs one line and removes an entire class of ambiguity.
The traps that did not change#
Two things catch people regardless of draft, and both survive a migration untouched.
pattern is a search, not a full match. "pattern": "abc" accepts "xxabcxx", because the regular expression only has to match somewhere in the string. Anchor with ^ and $ when you mean the whole value. This one quietly passes input that should have been rejected.
format is an annotation, not an assertion. By the specification, a validator is not required to enforce "format": "email" at all, and several do not by default. If you are relying on format for validation rather than documentation, confirm your validator asserts it — and remember that a downstream consumer using a different library may not.
Migrating an existing schema#
- Add
$schemawith the 2020-12 URI. - Replace every array-valued
itemswithprefixItems. Add"items": falsewhere the tuple was meant to be closed. - Find every
$refwith sibling keywords and decide whether those siblings were meant to apply. - Rename
definitionsto$defs. - Replace
additionalProperties: falsewithunevaluatedProperties: falsein any schema that composes withallOf. - Re-run your fixtures — including the ones that are supposed to fail. A migration that breaks tuple validation still passes every positive test, so only the negative cases catch it.
Step six is the one that matters. The changes in this draft mostly widen what a schema accepts, and a test suite full of valid documents will not notice.
As of August 2026#
Draft 2020-12 is the current published draft and the one to target for new work. Draft-07 remains widely deployed, and OpenAPI 3.1 aligns with 2020-12 — which is how many teams meet this migration in the first place.
Tags
- JSON
- Validation
- API
- JSON Schema
