JSON Schema draft differences: what to know before migrating between draft-04, 06, 07, 2019-09, and 2020-12
JSON Schema has shipped multiple drafts since 2013, and each one introduced subtly incompatible changes. “Schema works in library A, fails in library B” and “moved OpenAPI from 3.0 to 3.1 and validation broke” are textbook draft-mismatch symptoms. This article maps the meaningful changes per revision and the migration traps that come with them.
Draft list
| Draft | Released | $schema URL |
|---|---|---|
| draft-04 | 2013 | http://json-schema.org/draft-04/schema# |
| draft-06 | 2017 | http://json-schema.org/draft-06/schema# |
| draft-07 | 2018 | http://json-schema.org/draft-07/schema# |
| draft 2019-09 | 2019 | https://json-schema.org/draft/2019-09/schema |
| draft 2020-12 | 2020 | https://json-schema.org/draft/2020-12/schema |
draft-05 was skipped. From 2019 onward, the naming switched to YYYY-MM.
Important changes
id → $id (draft-06)
Draft-04 used id as the schema identifier. It clashed with user data fields named id. Draft-06 renamed it to $id:
// draft-04
{ "id": "http://example.com/schema.json" }
// draft-06+
{ "$id": "http://example.com/schema.json" } exclusiveMinimum / exclusiveMaximum (draft-06)
In draft-04, these were boolean modifiers on minimum/maximum:
// draft-04
{ "minimum": 0, "exclusiveMinimum": true } Draft-06 made them value-bearing in their own right:
// draft-06+
{ "exclusiveMinimum": 0 } Feeding a draft-04 schema with exclusiveMinimum: true to a draft-06+ validator can result in the validator interpreting it as “values must be greater than true” rather than “greater than 0.” Most libraries have a compatibility mode, but the default is strict per the declared draft.
const, contains, propertyNames (draft-06)
New keywords:
{
"const": "fixed-value", // single fixed value
"contains": { "type": "string" }, // at least one array element matches
"propertyNames": { "pattern": "^[a-z]+$" } // constrain object key names
} if / then / else (draft-07)
Conditional schemas:
{
"if": { "properties": { "country": { "const": "US" } } },
"then": { "required": ["postalCode"] },
"else": { "required": ["zip"] }
} Through draft-06 you had to express conditions via oneOf plus negation, which got complex fast.
$ref and sibling keywords (2019-09)
Through draft-07, an object containing $ref was treated as only the reference — sibling keywords like description were ignored:
// draft-07: description is silently dropped
{
"$ref": "#/definitions/User",
"description": "User information"
} In 2019-09 sibling keywords are honored. You can place description, examples, etc. alongside $ref.
unevaluatedProperties / unevaluatedItems (2019-09)
additionalProperties only sees the immediate properties declared in the same schema; properties contributed via allOf/anyOf are invisible to it. unevaluatedProperties looks at everything across composition and rejects whatever didn’t get evaluated:
{
"allOf": [{ "properties": { "name": { "type": "string" } } }],
"unevaluatedProperties": false // rejects everything except `name`
} Heavily used by OpenAPI 3.1 and complex schema composition.
$dynamicRef / $dynamicAnchor (2020-12)
A dynamic version of $ref that resolves anchors starting from the calling schema’s scope. Lets you build self-referential, extensible schemas where child schemas can override hooks. Implementation is non-trivial — only major validators (Ajv, JSV, json-everything) support it well.
dependentRequired / dependentSchemas (2019-09)
The draft-07 dependencies keyword was split to disambiguate the two roles:
// draft-07: one keyword, two meanings
{ "dependencies": { "creditCard": ["billingAddress"] } }
// 2019-09+
{ "dependentRequired": { "creditCard": ["billingAddress"] } } Implementation support matrix
Major validators:
| Library | draft-04 | draft-06 | draft-07 | 2019-09 | 2020-12 |
|---|---|---|---|---|---|
| Ajv (Node.js) | ✓ (older) | ✓ | ✓ | ✓ (Ajv 8) | ✓ (Ajv 8) |
| jsonschema (Python) | ✓ | ✓ | ✓ | ✓ | ✓ |
| everit-json-schema (Java) | ✓ | ✓ | ✓ | △ partial | ✗ |
| json-everything (.NET) | ✓ | ✓ | ✓ | ✓ | ✓ |
| gojsonschema (Go) | ✓ | ✓ | ✓ | ✗ | ✗ |
| santhosh-tekuri/jsonschema (Go) | ✓ | ✓ | ✓ | ✓ | ✓ |
The mainstream Go validator does not support 2019-09+ — a frequent stumbling block when validating OpenAPI 3.1 from Go.
OpenAPI relationship
OpenAPI versions and their internal JSON Schema:
- OpenAPI 3.0 — based on draft-05 (a slightly modified version).
- OpenAPI 3.1 — fully aligned with JSON Schema draft 2020-12.
OpenAPI 3.0 has a non-standard nullable: true field. OpenAPI 3.1 uses the standard type: ["string", "null"] form. Migrating 3.0 → 3.1 requires that substitution.
Migration checklist
When upgrading drafts:
- Rewrite
id→$id. - Replace any
exclusiveMinimum: truewithexclusiveMinimum: <number>(and confirm the same forMaximum). - Split
dependenciesintodependentRequired/dependentSchemas. $refsiblings now apply — make sure that’s intended (extract to a wrapping object if not).- Audit places that need
unevaluatedProperties: false(anywhere withallOf/ spread composition). nullable: true(OpenAPI 3.0) →type: [..., "null"].- Confirm the validator library actually supports the target draft.
Practical guidance
- New projects → 2020-12 first (newest, aligned with OpenAPI 3.1).
- Existing systems → match the existing schemas’
$schemaand lock to that draft. - Multi-language validation (must work in Go and Java too) → draft-07 is the broadest common subset.
- Maintaining OpenAPI 3.0 → keep schemas at draft-05-ish; if you can move to 3.1, jump straight to 2020-12.
Summary
JSON Schema’s draft differences are mostly subtle, but $id, exclusiveMinimum, $ref siblings, and unevaluatedProperties change the validation outcome. Always declare $schema explicitly and verify that downstream validators support the chosen draft. The OpenAPI 3.0/3.1 boundary is the place where this most often causes production bugs.
To author and check schemas concretely, the JSON validator tool handles the common drafts.