Quick answer: Test four array-length boundaries before releasing a JSON API: inclusive minimum and maximum counts, the difference between length and element quality, optional versus required properties, and local counts in nested arrays. minItems and maxItems constrain array length; they do not require named fields, select matching elements, or measure total request complexity.
A batch has the expected number of positions but still contains nothing your handler can process. Start with the JSON Schema introduction for the broader validation model; this suite isolates array length from nearby rules that are easy to confuse with it.
Run the Python blocks together, in order. They were tested with Python 3.12.14 and jsonschema 4.26.0, explicitly using Draft 2020-12 rather than claiming it is the newest dialect. All values are synthetic local fixtures. Wikipedia's array data type overview provides background on indexed collections; the schema references define validation behavior.
1. Do inclusive length boundaries reject the right instances and schema defects?
Test lengths immediately below, at, and above the permitted range, then include a non-array instance and an invalid keyword value. minItems and maxItems use inclusive counts and require non-negative integer bounds. They do not themselves require an array type, so a schema intended for arrays should declare that type and test it independently.
The Draft 2020-12 validation specification defines the length assertions and keyword value restrictions. The first fixture uses a range with two accepted counts, making an accidental exclusive-bound assumption visible at both edges.
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
DIALECT = "https://json-schema.org/draft/2020-12/schema"
def valid(schema, instance):
document = {"$schema": DIALECT, **schema}
Draft202012Validator.check_schema(document)
return Draft202012Validator(document).is_valid(instance)
bounded = {"type": "array", "minItems": 2, "maxItems": 3}
for size, expected in [(0, False), (1, False), (2, True), (3, True), (4, False)]:
assert valid(bounded, list(range(size))) == expected
assert valid({"minItems": 2}, "not an array")
assert not valid(bounded, "not an array")
assert valid({"type": "array", "maxItems": 0}, [])
for bad_bound in [-1, 1.5]:
try:
Draft202012Validator.check_schema({"minItems": bad_bound})
except SchemaError:
pass
else:
raise AssertionError("Invalid array bound was accepted")
Keep schema-authoring errors separate from rejected client data. A negative or fractional bound is a defect in the contract document, while an array outside a valid range is an instance failure. Reporting both as “too many items” hides a configuration mistake behind a client-facing message that cannot help anyone fix it.
Also test the accepted set when bounds are generated from configuration. A minimum greater than a maximum can consist of individually valid keyword values while accepting no arrays. Schema compilation alone does not establish that your intended happy path remains possible. Include a known accepted example when changing limits, not only a collection of known rejections.
A maximum of zero is useful when an array must currently be empty, but it still needs an array type if other instance types are forbidden. Conversely, a minimum of zero imposes no positive lower length requirement. Treat these values as deliberate contract decisions, not magic switches for optionality or disabling validation.
When exposing configurable batch limits, test the schema after generation rather than just testing the settings object. A valid integer stored under the wrong keyword or at the wrong schema location can leave the intended array unrestricted. Preserve the generated schema alongside fixture outcomes during review so the accepted counts can be checked directly.
Use different element values when checking the upper boundary if another rule also forbids duplicates. Otherwise, an overlong sample might fail uniqueness even after maxItems has accidentally been removed. Isolated fragment tests and full-contract tests answer different questions: the former establish keyword behavior, while the latter establish the combined accepted set. Keep both when several constraints can reject the same payload for unrelated reasons.
2. Are element quality and match counts checked separately from total length?
Test count-valid arrays containing null, empty values, repeated values, and too few matching elements. Each element still contributes one position to array length. Add items for value constraints, uniqueItems for uniqueness, and contains with its applicable match-count keywords for matching subsets. A length requirement alone does not prove that any element is useful or valid.
The official array reference separates length from item validation and uniqueness. Build controls that pass one rule while failing another; a normal populated array that passes everything cannot reveal which constraint is missing.
exactly_two = {"type": "array", "minItems": 2, "maxItems": 2}
for instance in [[None, None], [False, 0], ["", ""], [[], {}]]:
assert valid(exactly_two, instance)
strings = {**exactly_two, "items": {"type": "string", "minLength": 1}}
assert not valid(strings, ["ready", ""])
assert valid(strings, ["ready", "ready"])
assert not valid({**strings, "uniqueItems": True}, ["ready", "ready"])
assert valid({**strings, "uniqueItems": True}, ["ready", "waiting"])
matching = {
"type": "array", "minItems": 3,
"contains": {"const": "primary"}, "minContains": 2,
}
assert not valid(matching, ["primary", "other", "other"])
assert not valid(matching, ["primary", "primary"])
assert valid(matching, ["primary", "primary", "other"])
The two matching failures protect different claims. The first has enough total elements but too few primary values. The second has enough matches but too few total elements. Keep those reasons distinct in test names so a later change to the minimum batch size does not accidentally remove coverage for the matching rule.
Likewise, the repeated ready strings satisfy length and value rules until uniqueness is explicitly added. A product may legitimately allow repeats, so do not add uniqueItems merely because it sounds stricter. If uniqueness is by a selected object identifier rather than entire value equality, use the appropriate additional application rule and test it separately.
The contains regression guide covers matching subsets in more depth. This suite uses them only as a contrast to total array length. Avoid describing minItems as counting only the entries that matched items or contains; those keywords contribute different assertions to the overall result.
For JavaScript producers, validate the received representation too. An in-memory hole can become a null element during ordinary serialization, leaving the count unchanged while changing value semantics. The sparse array JSON tests demonstrate that producer boundary; a receiving array-length rule cannot reconstruct the missing source history from null.
For user-facing errors, report the failed requirement rather than reducing every array rejection to an invalid batch message. A count outside the permitted range, an empty string inside a valid-length array, and an insufficient number of primary entries need different corrections. Avoid depending on which error the validator happens to return first; select a stable application error category when the public interface needs consistent guidance.
3. Does an optional array stay distinct from an absent, empty, or null property?
Test the containing object with the array property absent, empty, null, and validly populated. A minItems rule beneath properties does not require the property name to exist. Use required at the object location when presence is mandatory, and use the property's type rule to decide whether null is accepted separately from an empty array.
The required-property reference distinguishes member presence from validation of a member that exists. This distinction matters when omitted batch options mean “use the default” while an explicitly empty array means “use no options.”
optional = {
"type": "object",
"properties": {"tags": {"type": "array", "minItems": 1}},
}
assert valid(optional, {})
assert not valid(optional, {"tags": []})
assert not valid(optional, {"tags": None})
assert valid(optional, {"tags": ["demo"]})
mandatory = {**optional, "required": ["tags"]}
assert not valid(mandatory, {})
assert valid(mandatory, {"tags": ["demo"]})
nullable = {
"type": "object",
"properties": {"tags": {"type": ["array", "null"], "minItems": 1}},
}
assert valid(nullable, {"tags": None})
assert not valid(nullable, {"tags": []})
The nullable example permits null because the type union allows it and array-length assertions do not constrain null. It still rejects the empty array. That is a coherent contract if null has a documented meaning, but it should not appear accidentally because a developer treated null and empty as interchangeable missing-data states.
Check framework decoding and generated models at integration time. A client or server model that initializes an omitted collection to an empty list can turn an accepted absent property into a rejected empty array before schema validation sees it. The local fixtures prove the schema's behavior, not that every adapter preserves those input states.
Do not use a default annotation as evidence that a value is inserted. Any tool-specific default application is a separate preprocessing policy. Record both the original request and the instance passed to the validator when testing that pipeline, using synthetic fixtures rather than actual customer data. This makes state changes visible without inventing behavior for a framework that was not tested.
The null and undefined guide explains related producer decisions. For an API review, write the accepted absent, null, empty, and populated states in a short matrix before implementing the schema. That forces the team to decide intent rather than inheriting whatever collection initialization a code generator happens to produce.
4. Does each nested array enforce its own count without pretending to bound the request?
Test a short parent array containing a long child array, then apply separate child constraints and compare the result. Array-length keywords count elements at the current instance location, not all descendants. A nested array occupies one parent position. Local counts also do not measure total bytes, string sizes, nesting depth, or parser resource use.
The Draft 2020-12 applicator specification defines where item schemas apply. The python-jsonschema validator API documents the implementation used for these fixtures. Both schema location and runtime configuration belong in reproducible evidence.
parent_only = {"type": "array", "maxItems": 1}
wide_child = [[1, 2, 3]]
assert valid(parent_only, wide_child)
nested = {
**parent_only,
"items": {"type": "array", "maxItems": 2},
}
assert not valid(nested, wide_child)
assert valid(nested, [[1, 2]])
assert not valid(nested, [[], []])
assert not valid(nested, ["not a child array"])
assert valid({"type": "array", "maxItems": 1}, ["x" * 10000])
print("All four array-length test groups passed")
Use the JSON Viewer to expand a sanitized nested fixture and distinguish the parent from the child before running schema assertions. The viewer helps locate elements; it does not execute these schema rules or prove that an endpoint enforces a resource budget.
The large-string control deliberately passes a one-element maximum. Its purpose is to expose a false resource claim, not recommend accepting oversized strings. Enforce transport limits before parsing where appropriate, and constrain string lengths or deeper structures at their relevant boundaries. The large JSON size guide addresses broader considerations that one array-count keyword cannot replace.
Retain parent-overflow and child-overflow fixtures separately. A nested schema can appear effective if every invalid sample also violates the root limit. The single-parent wide-child case isolates the child rule, while the two-empty-children case isolates the parent rule. This separation makes a refactor's effect understandable without relying on the order of validator error messages.
Finish by running these four groups against the generated or deployed schema as well as the handwritten fragment. Record the chosen dialect, validator version, accepted controls, and known rejections. Then exercise the real request pipeline for omission handling and size limits. That closes the gap between a clear local count rule and the application behavior the caller actually encounters.
When comparing old and new schemas, include a fixture at each former boundary and each new boundary. Increasing a maximum broadens acceptance only if no other rule rejects the added sizes; decreasing it can break callers even though the schema remains structurally valid. Treat that as an API contract change, document the intended accepted counts, and keep migration evidence with the schema review rather than assuming a small numeric edit has a small behavioral effect.