JSON Schema: the parts that decide whether validation works
Most broken JSON Schema setups do not throw. They return valid on data that is wrong.
Every claim here is either measured or sourced. Where it is neither, it says so.
A syntax check tells you a document parses. It says nothing about whether {"user_id": null, "email": 4} is something your service should accept. That gap is what a schema is for, and the difference between the two is worth being precise about: the validator answers “is this well formed JSON”, a schema answers “is this the document I was promised”.
The failure mode that costs people real time is not a schema that rejects good data. That gets noticed in minutes. It is a schema that accepts bad data, compiles without complaint, and reports valid for a year.
Picking a draft
There are two answers and both are defensible.
2020-12 for new work. It is the identifier OpenAPI 3.1 aligns with, so if you are writing an API description you are already in this dialect. Note the naming: 2020-12 is when the draft was cut, and it was announced in early 2021. The documents at that URI were last republished in June 2022, which was a patch to the specification text, not a new release.
draft-07 for the widest tooling. It has the longest tail of support across languages, editors and code generators, and a lot of validators outside the JavaScript ecosystem still treat it as the default. If your schema has to be consumed by tools you do not control, draft-07 is the pragmatic floor.
What you should not do is leave $schema out and hope. A schema with no declared dialect is interpreted according to whatever the validator felt like defaulting to, which is exactly the ambiguity you were trying to remove.
The 2020-12 changes you will actually touch
Three, in order of how often they bite.
prefixItems replaces the array form of items. In draft-07, items with an array value meant positional tuple validation and additionalItems constrained the rest. In 2020-12, positions go in prefixItems and items is always a single schema applying to everything not covered by prefixItems.
unevaluatedProperties and unevaluatedItems are the composition-aware versions of additionalProperties and additionalItems. More on why below, because this is the pair people get wrong.
$dynamicRef and $dynamicAnchor replace $recursiveRef and $recursiveAnchor from 2019-09. Unless you are writing extensible recursive schemas, a tree type that a caller can specialise, you will never touch these. If you inherited a 2019-09 schema that uses $recursiveRef, it does not work under a 2020-12 validator.
The Ajv entry point that fails silently
Ajv’s default export implements draft-07 only. The 2020-12 and 2019-09 dialects live at separate entry points. Wire it the obvious way:
const Ajv = require("ajv"); // draft-07, whatever your $schema says
const ajv = new Ajv({ strict: false });
const schema = {
type: "object",
properties: {
point: {
type: "array",
prefixItems: [{ type: "number" }, { type: "number" }],
minItems: 2,
maxItems: 2
}
}
};
ajv.validate(schema, { point: ["north", "west"] }); // true
prefixItems is not a keyword this validator knows. Unknown keywords are ignored, so the value is checked for being an array of two entries and nothing else. The element types you declared are never looked at. You get true on data your schema plainly forbids.
Ajv does have guards. Strict mode, on by default, objects to unknown keywords, and a $schema naming a meta-schema it has not loaded will throw. Both guards get switched off routinely: strict: false is the first thing people add when a schema uses a vocabulary Ajv does not recognise, and plenty of generated schemas ship without $schema at all. Remove either guard and the silent pass is what you get.
The correct wiring:
const Ajv2020 = require("ajv/dist/2020");
const addFormats = require("ajv-formats");
const ajv = new Ajv2020({ strict: true, allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);
validate({ point: ["north", "west"] }); // false
console.log(validate.errors);
For 2019-09 the entry point is ajv/dist/2019. Worth adding a test that asserts a known-bad document fails. A validation setup with no negative test is a setup nobody has proven works.
format does not validate anything by default
In 2019-09 and 2020-12, format is an annotation, not an assertion. The specification says so explicitly. A conforming validator that sees {"type": "string", "format": "email"} is allowed to record “this value was annotated as an email” and then accept "not an email" without complaint. Most of them do exactly that unless you turn assertion on.
So this schema:
{
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"created_at":{ "type": "string", "format": "date-time" }
},
"required": ["email"]
}
catches a missing email and catches an email that is a number. It does not catch "email": "banana" and it does not catch "created_at": "yesterday" unless the validator has been told to assert formats.
In Ajv that means two things, not one: install and register ajv-formats, which supplies the actual implementations, and use the entry point for your dialect so the right vocabulary is in play. Ajv ships no format implementations of its own. Without ajv-formats, a format is either an unknown-format error in strict mode or a no-op with strict off. Neither of those is validation.
If a value’s shape genuinely matters, back the format with a pattern, or with minLength and maxLength. A regex is asserted by every validator on every draft, with no plugin and no flag. format is documentation that some validators can be persuaded to enforce.
Dates are the usual casualty here, since JSON has no date type and every timestamp in your payload is really just a string.
additionalProperties versus unevaluatedProperties
additionalProperties only knows about properties and patternProperties in the same schema object. It cannot see anything a $ref or an allOf branch brought in. That single sentence explains almost every “my schema rejects a field that is clearly defined” bug report:
{
"allOf": [{ "$ref": "#/$defs/base" }],
"properties": { "role": { "type": "string" } },
"additionalProperties": false
}
Every property defined in base is now rejected, because at this level the only known property is role. Swap the last line for "unevaluatedProperties": false and the keyword runs after the in-place applicators have done their work, sees everything base evaluated, and rejects only what nothing accounted for.
Rule of thumb: a standalone object with all its properties declared locally takes additionalProperties: false. Anything composed with allOf, $ref, if/then or oneOf takes unevaluatedProperties: false. unevaluatedItems is the same relationship for arrays and pairs naturally with prefixItems.
required means present, not populated
required is a list of keys that must exist. That is all it is.
{ "type": "object", "required": ["user_id"] }
{"user_id": null} satisfies this. So does {"user_id": ""}. If null is not acceptable, say so in the property schema, because "type": "string" excludes null on its own and ["string", "null"] admits it:
{
"properties": { "user_id": { "type": "string", "minLength": 1 } },
"required": ["user_id"]
}
The mirror mistake is marking a nullable field as not required. A key that is sometimes absent and a key that is sometimes null are two different contracts for the consumer, and picking one deliberately is part of designing the response.
Generating a schema from samples
Hand-writing a schema for a payload with forty fields is tedious enough that people skip it. Generating one from a real response is the practical starting point, with one requirement that decides whether the result is usable.
An array of objects has to be merged across every element, not sampled from the first. Take element zero only and two things go wrong: a key that appears from element three onward is missing from properties entirely, and a key that happens to be present in element zero but absent later is marked required, so the schema rejects data you know is valid. Correct behaviour is the union of keys for properties and the intersection for required, with types unioned per key.
That is what the schema generator does. Feed it a page of real API output rather than a hand-typed example, then edit the result: tighten the strings that have known formats, add enum where the set is closed, and decide the additionalProperties question per object. Generated output is a draft, not a contract.
Numbers deserve a look too. A generator sees 9007199254740993 and writes "type": "integer", which is honest about the type and silent about the fact that JavaScript cannot hold that value.
Where the effort pays back
API contracts, where the schema is the thing the producer tests against and the consumer validates with, so a breaking change fails in CI rather than in a customer’s logs. Config validation, where a typo in a deployment file becomes an error with a JSON Pointer to the offending key instead of a null dereference three services deep. And code generation, where one definition emits both the reference documentation and the TypeScript types, so the three cannot drift apart.
None of that arrives from writing a schema. It arrives from writing a schema that a correctly wired validator actually enforces.