JSON Schema validation

A schema rejects what it forbids, not what it fails to mention — so undeclared properties pass by default. That single fact explains most surprises.

Measured with the Python jsonschema library 4.26 against draft 2020-12.

The default that catches everyone

schema = {
  "type": "object",
  "properties": {"name": {"type": "string"},
                 "age":  {"type": "integer", "minimum": 0}},
  "required": ["name"],
}

{"name": "a", "age": 30}      ✅
{"age": 30}                   ❌ 'name' is a required property
{"name": "a", "age": -1}      ❌ -1 is less than the minimum of 0
{"name": "a", "extra": "?"}   ✅  ← passes. Nothing forbade it.

JSON Schema is a constraint language, not a whitelist. It has nothing to say about a property you never mentioned, so the document is valid. This is deliberate and often what you want: a consumer can validate the fields it depends on while tolerating new ones added by a producer it does not control.

When you want a closed shape, say so:

"additionalProperties": false

{"name": "a", "extra": "?"}   ❌ Additional properties are not allowed

Neither default is wrong. Choosing without knowing which one you have is.

What type: integer actually means

age = 30      ✅
age = 30.0    ✅  ← valid: zero fractional part
age = 30.5    ❌
age = true    ❌  ← booleans are their own type

JSON Schema defines integer by mathematical value rather than representation, so 30.0 qualifies. A validator will not catch a producer sending it, and what happens next depends on the language: Python hands you a float, JavaScript cannot tell the difference at all. If the distinction matters, check it in code.

The true row is worth dwelling on, because Python disagrees. There, bool is a subclass of int, so True == 1 and an isinstance check against int passes. A hand-rolled Python validator will therefore accept a value that a real JSON Schema validator rejects. Validate against the schema, not against your language's type system.

required is about presence, not value

{"name": null}   ❌ None is not of type 'string'
                    ← rejected by "type", not by "required"

required asks only whether the key exists, and null is a value rather than an absence, so it satisfies the constraint. What stopped the document was the type. The two mechanisms are independent, and treating required as "must have a real value" produces schemas that pass documents you meant to reject.

If a field may legitimately be null, declare it: "type": ["string", "null"].

Collect every error, not the first

validate(doc, schema)              # raises on error #1 and stops

Draft202012Validator(schema).iter_errors(doc)
# $:     'name' is a required property
# $:     Additional properties are not allowed ('extra' ...)
# $.age: -1 is less than the minimum of 0

Our test document produced three distinct failures where validate() reported one. Ajv has the same split in JavaScript through its allErrors option, which is off by default because collecting everything costs more.

At an API boundary the iterator form is almost always right. Returning one error at a time turns a single malformed request into several round trips for whoever is integrating with you, and they have no way to know how many are left.

What a schema will not catch

Two failures from elsewhere in this series survive validation entirely. Integer precision loss happens inside the parser before the validator sees anything, so a rounded identifier validates perfectly. And duplicate keys have already collapsed by the time you hold a parsed document — the schema is shown the survivor.

A schema constrains structure. It cannot tell you the data arrived intact.

Frequently asked questions

Why does my schema accept properties it never declared?

Because JSON Schema is a constraint language rather than a whitelist: it rejects what it forbids, not what it fails to mention. A schema declaring name and age accepts a document containing extra with no complaint, which we confirmed. This is deliberate — it lets a consumer validate the parts it cares about while tolerating fields added later by a producer it does not control, which is what makes schema evolution possible without lockstep deploys. When you do want a closed shape, set additionalProperties to false and the same document is rejected. Decide which behaviour you want explicitly rather than discovering the default in production, where the symptom is a field silently ignored rather than an error anyone can see.

Is 30.0 a valid integer in JSON Schema?

Yes, and this surprises people coming from a typed language. JSON Schema defines integer by mathematical value, not by representation, so 30.0 satisfies type: integer because it has zero fractional part — we measured it passing while 30.5 was rejected. The consequence is that a validator will not catch a producer sending 30.0 where you expected 30, and whether that matters depends on the language reading it afterwards: Python gives you a float, JavaScript cannot tell the difference at all. If the distinction is load-bearing, check it in code rather than in the schema, since the schema has no vocabulary for representation, only for value.

Is true an integer in JSON Schema?

No, and this is worth noting precisely because Python says otherwise. JSON Schema treats booleans as their own type, so a document with age set to true is rejected against type: integer — we confirmed it against draft 2020-12. Python, by contrast, has bool as a subclass of int, so True == 1 is true and True passes an isinstance check against int. That means a hand-rolled Python validator can accept a value a real JSON Schema validator refuses. Validate against the schema rather than against your language's type system, because the two do not agree here, and the disagreement runs in the direction that lets bad data through rather than blocking good data.

Does required check that a value is not null?

No, it checks only that the key is present. A document with name set to null satisfies required, because the property does exist — null is a value, not an absence. What rejected it in our test was the type constraint, since null is not a string. The two mechanisms are independent, and conflating them is a common source of a schema that passes documents you meant to reject. If a field may legitimately be null, declare it as type: ["string", "null"]; if it may not, the type constraint alone already stops it, so the two keywords do genuinely different jobs and both are usually needed.

How do I get every validation error, not just the first?

Use the iterator form your library provides rather than the convenience function. In Python, validate() raises on the first failure while Draft202012Validator(schema).iter_errors(doc) yields all of them — we measured a document producing three distinct errors where validate() reported one. Ajv in JavaScript has the same split, controlled by its allErrors option, which is off by default because collecting every failure costs more than stopping at the first. The distinction matters most at an API boundary: returning one error at a time turns a single malformed request into several round trips for whoever is integrating with you, and they cannot tell how many errors are still ahead of them.

References

Check a document parses at all with the JSON formatter, which also flags the two failures a schema cannot see. More tools at withuse.io/tools.