JSON Schema validation

A schema rejects what it forbids, not what it fails to mention. Undeclared properties pass by default, and that single fact explains most of the surprises below.

The run

Everything on this page came from one session with the Python jsonschema library 4.26 against draft 2020-12. Here is the schema, and then every document we put through it with the verdict the validator actually returned.

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": "?"}  ✅  ← nothing forbade it
  {"name": "a", "age": 30.0}   ✅  ← zero fractional part
  {"name": "a", "age": 30.5}   ❌ 30.5 is not of type 'integer'
  {"name": "a", "age": true}   ❌ True is not of type 'integer'
  {"name": null}               ❌ None is not of type 'string'

  # then, with "additionalProperties": false added
  {"name": "a", "extra": "?"}  ❌ Additional properties are not allowed

Four of those nine lines tend to surprise people. Taking them in order.

Line 4 — the extra property passed

JSON Schema is a constraint language rather than a whitelist. It has nothing to say about a property you never mentioned, so the document is valid. This is deliberate and frequently what you want: a consumer can validate the handful of fields it actually depends on while tolerating new ones added by a producer it does not control. Without that, every field a producer adds would break every consumer at once, and schema evolution would require lockstep deploys across teams that have no reason to coordinate.

The cost is that a typo in a property name is indistinguishable from an extra field nobody declared. Set "additionalProperties": false and the same document is rejected, as the last line shows. Neither default is wrong; holding one without knowing which is.

Lines 5 and 6 — 30.0 is an integer, 30.5 is not

integer is defined by mathematical value, not by how the number was written, so 30.0 qualifies and a validator will not catch a producer sending it. What happens afterwards depends entirely on the reader: Python hands you a float where you expected an int, JavaScript cannot tell the two apart at all, and a strongly typed deserialiser may refuse the document your validator just approved.

If that distinction is load-bearing — a quantity, a count, an identifier — check it in code after validation. The schema has no keyword for it.

Line 7 — true is not an integer

Worth dwelling on, because Python disagrees. There, bool is a subclass of int, so True == 1 and an isinstance check against int passes. A validator you write by hand in Python will therefore accept a value that a real JSON Schema validator refuses. The disagreement runs in the dangerous direction: it lets bad data through rather than blocking good data, so it fails quietly.

Line 8 — required did not catch the null

required asks only whether the key exists, and null is a value rather than an absence, so it satisfies the constraint. The type constraint is what stopped the document. Treating required as "must have a real value" produces schemas that pass documents you meant to reject, and the gap only shows up when a producer starts sending explicit nulls.

Collecting every error instead of 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

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

At an API boundary the iterator form is almost always the right call. 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 still ahead of them.

Two failures a schema cannot see

Integer precision loss happens inside the parser, before the validator is handed anything, so an identifier that has already been rounded validates perfectly. Duplicate keys have likewise collapsed by the time you hold a parsed document — the schema is shown only the survivor.

A schema constrains structure. It cannot tell you the data arrived intact, and no amount of tightening it will change that.

Three questions this leaves open

Is 30.0 a valid integer in JSON Schema?

Yes. JSON Schema defines integer by mathematical value rather than representation, so 30.0 has zero fractional part and passes. 30.5 does not. If the distinction between 30 and 30.0 matters to whatever reads the document next, check it in code — the schema has no vocabulary for representation.

Does required stop a field from being null?

No. required asks only whether the key is present, and null is a value rather than an absence, so {"name": null} satisfies it. What rejected that document in our run was the type constraint. The two keywords do genuinely different jobs and you usually need both. If a field may legitimately be null, say so with type: ["string", "null"] rather than relying on required to imply it.

Why does a hand-rolled Python validator accept things a real one rejects?

Because Python's type system disagrees with JSON Schema's on exactly one point, and it disagrees in the direction that lets bad data through. In Python, bool is a subclass of int, so True == 1 evaluates true and isinstance(True, int) passes. JSON Schema treats booleans as their own type, so a document with age set to true fails against type: integer — we confirmed that against draft 2020-12. Any check you write with isinstance will therefore accept a value the schema refuses. This is the general argument for validating against the schema rather than against your language's notion of types: the schema is the contract you published, and your language's type system was never party to it.

References

Before validating, check the document parses at all — the JSON formatter flags both of the failures a schema cannot see. More tools at withuse.io/tools.