JSON Schema Draft 2020-12: New Keywords & Migration Guide
JSON Schema Draft 2020-12 is the current stable release (published October 2021), replacing Draft-07 with 6 new keywords: prefixItems, unevaluatedProperties, unevaluatedItems, $dynamicRef, $dynamicAnchor, and restructured items semantics. The largest breaking change is that items now applies only to additional array items beyond those validated by prefixItems — in Draft-07, items handled all positional (tuple) validation; migrating tuple schemas requires renaming items to prefixItems. This guide covers all breaking changes from Draft-07 to 2020-12, each new keyword with examples, unevaluatedProperties vs. additionalProperties, migration patterns, and Ajv 8.x configuration for 2020-12.
Want to test a 2020-12 schema right now? Jsonic's JSON Schema Validator supports prefixItems, unevaluatedProperties, and all other 2020-12 keywords, showing the exact error path when validation fails.
What is JSON Schema Draft 2020-12?
Draft 2020-12 is the 8th major revision of the JSON Schema specification, published in October 2021 by the JSON Schema organization. It supersedes both Draft-07 (2018) and Draft 2019-09 as the recommended version for new schemas. The spec is split into 8 separate RFC-style documents — core, validation, applicator, unevaluated, format-annotation, format-assertion, content, and meta-data — compared to Draft-07's single combined document.
The 2 hard breaking changes from Draft-07 are: (1) the array-of-schemas form of items is removed and replaced by prefixItems, and (2) $recursiveRef/$recursiveAnchor from Draft 2019-09 are replaced by $dynamicRef/$dynamicAnchor. The 4 new additive keywords are prefixItems, unevaluatedProperties, unevaluatedItems, and $dynamicRef/$dynamicAnchor. Validator ecosystem support as of 2024: Ajv 8.x, Python jsonschema 4.18+, .NET Json.NET 13.x, and Java Everit all support 2020-12 natively.
| Draft | Published | Status | Key addition |
|---|---|---|---|
| Draft-04 | 2013 | Outdated | First widely-adopted version; $ref, definitions |
| Draft-07 | 2018 | Legacy (still common) | if/then/else, readOnly |
| Draft 2019-09 | 2019 | Stable | $recursiveRef, unevaluatedProperties (preview) |
| Draft 2020-12 | October 2021 | Current (recommended) | prefixItems, $dynamicRef, finalized unevaluated* |
Breaking change 1: prefixItems replaces tuple items
The most impactful change for existing schemas: in Draft-07, passing an array to items validated each array element at a specific index (tuple validation). In Draft 2020-12, items only validates additional array elements beyond the end of the prefixItems list. The array-of-schemas form of items is removed entirely. Migration requires 1 rename: items: [{...}, {...}] becomes prefixItems: [{...}, {...}].
// Draft-07 tuple schema (items as array)
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": [
{ "type": "string", "description": "name" },
{ "type": "number", "description": "age" },
{ "type": "boolean", "description": "active" }
],
"additionalItems": false
}
// Draft 2020-12 equivalent (prefixItems replaces tuple items)
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"prefixItems": [
{ "type": "string", "description": "name" },
{ "type": "number", "description": "age" },
{ "type": "boolean", "description": "active" }
],
"items": false
}Note: additionalItems is also removed in 2020-12. Its role is taken over by the single-schema form of items. Set items: false to disallow extra elements, or items: {"type": "string"} to allow extra elements that match a specific schema.
// 2020-12: allow extra items that must be strings
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"prefixItems": [
{ "type": "string" },
{ "type": "number" }
],
"items": { "type": "string" } // any extra items must be strings
}
// Valid: ["hello", 42] — 2 prefix items, no extras
// Valid: ["hello", 42, "world"] — 1 extra string item, OK
// Invalid: ["hello", 42, 99] — extra item 99 is a number, not string| Draft-07 keyword | Draft 2020-12 equivalent | Notes |
|---|---|---|
items: [schema, schema] | prefixItems: [schema, schema] | Direct rename — only change needed |
items: schema | items: schema | Unchanged keyword, but now only applies to items beyond prefixItems |
additionalItems: false | items: false | additionalItems is removed; use items instead |
additionalItems: schema | items: schema | Same — validates extra items beyond prefixItems |
unevaluatedProperties vs. additionalProperties
additionalProperties has a blind spot: it only sees properties declared in the same schema object's properties and patternProperties. It cannot see properties validated in allOf, anyOf, oneOf, $ref, or if/then/else subschemas. This causes a frustrating bug: additionalProperties: false rejects properties that are explicitly allowed in a subschema. The new unevaluatedProperties keyword solves this by tracking all properties evaluated anywhere in the annotation tree before applying its constraint.
// BROKEN: additionalProperties cannot see into allOf subschema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{ "properties": { "name": { "type": "string" } } }
],
"additionalProperties": false
}
// Result: { "name": "Alice" } FAILS — "name" is unknown to additionalProperties
// FIXED: unevaluatedProperties sees all evaluated properties
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{ "properties": { "name": { "type": "string" } } }
],
"unevaluatedProperties": false
}
// Result: { "name": "Alice" } PASSES — "name" was evaluated by allOf subschema
// Result: { "name": "Alice", "age": 30 } FAILS — "age" was never evaluatedA practical pattern for composing base and extended schemas:
// Base person schema
const personSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/person.schema.json",
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer", "minimum": 0 }
},
"required": ["name"]
}
// Extended employee schema — adds 2 required fields, closes all others
const employeeSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"allOf": [
{ "$ref": "https://example.com/person.schema.json" }
],
"properties": {
"employeeId": { "type": "string" },
"department": { "type": "string" }
},
"required": ["employeeId"],
"unevaluatedProperties": false
// Valid: { "name": "Alice", "employeeId": "E001", "department": "Eng" }
// Invalid: { "name": "Alice", "employeeId": "E001", "foo": "bar" }
}| Scenario | Use | Why |
|---|---|---|
| Flat schema, no composition | additionalProperties: false | Simpler; works correctly for single-level schemas |
Schema uses allOf, $ref, or if/then | unevaluatedProperties: false | Correctly closes schema including all subschema properties |
| Need to allow specific extra properties, block all others | unevaluatedProperties: false + own properties | Most restrictive and correct in composed schemas |
unevaluatedItems for arrays
unevaluatedItems is the array counterpart of unevaluatedProperties. It validates array items that were not evaluated by any prefixItems, items, contains, or subschema annotations. Like unevaluatedProperties, it understands the full annotation tree across allOf/anyOf/$ref — something items: false alone cannot do when combined with subschemas.
// unevaluatedItems: close an array after subschema has defined prefix
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"allOf": [
{
"prefixItems": [
{ "type": "string" },
{ "type": "number" }
]
}
],
"unevaluatedItems": false
}
// Valid: ["hello", 42] — matches the 2 prefixItems
// Invalid: ["hello", 42, true] — 3rd item was never evaluatedFor most simple schemas that do not use allOf/$ref with arrays,items: false after prefixItems is sufficient and more readable. Reserve unevaluatedItems for arrays composed from multiple subschemas.
$dynamicRef and $dynamicAnchor
$dynamicRef and $dynamicAnchor replace the Draft 2019-09 $recursiveRef/$recursiveAnchor pair. They solve a specific problem in reusable schema libraries: you want to write a recursive schema (e.g., a tree) and allow consumers to substitute a more specific type at the recursion point without rewriting the entire structure. The 3 components are: (1) a base schema declares $dynamicAnchor: "node" at the recursion point, (2) the recursive reference uses $dynamicRef: "#node" instead of a plain $ref, (3) a derived schema also declares $dynamicAnchor: "node" at its root, which overrides the base anchor during validation.
// Base tree schema — node can hold any value
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/tree.schema.json",
"$dynamicAnchor": "node",
"type": "object",
"properties": {
"value": {},
"children": {
"type": "array",
"items": { "$dynamicRef": "#node" }
}
}
}
// Derived schema — node must be a string tree
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/string-tree.schema.json",
"$dynamicAnchor": "node",
"allOf": [
{ "$ref": "https://example.com/tree.schema.json" }
],
"properties": {
"value": { "type": "string" }
}
}
// When validating against string-tree.schema.json:
// { "value": "root", "children": [{ "value": "child" }] } → Valid
// { "value": "root", "children": [{ "value": 42 }] } → Invalid (42 not a string)This is an advanced feature intended for schema library authors. If you are not building reusable recursive schema libraries, use plain $ref for recursion — it is simpler and supported in all drafts. See the JSON Schema $ref and $defs guide for plain recursive reference patterns.
Breaking change 2: $recursiveRef replaced
Draft 2019-09 introduced $recursiveRef and $recursiveAnchor as an experimental mechanism for extensible recursive schemas. Draft 2020-12 replaces both with the more capable $dynamicRef/$dynamicAnchor. The migration is a 2-step rename: replace every $recursiveRef with $dynamicRef, and every $recursiveAnchor with $dynamicAnchor. The semantics are compatible for the simple case (anonymous recursive extension). Validators that only target 2020-12 do not support $recursiveRef at all, so migration is required if upgrading from 2019-09.
// Draft 2019-09 (deprecated)
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$recursiveAnchor": true,
"type": "object",
"properties": {
"children": { "type": "array", "items": { "$recursiveRef": "#" } }
}
}
// Draft 2020-12 equivalent
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$dynamicAnchor": "node",
"type": "object",
"properties": {
"children": { "type": "array", "items": { "$dynamicRef": "#node" } }
}
}Migration checklist: Draft-07 to 2020-12
A complete Draft-07 to 2020-12 migration requires 3 mandatory changes and 3 recommended improvements. Work through the list in order — the mandatory changes will cause validation errors if skipped; the recommended changes fix subtle correctness bugs.
| # | Change | Action | Breaking? |
|---|---|---|---|
| 1 | Update $schema URI | Change "http://json-schema.org/draft-07/schema#" to "https://json-schema.org/draft/2020-12/schema" | Required |
| 2 | Rename tuple items | If items is an array, rename to prefixItems | Breaking |
| 3 | Replace additionalItems | Change additionalItems: false to items: false; additionalItems: schema to items: schema | Breaking |
| 4 | Replace $recursiveRef | Rename $recursiveRef → $dynamicRef, $recursiveAnchor → $dynamicAnchor | Breaking (if used) |
| 5 | Fix composed schema closing | Replace additionalProperties: false with unevaluatedProperties: false in schemas using allOf/$ref | Recommended |
| 6 | Update Ajv import | Change import Ajv from 'ajv' to import Ajv2020 from 'ajv/dist/2020' | Required (for Ajv users) |
Most schemas only need changes 1–3. If your Draft-07 schema does not use tuple items or additionalItems, then only the $schema URI update (change 1) is strictly necessary for a syntactically valid 2020-12 schema.
Ajv 8.x configuration for Draft 2020-12
Ajv 8 ships 2020-12 support in a separate module export. 3 key rules: (1) import from 'ajv/dist/2020', not from 'ajv'; (2) use addFormats from ajv-formats for format keywords; (3) if you need both Draft-07 and 2020-12, create 2 separate instances — they do not share state.
// Install: npm install ajv ajv-formats
import Ajv2020 from 'ajv/dist/2020'
import addFormats from 'ajv-formats'
const ajv = new Ajv2020({ allErrors: true })
addFormats(ajv)
// 2020-12 schema with prefixItems and unevaluatedProperties
const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
name: { type: 'string' },
tags: {
type: 'array',
prefixItems: [
{ type: 'string', description: 'primary tag' },
{ type: 'string', description: 'secondary tag' }
],
items: { type: 'string' } // any additional tags must be strings
}
},
required: ['name'],
unevaluatedProperties: false
}
const validate = ajv.compile(schema)
const valid = validate({ name: 'Alice', tags: ['admin', 'user', 'moderator'] })
console.log(valid) // true
const invalid = validate({ name: 'Alice', role: 'admin' })
console.log(invalid) // false
console.log(validate.errors)
// [{ instancePath: '', keyword: 'unevaluatedProperties',
// message: 'must NOT have unevaluated properties',
// params: { unevaluatedProperty: 'role' } }]// Supporting both Draft-07 and 2020-12 simultaneously
import Ajv from 'ajv' // Draft-07
import Ajv2020 from 'ajv/dist/2020' // Draft 2020-12
const ajv7 = new Ajv()
const ajv2020 = new Ajv2020()
function getValidator(schema) {
const draft = schema.$schema || ''
return draft.includes('2020-12') ? ajv2020 : ajv7
}
const v = getValidator(schema)
const validate = v.compile(schema)The allErrors: true option tells Ajv to collect all validation errors rather than stopping at the first one — useful for surfacing all problems to the user at once. Without it, only the first error is returned. See the Ajv validation guide for a full breakdown of Ajv options and error handling.
New keyword definitions
Draft 2020-12 introduces or finalizes 6 keyword categories. Each definition below includes the draft it was introduced in and its interaction with other keywords.
| Keyword | Introduced | Purpose | Replaces |
|---|---|---|---|
prefixItems | 2020-12 | Validates array items at specific index positions (tuple validation) | Array-of-schemas form of items (Draft-07) |
unevaluatedProperties | 2019-09 (finalized 2020-12) | Validates object properties not evaluated by any schema in the annotation tree | Augments additionalProperties for composed schemas |
unevaluatedItems | 2019-09 (finalized 2020-12) | Validates array items not evaluated by any schema in the annotation tree | Augments items: false for composed schemas |
$dynamicRef | 2020-12 | Runtime-resolved reference for extensible recursive schemas | $recursiveRef (Draft 2019-09) |
$dynamicAnchor | 2020-12 | Declares a named hook point for $dynamicRef resolution | $recursiveAnchor (Draft 2019-09) |
items (restructured) | 2020-12 | Now validates only array items beyond prefixItems count | Combined tuple + additional-items role from Draft-07 |
Frequently asked questions
What changed in JSON Schema Draft 2020-12 compared to Draft-07?
Draft 2020-12 (published October 2021) introduced 6 new keywords and made 2 breaking changes from Draft-07. The new keywords are: prefixItems (positional/tuple array validation), unevaluatedProperties (closes properties not caught by additionalProperties in subschemas), unevaluatedItems (closes array items not caught by subschemas), $dynamicRef, $dynamicAnchor (recursive schema extensions), and a restructured items behavior. The 2 breaking changes are: (1) items now validates only additional array items beyond those validated by prefixItems, not all positional items as in Draft-07; (2) $recursiveRef and $recursiveAnchor are replaced by $dynamicRef and $dynamicAnchor. Draft 2020-12 also splits the spec into 8 separate documents compared to Draft-07's single document. Validator support improved: Ajv 8.x, Python jsonschema 4.x, and .NET Json.NET all support 2020-12 natively.
How do I use prefixItems for tuple validation in JSON Schema 2020-12?
In Draft 2020-12, prefixItems replaces the Draft-07 array-of-schemas form of items for tuple validation. Each element of prefixItems is a schema that validates the array item at that index position. For example:
{
"prefixItems": [
{ "type": "string" },
{ "type": "number" },
{ "type": "boolean" }
]
}This validates an array where index 0 must be a string, index 1 a number, and index 2 a boolean. To also restrict additional items beyond the prefixItems tuple, set items: false (disallows any extra items) or items: {"type": "string"} (validates all extra items against that schema). The key migration step: if your Draft-07 schema has "items": [{...}, {...}] (an array), rename it to "prefixItems". If it has "items": {"type": "string"} (a single object), it stays as items — but its semantics change to mean "additional items only."
What is the difference between unevaluatedProperties and additionalProperties?
additionalProperties only considers properties declared in the same schema object's properties and patternProperties keywords — it cannot see properties validated by allOf, anyOf, oneOf, or $ref subschemas. unevaluatedProperties, introduced in Draft 2019-09 and refined in 2020-12, tracks all properties evaluated anywhere in the schema tree, including across subschemas. Example: if you have allOf: [{"properties": {"name": {}}}] and additionalProperties: false at the top level, the "name" property will be rejected because additionalProperties cannot see into the allOf subschema. Replacing additionalProperties: false with unevaluatedProperties: false fixes this — it knows "name" was already evaluated. Use additionalProperties when your schema is flat (no composition). Use unevaluatedProperties when combining schemas with allOf, anyOf, $ref, or if/then/else and you want to close the object against unknown properties.
How do I migrate a Draft-07 JSON Schema to 2020-12?
There are 3 required migration steps for Draft-07 to 2020-12. Step 1 (required): Update the $schema URI from "http://json-schema.org/draft-07/schema#" to "https://json-schema.org/draft/2020-12/schema". Step 2 (breaking): If items is an array (tuple validation), rename it to prefixItems. The single-schema form of items stays as items but now means "additional items beyond prefixItems." Step 3 (breaking): Replace additionalItems: false with items: false, and additionalItems: schema with items: schema. Also recommended: replace additionalProperties: false with unevaluatedProperties: false when using allOf/ anyOf/$ref composition. Most schemas only need steps 1–3. If your Draft-07 schema does not use tuple items or additionalItems, only the $schema URI update is strictly necessary.
How do I configure Ajv to validate JSON Schema Draft 2020-12?
Ajv 8.x ships with Draft 2020-12 support in a separate export. Install Ajv 8 with: npm install ajv. Then import the 2020-12 class: import Ajv2020 from 'ajv/dist/2020'. Create an instance: const ajv = new Ajv2020(). Compile and validate as normal: const validate = ajv.compile(schema); validate(data). For JSON Schema formats (email, uri, date, etc.) add the ajv-formats package: npm install ajv-formats, then addFormats(ajv). Ajv 8 does NOT automatically fall back to Draft-07 — each instance is tied to a specific draft. If you need to support both drafts simultaneously, create separate Ajv and Ajv2020 instances. The default Ajv import (import Ajv from 'ajv') still targets Draft-07 in Ajv 8. See the full Ajv guide for more configuration options.
What is $dynamicRef used for in JSON Schema 2020-12?
$dynamicRef (paired with $dynamicAnchor) enables recursive schema extensions where the recursive reference can be overridden by a referencing schema. It replaces Draft 2019-09's $recursiveRef/$recursiveAnchor. The use case: you have a base schema that recursively references itself (e.g., a tree node), and you want to allow derived schemas to substitute a more specific type at the recursion point without rewriting the entire tree structure. A $dynamicAnchor: "items" in the base schema creates a named hook; a derived schema with $dynamicAnchor: "items" at its root overrides that hook so $dynamicRef: "#items" resolves to the derived schema's definition during recursion. This is an advanced feature — most schemas do not need it. If you are not building reusable recursive schema libraries, you can ignore $dynamicRef and use plain $ref for recursion instead. Read the JSON Schema $ref guide for standard recursive patterns.
Test your Draft 2020-12 schema
Paste your migrated schema into Jsonic's JSON Schema Validator to confirm that prefixItems, unevaluatedProperties, and all other 2020-12 keywords work correctly against your data.