← Back to blog
Data Formats·August 10, 2026·6 min read

JSON Schema in Practice: What allOf, oneOf, and additionalProperties Actually Do

JSON Schema looks like static typing for JSON, but its composition keywords follow evaluation rules that trip up most people writing their first real-world schema — here's the mental model that actually holds up.

A payment API team ships a schema that validates a paymentMethod field against three shapes: card, bank_transfer, and wallet. They use oneOf because that's the keyword that sounds right — "one of these three shapes." Local tests pass. Two weeks later, a payload with both a cardNumber and an accountNumber field sneaks past validation into a downstream reconciliation job, because none of the three sub-schemas had additionalProperties: false, so a card-shaped object that also happened to satisfy the bank-transfer branch matched two of the three schemas — which should have made oneOf reject it, except the actual bug was the opposite: it matched zero and got rejected in a case where it should have matched one, silently failing open in the application's error-handling path instead of loud in the API layer. The team's actual mistake wasn't a typo. It was believing JSON Schema works like a type system, when it actually works like a constraint solver.

That distinction is the source of almost every non-trivial JSON Schema bug, so it's worth making precise before touching the keywords.

JSON Schema doesn't check types, it evaluates constraints

A type system in TypeScript or Rust starts from a shape and asks "does this value match?" JSON Schema starts from a document and asks "how many keywords does this value satisfy?" Every keyword — type, properties, minimum, pattern, allOf, oneOf — is an independent assertion, and the schema as a whole passes only if every assertion at every level passes. There's no short-circuiting, no ordering, and critically, no keyword actually knows about any other keyword unless you compose them explicitly with allOf/oneOf/anyOf/not.

This is why properties alone doesn't restrict a JSON object to the keys you listed:

{
  "type": "object",
  "properties": {
    "cardNumber": { "type": "string" },
    "expiry": { "type": "string" }
  }
}

An object with cardNumber, expiry, and a completely unrelated accountNumber field validates fine against this schema. properties only describes constraints for keys that are present — it says nothing about keys that aren't listed. That's additionalProperties's job, and its default value is true. If you don't set it explicitly, your schema is permissive by default, which is exactly backwards from what most engineers assume walking in.

The composition keywords: allOf, oneOf, anyOf, not

Once you accept that keywords are constraints rather than type declarations, the four composition keywords stop being mysterious — they're just boolean combinators over sets of constraints.

KeywordPasses whenCommon useCommon mistake
allOfEvery sub-schema passesMerging a base schema with an extension (e.g. shared id/timestamp fields plus type-specific fields)Combining sub-schemas with conflicting additionalProperties: false, which silently rejects everything
anyOfAt least one sub-schema passesAccepting a field that can be a string or a numberUsed when oneOf was intended — anyOf doesn't catch a value that legitimately matches two branches by accident
oneOfExactly one sub-schema passesTrue discriminated unions (a paymentMethod that is either card or bank transfer, never both)Sub-schemas without additionalProperties: false, so an object can satisfy more than one branch and oneOf rejects a value that should have matched
notThe sub-schema failsExcluding a specific shape or valueOverused as a substitute for a positive constraint, producing error messages that only say what's forbidden, never what's expected

The oneOf trap from the opening example is the one that catches the most teams, because it's invisible until your data has a slightly-too-permissive shape. If your three payment-method branches all lack additionalProperties: false, an object with fields from two branches satisfies both — and oneOf requires exactly one match, so it fails the whole document. The validator error says "matches more than one schema," which reads like a data problem, not a schema problem, so people go debug the payload instead of the schema. The fix is almost always to close every branch of a oneOf with additionalProperties: false, or better, add a required discriminator field per branch ("required": ["cardNumber"]) so branches are structurally exclusive instead of relying on the validator to figure it out by elimination.

allOf's quieter failure mode: silently impossible schemas

allOf has the opposite failure shape. Because every sub-schema must pass simultaneously, combining two sub-schemas that each close the object with additionalProperties: false but declare different properties produces a schema that can never validate anything:

{
  "allOf": [
    {
      "type": "object",
      "properties": { "id": { "type": "string" } },
      "additionalProperties": false
    },
    {
      "type": "object",
      "properties": { "createdAt": { "type": "string" } },
      "additionalProperties": false
    }
  ]
}

An object with just id fails the second branch (unknown property id). An object with just createdAt fails the first branch. An object with both fails both branches, because each branch's additionalProperties: false treats the other branch's property as unrecognized. This schema has an empty solution set and no validator will warn you — it just rejects everything, and the failure looks like a data bug in every bug report that comes in. The rule of thumb: only close an object with additionalProperties: false in the outermost schema, never inside individual allOf branches you intend to merge.

$ref and the draft-version trap

The other place JSON Schema quietly breaks is $ref resolution combined with draft mismatches. Schema authors write $ref: "#/definitions/address" (the Draft-07 convention) and then run it against a validator configured for the 2020-12 spec, which renamed definitions to $defs and changed how $ref interacts with sibling keywords — in Draft-07, keywords alongside a $ref are ignored entirely; from 2019-09 onward, they're evaluated together. A schema that relies on the older behavior (a $ref plus a sibling description, expecting the description to be decorative) can start enforcing unexpected sibling constraints under a newer draft's validator, or vice versa. Most production incidents here aren't from writing an invalid schema — they're from writing a schema valid under one draft and validating it with a library defaulting to another. Pin the draft explicitly with $schema, and make sure the validator you test locally is the same version running in production; a passing local check against a different draft than your API gateway uses is worse than no check at all, because it creates false confidence.

A minimal checklist that catches most of this

None of this requires memorizing the full JSON Schema spec. It requires treating every keyword as an independent, additive constraint rather than a type annotation, and being deliberate about the two keywords — additionalProperties and oneOf — that silently change behavior based on what you didn't write. If you want to check a schema against real data before it ships, Utilix's JSON Schema Validator runs Draft-07 validation directly in the browser, which is a fast way to confirm a fix like closing an allOf branch actually resolves the failure before it goes anywhere near production.

#json-schema#json#data-validation#api-design#data-formats

Related reading

Data Formats
NDJSON vs JSON Arrays for Streaming: Why the Trailing Bracket Is the Problem
Data Formats
Base64 vs Base32 vs Base58: Why Bitcoin Doesn't Use the Encoding Your API Does
API Design
Webhook Signature Verification: How Stripe, GitHub, and Svix Actually Stop Forged Events