Skip to content
Guides

4 JSON Schema not Tests for Forbidden API States

·9 min read
4 JSON Schema not Tests for Forbidden API States - Guides guide from JSON Prettifier

Quick answer: Test JSON Schema not by defining the allowed domain, making forbidden conditions require their discriminators, checking every prohibited field combination, and proving rejection stops the application operation. Negation rejects matching instances; it does not remove fields, choose replacement values, or establish business authorization.

A release can accidentally reject every empty object after someone adds a rule intended to forbid one status. The JSON is valid. The schema can be valid too. The mistake is the exact condition being negated.

These four checks focus on forbidden states rather than general branching. Read the JSON Schema overview for the basics and the conditional-validation tests for positive branch selection. Inspect fixtures in the JSON Viewer when their structure is unclear; the viewer does not execute these schemas.

1. Does the rule exclude only values inside the intended input domain?

Define the allowed type and value constraints alongside not, then test forbidden values and unrelated JSON types separately. Negating one type does not select a single alternative type. A rule that rejects strings still accepts numbers, objects, arrays, booleans and null unless other assertions restrict the accepted domain explicitly.

Start with the positive contract in plain language. For example, an API may accept a nonempty string label except for a reserved marker. The negative rule describes the exception; it does not replace the requirement that the value be a nonempty string. Keeping those responsibilities separate makes both the schema and its failures easier to explain.

The Draft 2020-12 core specification defines not as inversion of the subschema's validation result. Its value can be an object schema or a boolean schema. Unlike allOf, anyOf and oneOf, not does not take an array of subschemas. Check the schema itself before relying on instance outcomes.

from jsonschema import Draft202012Validator

label_schema = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "string",
    "minLength": 1,
    "not": {"const": "RESERVED"},
}
Draft202012Validator.check_schema(label_schema)
label_validator = Draft202012Validator(label_schema)
for value, expected in [
    ("ordinary", True), ("RESERVED", False), ("", False),
    (None, False), (17, False), ({}, False), ([], False),
]:
    assert label_validator.is_valid(value) is expected
assert Draft202012Validator({"not": {"type": "string"}}).is_valid(None)

Keep case sensitivity explicit. The reserved marker in this example is an exact value; a differently cased spelling does not match that const. If the application normalizes labels before validation, test that processing order separately. Do not describe exact-value exclusion as a general reserved-word filter across languages, normalization forms or letter case.

Use at least one nearby accepted value in the fixture set. A schema that rejects every instance would pass a suite containing only forbidden examples. The accepted control proves the exception has not swallowed the domain. Include boundary cases for the positive constraints too, because negation cannot compensate for a missing minimum length or a permissive type declaration.

Review the resulting error category with client developers. A wrong type, an empty string and a reserved value have different remedies. The schema error-reporting guide explains how to preserve useful classifications without exposing sensitive instance values or coupling clients to a library's exact English diagnostic.

Keep the forbidden subset small enough to explain in a code review. When a list of exceptions becomes longer than the allowed domain, an explicit allowed-value list may communicate the policy more clearly. Compare both representations against the same accepted and rejected corpus before replacing one with the other. Readability is useful, but equivalence should come from observed validation outcomes rather than confidence in a shorter schema.

2. Does a forbidden property value require that property to exist?

Put required inside a negated object condition when the forbidden state depends on a property's presence. A properties keyword constrains values that exist but does not require the named member. Without the inner presence check, an object missing that member can satisfy the forbidden subschema and be rejected unexpectedly by not.

Consider a draft record that may omit its status but must never submit a status of blocked. A properties-only inner rule appears to describe blocked records, yet it also accepts an empty object. Negation then reverses that success. This is why the missing-discriminator fixture is as valuable as the obvious forbidden-status fixture.

The validation vocabulary defines required as a property-presence assertion. Place it at the same object location whose member triggers the forbidden condition. Requiring the field at the outer level would change the contract by making every record include status, including records where omission is legitimate.

status_schema = {
    "type": "object",
    "properties": {"status": {"type": "string"}},
    "not": {
        "required": ["status"],
        "properties": {"status": {"const": "blocked"}},
    },
}
Draft202012Validator.check_schema(status_schema)
status_validator = Draft202012Validator(status_schema)
for value, expected in [
    ({}, True), ({"status": "draft"}, True),
    ({"status": "blocked"}, False), ({"status": None}, False),
]:
    assert status_validator.is_valid(value) is expected
broken = {"not": {"properties": {"status": {"const": "blocked"}}}}
assert not Draft202012Validator(broken).is_valid({})

The null case is deliberately separate. It fails the outer string constraint, whereas blocked fails the negation rule. Both are rejected, but for different reasons. If null is an accepted domain value, declare that policy and add the corresponding accepted fixture rather than relying on an accidental gap in the inner condition.

Nested objects need their own location checks. A root-level required list cannot require a member inside a child object. Build a fixture where the child is absent, one where it exists without its discriminator, and one where the forbidden child value exists. Confirm each failure comes from the intended layer instead of an unrelated required field.

Avoid inserting required merely to make a troublesome example pass. First decide whether absence is allowed by the product contract. Then encode and test that policy. A schema's successful compilation proves that its vocabulary is structurally acceptable; it cannot establish that your chosen notion of a forbidden state matches the business requirement.

Preserve a regression fixture for the original missing-property failure even after the schema is corrected. It captures why the inner presence condition exists and discourages a later cleanup from removing it as apparently redundant. Give the fixture a descriptive name and record its intended acceptance result. That small explanation is more durable than a comment saying only that the schema was fixed.

3. Do all combinations of mutually forbidden fields behave correctly?

Test neither field, each field alone, and both fields together when not forbids a combination. Negating required with two names prohibits their simultaneous presence, but it does not require either field. Values such as null and false still count as present, so test those separately from omission when the domain permits them.

Suppose an import request can name a source URL or include inline content, and the protocol prohibits supplying both. This example concerns presence only; it does not fetch URLs or validate content. The official Boolean-combination reference describes negation as rejection of a matching subschema, which makes the four presence combinations a compact truth table.

source_schema = {
    "type": "object",
    "not": {"required": ["sourceUrl", "inlineContent"]},
}
Draft202012Validator.check_schema(source_schema)
source_validator = Draft202012Validator(source_schema)
for document, expected in [
    ({}, True), ({"sourceUrl": "https://example.com/data"}, True),
    ({"inlineContent": "{}"}, True),
    ({"sourceUrl": "x", "inlineContent": "{}"}, False),
    ({"sourceUrl": None, "inlineContent": False}, False),
]:
    assert source_validator.is_valid(document) is expected

If the contract requires exactly one source, the empty object must also fail. That is an additional positive requirement, not something this exclusion already guarantees. Express the full policy and retain all four truth-table rows after changing the schema. Otherwise an engineer can fix the double-source case while leaving source-free requests accepted.

Wikipedia's exclusive-or background helps explain the distinction between allowing at most one and requiring exactly one. JSON Schema keyword semantics come from the specification, not from treating every logical operator name as interchangeable. In particular, do not replace this rule with oneOf without checking what each branch actually accepts.

For a single forbidden property, a false schema under that property's definition can be easier to read than a negated required rule. Choose the representation that communicates the actual contract to maintainers. Our object-closure tests cover the separate decision to forbid every unrecognized property rather than a particular named combination.

Retest clients that send placeholder fields. A client serializing an unused source as null still sends the property, so this presence rule rejects a request containing the other source too. Explain omission requirements in the API examples and test the real serializer output. A fixture constructed manually without the placeholder may hide a predictable integration failure.

4. Does rejection stop the application before any side effect?

Run accepted and forbidden documents through the application gate that precedes persistence or execution. A validator returning false is useful only if the caller respects that result. Assert that rejected inputs produce no side effect, accepted inputs reach the operation once, and validation leaves the submitted document unchanged under a read-only policy.

The python-jsonschema validator documentation provides the is_valid and validate APIs used here. This small gate demonstrates control flow; it is not a deployed HTTP endpoint, database transaction test or authorization system. Use your framework's request tests to verify the corresponding production path before release.

from copy import deepcopy

stored = []
def submit(document):
    if not source_validator.is_valid(document):
        return False
    stored.append(deepcopy(document))
    return True

bad = {"sourceUrl": "x", "inlineContent": "{}"}
before = deepcopy(bad)
assert submit(bad) is False
assert stored == [] and bad == before
good = {"inlineContent": "{}"}
assert submit(good) is True
assert stored == [good]
assert stored[0] is not good

Add the same assertion around queue publication, file creation or external calls if those operations can occur before storage. Checking only the final database row is insufficient when a rejected request has already emitted an event. Keep rejected-fixture values synthetic so failure output cannot disclose real credentials or customer payloads.

Inventory alternative entry points as well. Bulk imports, administrative forms and background replay jobs can reach the same operation without using the public request handler. Run the forbidden fixture through each relevant path or place the shared acceptance gate where those paths converge. A successful public-endpoint test does not prove that an internal caller cannot bypass validation entirely.

Before tightening an existing contract, check queued requests and representative stored records against the proposed rule. A newly forbidden combination may already exist in historical data. Decide how reads, updates and replays handle those records, and separate migration policy from validation of new submissions. Rejecting an old replay without a clear recovery path can turn a correct rule into an operational incident.

The examples were executed with jsonschema 4.26.0. They establish the shown negation and gate outcomes, not performance guarantees for arbitrary schemas. Preserve the chosen dialect and validator configuration with the fixture corpus, and rerun it when either changes. A deny rule evaluated under the wrong schema or bypassed on one route is not an effective acceptance boundary.

Finish with a contract review: identify the permitted domain, the exact forbidden subset, the meaning of absence, and the operation protected by rejection. These four checks turn not from an easy-to-misread keyword into a testable exception. Keep authorization, external state and domain decisions in their appropriate application controls; structural rejection alone does not prove an operation is safe or permitted.

Frequently Asked Questions

What does not mean in JSON Schema?
The not keyword accepts an instance when its subschema rejects that instance, and rejects it when the subschema accepts. Its value is a schema, rather than an array of branches. Other keywords alongside not still apply independently, so define the allowed input domain as well as the forbidden subset.
Does not remove a forbidden property?
JSON Schema not expresses an acceptance constraint; it does not instruct a validator to delete a property or repair a document. Reject the forbidden state at the application boundary, or implement a separately documented transformation. Verify the input remains unchanged when your validation pipeline is intended to be read-only.
How can I forbid two fields together?
For an object, place a required array containing both field names inside not. That rejects objects containing both names while allowing either name alone or neither name. Add separate type and value constraints as needed, and use additional rules if the contract requires at least one of the fields.
Why does a negated properties rule reject an empty object?
A properties subschema does not require its named property to exist. An empty object can therefore satisfy that inner schema, causing not to reject the object. Include required inside the negated schema when the forbidden condition should apply only to objects that actually contain the named property.
Can a JSON viewer run not keyword validation?
A JSON viewer can help inspect the structure of a sample payload, but viewing or parsing valid JSON does not prove compliance with a separate schema. Run the payload through a validator supporting the selected dialect, then check that the application uses the result before persistence or other side effects.

Working with JSON right now?

Paste it into JSON Prettifier to format, validate, or minify it. Everything runs in your browser.

Open JSON Prettifier