JSON Schema oneOf vs anyOf vs allOf Explained

Last updated:

JSON Schema's composition keywords — oneOf, anyOf, allOf, and not — are the building blocks of complex validation logic. Choosing the wrong keyword leads to silent failures, confusing error messages, or performance problems. This guide explains each keyword precisely, with practical examples and Ajv behavior.

anyOf: At Least One Must Match

anyOf passes if the data validates against at least one of the listed subschemas. It short-circuits after the first match, making it the most performant composition keyword for non-exclusive alternatives.

// Nullable string — the most common anyOf use case
{
  "type": "object",
  "properties": {
    "middleName": {
      "anyOf": [
        { "type": "string", "minLength": 1 },
        { "type": "null" }
      ]
    }
  }
}

// Valid values for middleName:
// "Marie"  → matches string branch ✓
// null     → matches null branch ✓
// ""       → fails string (minLength: 1) AND null → invalid ✗
// 42       → neither branch → invalid ✗

In Draft 2019-09+, the preferred way to express a nullable string is with the type array shorthand:

// Equivalent in Draft 2019-09+ / OpenAPI 3.1
{ "type": ["string", "null"], "minLength": 1 }

// OpenAPI 3.0 style (no type array, use anyOf)
{
  "anyOf": [
    { "type": "string", "minLength": 1 },
    { "type": "null" }
  ]
}

anyOf also works for accepting multiple formats for the same field — a value that can be expressed as either a string ID or a numeric ID:

// Accept either a string ID ("user_abc123") or integer ID (42)
{
  "properties": {
    "userId": {
      "anyOf": [
        { "type": "string", "pattern": "^[a-z]+_[0-9a-z]+$" },
        { "type": "integer", "minimum": 1 }
      ]
    }
  }
}

// Ajv error when neither matches:
// "userId must match a schema in anyOf"
// → with verbose: true, each branch error is shown

anyOf Performance Tip

Because anyOf short-circuits, put the most common case first. For a nullable field where 95% of values are strings, put the string branch before the null branch to avoid evaluating both for most requests.

oneOf: Exactly One Must Match

oneOf passes only when the data validates against exactly one of the listed subschemas. Unlike anyOf, it cannot short-circuit — all branches must be evaluated to confirm exactly one matches. Use it when the alternatives are mutually exclusive by design.

// Shape union — circle and rectangle are mutually exclusive
{
  "oneOf": [
    {
      "type": "object",
      "properties": {
        "shape":  { "const": "circle" },
        "radius": { "type": "number", "exclusiveMinimum": 0 }
      },
      "required": ["shape", "radius"],
      "additionalProperties": false
    },
    {
      "type": "object",
      "properties": {
        "shape":  { "const": "rect" },
        "width":  { "type": "number", "exclusiveMinimum": 0 },
        "height": { "type": "number", "exclusiveMinimum": 0 }
      },
      "required": ["shape", "width", "height"],
      "additionalProperties": false
    }
  ]
}

// Valid: { "shape": "circle", "radius": 5 }
// Invalid (two branches match if const is removed): ambiguous data

The oneOf Trap: Accidentally Matching Multiple Branches

A common mistake is using oneOf for a nullable field:

// WRONG: oneOf for nullable — can cause unexpected failures
{
  "oneOf": [
    { "type": "string" },
    { "type": "null" }
  ]
}
// If you later add { "minLength": 0 } to the string branch,
// an empty string "" still matches "string" but the schema
// is no longer strictly mutually exclusive by intent.

// CORRECT: use anyOf for nullable types
{
  "anyOf": [
    { "type": "string" },
    { "type": "null" }
  ]
}

allOf: All Must Pass (Composition)

allOf requires the data to satisfy every listed subschema. It is the intersection operator — equivalent to TypeScript's A & B. Its primary use is schema inheritance: extend a base schema with additional properties.

{
  "$defs": {
    "BaseResource": {
      "type": "object",
      "properties": {
        "id":        { "type": "string" },
        "createdAt": { "type": "string", "format": "date-time" },
        "updatedAt": { "type": "string", "format": "date-time" }
      },
      "required": ["id", "createdAt"]
      // IMPORTANT: no additionalProperties: false here
    }
  },
  "allOf": [
    { "$ref": "#/$defs/BaseResource" },
    {
      "properties": {
        "title":   { "type": "string", "minLength": 1 },
        "content": { "type": "string" },
        "tags":    { "type": "array", "items": { "type": "string" } }
      },
      "required": ["title"],
      "unevaluatedProperties": false
    }
  ]
}

// Valid: { "id": "post_1", "createdAt": "2026-01-01T00:00:00Z", "title": "Hello" }
// Invalid: { "id": "post_1", "createdAt": "...", "title": "Hi", "unknown": true }
//  → fails unevaluatedProperties: false

allOf for Layered Constraints

allOf can layer multiple constraint schemas without inheritance:

// A string that is both a valid email AND shorter than 100 chars
{
  "allOf": [
    { "type": "string", "format": "email" },
    { "maxLength": 100 }
  ]
}

// Percentage value between 0 and 100 inclusive, multiple of 5
{
  "allOf": [
    { "type": "number", "minimum": 0, "maximum": 100 },
    { "multipleOf": 5 }
  ]
}
// Valid: 0, 5, 50, 100
// Invalid: 3, 101, -5

not: Invert a Schema

not inverts the validation result of a schema. Data is valid under not only when it fails validation against the inner schema.

// Reject null values
{ "not": { "type": "null" } }
// null   → invalid (matches inner schema) ✗
// "text" → valid (does not match inner schema) ✓

// Reject specific string values
{
  "type": "string",
  "not": { "enum": ["admin", "root", "superuser"] }
}
// "alice" → valid ✓
// "admin" → invalid ✗

// Reject objects with a specific property
{
  "type": "object",
  "not": {
    "required": ["password"]
  }
}
// { "username": "alice" }            → valid ✓
// { "username": "alice", "password": "secret" } → invalid ✗

// Combined: a string that is NOT empty and NOT "default"
{
  "allOf": [
    { "type": "string", "minLength": 1 },
    { "not": { "const": "default" } }
  ]
}

Ajv error messages for not are inverted. When validation fails, Ajv reports "must NOT be valid" — which means the data matched the inner schema when it shouldn't have. This is often confusing; consider wrapping not schemas with an errorMessage (via ajv-errors) to provide a human-readable error.

Discriminated Unions with oneOf + discriminator

A discriminated union uses a shared literal field (the "discriminator") to route validation to exactly one schema variant. The const keyword on the discriminator field makes the branches mutually exclusive by construction, solving the oneOf ambiguity problem.

// Webhook event — three mutually exclusive shapes
{
  "$defs": {
    "PaymentSucceeded": {
      "type": "object",
      "properties": {
        "type":     { "const": "payment.succeeded" },
        "amount":   { "type": "number", "minimum": 0 },
        "currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
        "chargeId": { "type": "string" }
      },
      "required": ["type", "amount", "currency", "chargeId"],
      "additionalProperties": false
    },
    "UserCreated": {
      "type": "object",
      "properties": {
        "type":   { "const": "user.created" },
        "userId": { "type": "string" },
        "email":  { "type": "string", "format": "email" }
      },
      "required": ["type", "userId", "email"],
      "additionalProperties": false
    },
    "PaymentRefunded": {
      "type": "object",
      "properties": {
        "type":     { "const": "payment.refunded" },
        "amount":   { "type": "number", "minimum": 0 },
        "reason":   { "type": "string" },
        "chargeId": { "type": "string" }
      },
      "required": ["type", "amount", "chargeId"],
      "additionalProperties": false
    }
  },
  "oneOf": [
    { "$ref": "#/$defs/PaymentSucceeded" },
    { "$ref": "#/$defs/UserCreated" },
    { "$ref": "#/$defs/PaymentRefunded" }
  ]
}

Because each branch has {"const": "..."} on the type field, only one branch can ever match a given input — which is exactly what oneOf requires.

OpenAPI 3.1 discriminator Keyword

# OpenAPI 3.1 — discriminator keyword hints tooling without changing validation
components:
  schemas:
    WebhookEvent:
      oneOf:
        - $ref: '#/components/schemas/PaymentSucceeded'
        - $ref: '#/components/schemas/UserCreated'
        - $ref: '#/components/schemas/PaymentRefunded'
      discriminator:
        propertyName: type
        mapping:
          payment.succeeded: '#/components/schemas/PaymentSucceeded'
          user.created:      '#/components/schemas/UserCreated'
          payment.refunded:  '#/components/schemas/PaymentRefunded'

The discriminator keyword is an OpenAPI extension — JSON Schema itself does not define it. It tells code generators (Redocly, openapi-typescript) which schema to deserialize to based on the discriminator value, without requiring them to try every branch.

Ajv Error Messages for Composition Keywords

Understanding how Ajv reports composition errors helps you write schemas that produce useful error messages.

import Ajv from 'ajv'

const ajv = new Ajv({ allErrors: true, verbose: true })

const schema = {
  oneOf: [
    { type: 'string', minLength: 3 },
    { type: 'number', minimum: 0 }
  ]
}

const validate = ajv.compile(schema)

// Test 1: value matches neither branch
validate(true)
console.log(validate.errors)
// [{ keyword: 'oneOf', message: 'must match exactly one schema in oneOf',
//    params: { passingSchemas: null } }]

// Test 2: value matches both branches (impossible with these schemas,
// but would look like:)
// [{ keyword: 'oneOf', params: { passingSchemas: [0, 1] } }]

// Test 3: anyOf — shows per-branch errors with allErrors: true
const anyOfSchema = {
  anyOf: [
    { type: 'string', minLength: 5 },
    { type: 'number', minimum: 10 }
  ]
}
const validate2 = ajv.compile(anyOfSchema)
validate2(3)
// → errors include both branch failures:
//   "must be string", "must be >= 10"

Custom Error Messages with ajv-errors

import Ajv from 'ajv'
import ajvErrors from 'ajv-errors'

const ajv = new Ajv({ allErrors: true, jsonPointers: true })
ajvErrors(ajv)

const schema = {
  oneOf: [
    { type: 'string' },
    { type: 'number' }
  ],
  errorMessage: 'must be a string or a number, not a boolean or object'
}

const validate = ajv.compile(schema)
validate(true)
// → [{ message: 'must be a string or a number, not a boolean or object' }]

Performance: Ordering Matters

The performance characteristics of composition keywords differ significantly at scale.

// anyOf — short-circuits after first match
// Put the most common case FIRST
{
  "anyOf": [
    { "type": "string" },   // 90% of values → match here, stop
    { "type": "number" },   // 9% of values
    { "type": "null" }      // 1% of values
  ]
}

// oneOf — ALL branches evaluated regardless
// Put the cheapest (fastest-failing) branch FIRST
// to fail fast on invalid data (all branches fail quickly)
{
  "oneOf": [
    { "properties": { "type": { "const": "a" } }, "required": ["type"] },
    { "properties": { "type": { "const": "b" } }, "required": ["type"] },
    { "properties": { "type": { "const": "c" } }, "required": ["type"] }
  ]
}

// allOf — all schemas always evaluated
// Put the schema most likely to fail FIRST for early rejection
{
  "allOf": [
    { "type": "object" },                // cheap type check first
    { "required": ["id", "name"] },      // required fields next
    { "$ref": "#/$defs/ComplexSchema" }  // expensive check last
  ]
}

For high-throughput validation (thousands of requests per second), prefer anyOf over oneOf for nullable and alternative-type fields. Reserve oneOf for genuinely mutually exclusive discriminated unions where the const-based discriminator makes the branch evaluation fast anyway.

Ajv's if/then/else as a oneOf Alternative

// if/then/else is often faster than oneOf for discriminated unions
// because it uses the discriminator to skip irrelevant branches
{
  "type": "object",
  "required": ["type"],
  "properties": {
    "type": { "type": "string" }
  },
  "if":   { "properties": { "type": { "const": "circle" } } },
  "then": { "required": ["radius"], "properties": { "radius": { "type": "number" } } },
  "else": {
    "if":   { "properties": { "type": { "const": "rect" } } },
    "then": {
      "required": ["width", "height"],
      "properties": {
        "width":  { "type": "number" },
        "height": { "type": "number" }
      }
    }
  }
}

Comparison Table

KeywordValidation RulePrimary Use CaseError ClarityPerformance
anyOfAt least 1 subschema matchesNullable types, alternative formatsGood — shows all branch errorsFastest — short-circuits
oneOfExactly 1 subschema matchesDiscriminated unionsPoor — generic "exactly one" errorSlowest — all branches evaluated
allOfAll subschemas must matchComposition/inheritanceGood — per-schema errorsAll evaluated, fail-fast ordering helps
notMust NOT match inner schemaExclusion constraintsConfusing — inverted senseFast — single schema check

Definitions

oneOf
A JSON Schema keyword that requires the instance to be valid against exactly one of the provided subschemas. All subschemas are always evaluated to verify mutual exclusivity.
anyOf
A JSON Schema keyword that requires the instance to be valid against at least one of the provided subschemas. Validators short-circuit after finding the first matching schema, making it more performant than oneOf.
allOf
A JSON Schema keyword that requires the instance to be valid against all listed subschemas simultaneously. The JSON Schema equivalent of TypeScript's intersection type (A & B).
Discriminated union
A pattern where a shared literal field (the "discriminator", typically type or kind) uniquely identifies which schema variant applies to a given object. Makes oneOf branches mutually exclusive by construction, improving both correctness and error messages.
Keyword short-circuiting
An optimization where a validator stops evaluating subschemas as soon as the result is determined. anyOf short-circuits on the first match (success). allOf can short-circuit on the first failure. oneOf cannot short-circuit because all branches must be checked.

FAQ

What is the difference between oneOf and anyOf in JSON Schema?

oneOf requires the data to match exactly one subschema — fails if zero or two or more match. anyOf requires at least one match — passes as long as one subschema validates the data. Use anyOf for nullable types and non-exclusive alternatives. Use oneOf for mutually exclusive discriminated unions.

When should I use anyOf instead of oneOf?

Use anyOf whenever the subschemas are not strictly mutually exclusive or when a value satisfying multiple schemas is acceptable. The canonical case is nullable fields: {"anyOf": [{"type": "string"}, {"type": "null"}]}. anyOf also gives cleaner error messages and better performance than oneOf.

How does allOf work in JSON Schema?

allOf requires the data to be valid against every listed subschema. Use it for inheritance: {"allOf": [{"$ref": "#/$defs/Base"}, {"properties": {"extra": {...}}}]}. Never put additionalProperties: false in base schemas used with allOf — use unevaluatedProperties: false in the final composed schema instead.

What does the not keyword do in JSON Schema?

not inverts a schema — data is valid only if it fails validation against the inner schema. {"not": {"type": "null"}} rejects null. {"not": {"enum": ["admin"]}} rejects the string "admin". Ajv error messages for not say "must NOT be valid" which reads as the data matched what it should not have matched.

What is a discriminated union and how do I implement it in JSON Schema?

A discriminated union uses a literal field (e.g., type) with const to make oneOf branches mutually exclusive. Each branch has {"const": "uniqueValue"} on the discriminator field so only one branch can ever match a given input. In OpenAPI 3.1, add a discriminator keyword with propertyName and mapping to help code generators route efficiently.

Why do oneOf Ajv errors say "must match exactly one schema in oneOf" with no detail?

Ajv reports a generic top-level error because when zero or multiple branches match, it cannot identify the "intended" branch. Fix this by adding const discriminators to make branches mutually exclusive, enabling allErrors: true on the Ajv instance for per-branch detail, or using ajv-errors to set a custom errorMessage on the oneOf schema.

Does the order of subschemas in anyOf or oneOf affect validation?

For anyOf: yes, order affects performance. Ajv evaluates left to right and short-circuits after the first match — put the most common case first. For oneOf: order does not affect correctness (all branches are always evaluated) but put the cheapest-to-fail branches first to speed up rejection of invalid data. For allOf: put cheap type checks first and expensive $ref schemas last.

Can I combine oneOf, anyOf, and allOf together?

Yes. A common pattern: allOf with a base $ref plus an anyOf for optional variant groups. Or oneOf for the top-level discriminated union, with each branch using allOf to compose from base schemas. The key rule: be precise about which keyword expresses your intent at each level — wrong choices lead to silent false positives or confusing error messages.

Further reading and primary sources