JSON Schema draft differences: what to know before migrating between draft-04, 06, 07, 2019-09, and 2020-12

4 min read

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

DraftReleased$schema URL
draft-042013http://json-schema.org/draft-04/schema#
draft-062017http://json-schema.org/draft-06/schema#
draft-072018http://json-schema.org/draft-07/schema#
draft 2019-092019https://json-schema.org/draft/2019-09/schema
draft 2020-122020https://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:

Librarydraft-04draft-06draft-072019-092020-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.1fully 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:

  1. Rewrite id$id.
  2. Replace any exclusiveMinimum: true with exclusiveMinimum: <number> (and confirm the same for Maximum).
  3. Split dependencies into dependentRequired / dependentSchemas.
  4. $ref siblings now apply — make sure that’s intended (extract to a wrapping object if not).
  5. Audit places that need unevaluatedProperties: false (anywhere with allOf / spread composition).
  6. nullable: true (OpenAPI 3.0) → type: [..., "null"].
  7. 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’ $schema and 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.