Validating Arrays and Nested Objects With JSON Schema
Validate arrays of objects, tuples and deep nesting with JSON Schema: items, prefixItems, contains, uniqueItems and error paths, tested in Python.

Arrays and nested objects are where JSON gets its shape, and where hand-written validation code grows into loops full of if statements. JSON Schema describes them with a handful of keywords that compose: items for the element schema, properties for the nested keys, and a few array-only limits. The compositions are where people make errors. This article runs each keyword on a small order document in Python, shows exactly what the library reports, and points out the cases where the result is not what the keyword's name suggests.
One schema for every element
An array of order lines, each with a SKU and a quantity:
schema = {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "qty"],
"properties": {
"sku": {"type": "string"},
"qty": {"type": "integer", "minimum": 1},
},
},
}items takes a schema and applies it to every element. I validated an array with one good element and three bad ones:
[{"sku": "A", "qty": 2}, {"sku": "B", "qty": 0}, {"qty": 1}, "x"][$[1].qty] 0 is less than the minimum of 1
[$[2]] 'sku' is a required property
[$[3]] 'x' is not of type 'object'Three errors, each with the index and the path inside the element. That shape of report is what makes schema validation useful for bulk data: you can list every bad element, instead of stopping at the first. The first element produced nothing. If the array has a million elements, the same output could be a million lines, so cap what you print and keep a count (validating JSON with JSON Schema in real projects covers turning errors into a summary).
The empty array [] deserves attention. Without minItems, it is valid, because every element (there are none) satisfies items. With minItems: 1 it failed with [$] [] should be non-empty. Decide whether an empty list is a legitimate value for each array and write the limit if it is not. A request that must contain at least one line item and arrives as [] is exactly the kind of document that passes a schema without minItems.
Nested objects
Nesting needs no new keyword: a property's schema can itself be an object schema with its own properties and required. The path in an error shows every level:
{"properties": {"a": {"properties": {"b": {"items": {"type": "integer"}}}}}}
# document {"a": {"b": [1, "x"]}}
# [$.a.b[1]] 'x' is not of type 'integer'$.a.b[1] reads as key a, key b, element 1. Two cautions for deep schemas. Each level's required applies only to that level's object, so a required user does not make user.id required; the user schema needs its own required: ["id"]. And additionalProperties: false is per object as well. Closing the top level leaves the nested objects open unless you repeat it in each, a pattern worth enforcing with a test that feeds an unknown key at every level. When many places share a nested shape, define it once under $defs (reusing definitions with $ref in JSON Schema).
Fixed positions: tuples
Sometimes an array is a record: [longitude, latitude] or [name, count]. In draft 2020-12 this is prefixItems, a list of schemas by position, with items: false to forbid extra elements:
{"type": "array",
"prefixItems": [{"type": "number"}, {"type": "number"}],
"items": False}The results:
[1, 2] OK
[1] OK
[1, 2, 3] [$] Expected at most 2 items but found 1 extra: 3
["a", 2] [$[0]] 'a' is not of type 'number'[1] passed, which is the surprise. prefixItems constrains positions that exist and does not require them. For a pair that must have both, add minItems: 2 (and maxItems: 2 instead of items: false if you prefer). In drafts before 2020-12 the same idea is written as items holding an array of schemas, with additionalItems for the rest. Code that must work with an older validator should be tested on exactly these three documents.
Members that must exist, and repeats that must not
contains requires that at least one element matches a schema, and minContains/maxContains set the count:
{"type": "array", "contains": {"const": "admin"}, "minContains": 1, "maxContains": 1}["a", "admin"] passed. ["a"] failed with ['a'] does not contain items matching the given schema. ["admin", "admin"] failed with Too many items match the given schema (expected at most 1). This is how to say "exactly one primary contact" or "must include the admin role".
uniqueItems: true forbids repeated elements, and its equality is by value, deeply:
[{"a": 1, "b": 2}, {"b": 2, "a": 1}] non-unique (key order does not matter)
[1, 1.0] non-unique (1 and 1.0 are the same number)
[1, true] OK (a number is not a boolean)That behavior follows JSON data equality: objects are equal when they have the same keys and equal values whatever the order, and numbers compare by value. It is also why uniqueItems is costly on long arrays of objects: the validator compares elements with each other. In the event data from the other articles, uniqueItems on tags found 33,333 records with the same tag twice. That check is cheap there, as arrays have two items; on an array of 100,000 objects it is quadratic in the worst case and becomes the slowest part of the validation. Put a maxItems next to it for untrusted input.
Combining with other keywords
A few combinations come up all the time:
Non-empty array of non-empty strings:
{"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}.Array of one of several shapes:
"items": {"oneOf": [{"$ref": "#/$defs/circle"}, {"$ref": "#/$defs/square"}]}. Errors from a failedoneOflist the problems with every branch, which is noisy; adding aconstdiscriminator such as"kind": {"const": "circle"}to each branch lets you see which one was meant.Array or single value: APIs that accept
"tag": "a"or"tag": ["a", "b"]useanyOfwith the two forms. Document it, then normalize to an array in code.Object used as a map: keys are data, so
propertiescannot list them. UseadditionalPropertiesas the value schema ({"type": "object", "additionalProperties": {"type": "integer"}}) andpropertyNamesto constrain the keys ({"propertyNames": {"pattern": "^[a-z]+$"}}).
Where array validation hurts
The cost of validating an array is proportional to its length times the cost of the items schema, plus the pairwise comparisons for uniqueItems. The event schema validated 200,000 records in 0.19 s with Ajv, and in 12.1 s with the Python library, and the difference is the same per-element machinery repeated. For one request body of ten items none of this matters. For a nightly file of ten million records it is the difference between a minute and an hour, and it is the reason to compile validators once and to choose a fast implementation for bulk work.
When array validation gets expensive
The array is huge. Do not load a 2 GB array into memory to validate it element by element. Stream the elements and validate each one against the items schema, so memory stays flat (handling large JSON files in Python with ijson).
You need cross-element rules. "Each SKU appears once" or "quantities add up to the total" are not expressible, except uniqueItems on whole elements. Check them in code after the schema passes.
The depth is untrusted. A document nested thousands of levels deep made the Python validator raise RecursionError. Cap depth at the parser or in a pre-check before running the schema.
The array keywords are documented at Understanding JSON Schema: array and object, and the formal definitions are in the 2020-12 applicator vocabulary. For one-off inspection of a nested payload, use the JSON formatter.


