Pick the wrong JSON Schema draft and you find out months later, when unevaluatedProperties silently does nothing, or a schema that validates fine in one library gets rejected by another. The draft version isn't cosmetic — it changes what keywords exist, how $ref resolves, and how strict your validation actually is. Here's what changed across the drafts that matter and how to pick one deliberately instead of by accident.
Why the Draft Version Matters
JSON Schema is versioned through "drafts," each identified by a $schema URI like http://json-schema.org/draft-07/schema# or https://json-schema.org/draft/2020-12/schema. The draft you declare determines which keywords are recognized, how schema composition (allOf, anyOf, $ref) behaves, and in some cases what a valid document even looks like. A schema authored for one draft can validate documents incorrectly — or throw errors — when loaded by a validator expecting a different one.
Most confusion comes from three drafts still in active use: draft-07 (2018), 2019-09, and 2020-12, the current stable draft. Draft-06 and earlier are mostly legacy at this point and rarely worth targeting for new work.
What Changed: Draft-07 to 2019-09
Draft-07 is where most existing schemas — and a lot of tooling — still lives. It has if/then/else, const, contains, and format assertions, but its composition model has a real gap: there's no way to say "these are all the properties I've accounted for across a set of combined subschemas."
2019-09 introduced the vocabulary system, splitting the spec into modular pieces (core, validation, applicator, meta-data, format) that implementations can support independently. The practical developer-facing change is unevaluatedProperties and unevaluatedItems, which track what's been validated across allOf, $ref, and conditional branches — something additionalProperties alone can't do when schemas are composed.
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"allOf": [
{ "properties": { "name": { "type": "string" } } }
],
"properties": {
"age": { "type": "number" }
},
"unevaluatedProperties": false
}
With draft-07, additionalProperties: false on the outer schema wouldn't see name as accounted for — it only looks at the local properties keyword, not what sibling allOf branches validated. unevaluatedProperties fixes exactly this composition problem.
What Changed: 2019-09 to 2020-12
2020-12 is largely a cleanup and stabilization pass, but two changes affect real schemas:
itemsandprefixItemssplit apart. In earlier drafts,itemsdid double duty — an array of schemas meant tuple validation, a single schema meant "every item matches this." 2020-12 separates these:prefixItemsis now the tuple form, anditemsis only ever a single schema applied to remaining elements after the prefix. This removes a common source of ambiguity when reading someone else's schema.$dynamicRef/$dynamicAnchorreplaced$recursiveRef/$recursiveAnchor. These support extensible, recursive schema patterns (useful for building schema "base classes"). Most application-level schemas never touch this, but schema-authoring libraries do.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"prefixItems": [
{ "type": "string" },
{ "type": "number" }
],
"items": { "type": "boolean" }
}
That schema validates ["ok", 42, true, false] — the first two positions are fixed types, everything after must be boolean. Under draft-07 this required an object-form items array and a separate additionalItems keyword, which is less discoverable and easy to get wrong.
Which Draft to Target
This is the actual decision point, and it depends on what's consuming the schema, not personal preference:
- New projects with modern tooling (Ajv 8+, most Python/Go/Rust validators updated in the last couple of years): target 2020-12. It's the current spec, has the clearest composition semantics via
unevaluatedProperties, and new tooling development happens against it. - You're integrating with an ecosystem that pins an older draft — some API gateways, older OpenAPI tooling (OpenAPI 3.0 uses a draft-04-like subset), or a validator library that hasn't updated — target draft-07. Fighting your tooling to use a newer draft it doesn't fully support isn't worth it.
- You need
unevaluatedPropertiesbut your validator's 2020-12 support is incomplete: 2019-09 is a reasonable middle ground since it introduced the same composition keywords with a smaller feature surface than 2020-12.
Whatever you pick, always declare $schema explicitly. Omitting it means different validators may fall back to different default drafts, and that default can change across a library's major versions without warning. An explicit $schema line is one of the cheapest correctness guarantees you can add to a schema file.
Validating Against Your Chosen Draft
Once you've picked a draft, keeping documents honest against it is a formatting and validation problem you'll hit constantly during development. Paste a schema and a sample document into the JSON Validator to check structural validity, or use the JSON Formatter to clean up and inspect schema files pulled from third-party APIs before you commit to a draft assumption about them.
Migrating an Existing Schema
Moving draft-07 schemas to 2020-12 is usually mechanical:
- Update
$schemato the 2020-12 URI. - Replace any array-form
items(tuple validation) withprefixItems, and change the trailingitemsto the single-schema form. - Replace
additionalItemswithitemsunder the new semantics. - Where you were faking "all evaluated properties" with duplicated
propertiesblocks acrossallOfbranches, replace withunevaluatedProperties: falseat the composing level. - Re-run your test documents — both valid and intentionally invalid ones — against the updated schema with your validator library, since composition keyword behavior is the part most likely to shift.
Don't do a bulk find-and-replace on $schema alone and call it done — the items/prefixItems split is a silent behavior change, not a syntax error, so a document that used to fail validation might start passing (or vice versa) without any error to flag it.