Skip to main content

Fixing additionalProperties false Validation Failures

Symptom: validation fails with a message naming a property your schema clearly defines.

$ ajv validate -s validation-problem.yaml -d fixture.json --spec=draft2020
data must NOT have additional properties (additionalProperty: "title")
data must NOT have additional properties (additionalProperty: "status")

title and status are declared — in the base schema this one composes with. This guide is part of JSON Schema Keywords for API Payloads and covers the composition blindness that causes it, plus the other three situations that produce the same message.

Root Cause: additionalProperties Sees One Schema Object

additionalProperties asks a narrow question: which properties are declared right here, alongside me? Properties contributed by a $ref inside an allOf are declared somewhere else, so it does not count them.

# The base — perfectly fine on its own.
Problem:
  type: object
  required: [type, title, status]
  properties:
    type: { type: string }
    title: { type: string }
    status: { type: integer }

# The extension — broken.
ValidationProblem:
  allOf:
    - $ref: '#/components/schemas/Problem'
  additionalProperties: false     # ← sees only `errors`, below
  required: [errors]
  properties:
    errors: { type: array, items: { $ref: '#/components/schemas/FieldError' } }
What the keyword can and cannot see A dashed boundary marks the schema object containing additionalProperties: it encloses only the errors property. The base schema's three properties sit outside that boundary and are therefore treated as unknown extras. ValidationProblem — the composed schema allOf branch: Problem type title status rejected as extras what additionalProperties can see errors and nothing else The keyword is not broken — it is answering a narrower question than the one you asked.

Fix 1: Use unevaluatedProperties on Composed Objects

ValidationProblem:
  allOf:
    - $ref: '#/components/schemas/Problem'
  unevaluatedProperties: false     # considers every property any branch evaluated
  required: [errors]
  properties:
    errors: { type: array, items: { $ref: '#/components/schemas/FieldError' } }

Why this works: unevaluatedProperties runs after every applicable subschema — including allOf branches, and including any then branch that matched — and only complains about properties none of them touched. That is the question you meant to ask.

Availability matters: it arrived in draft 2019-09, so it needs OpenAPI 3.1 and a validator on 2019-09 or 2020-12. On OpenAPI 3.0 the workaround is to inline the base’s properties into the extension, which reintroduces the duplication $ref existed to remove — a good reason to move to 3.1.

Fix 2: Do Not Close Response Schemas at All

The second common source of this error is a consumer validating responses against a closed schema. Every field the provider adds then breaks that consumer — turning a deliberately additive, compatible change into an outage.

# Request body: close it. Typos and unexpected fields are bugs.
CreateOrderRequest:
  type: object
  required: [customer_id, items]
  additionalProperties: false
  properties:
    customer_id: { type: string }
    items: { type: array, items: { $ref: '#/components/schemas/LineItem' } }

# Response body: leave it open. New fields must not break existing readers.
Order:
  type: object
  required: [id, status, total]
  properties:
    id: { type: string }
    status: { type: string }
    total: { $ref: '#/components/schemas/Money' }
  # deliberately no additionalProperties — additive changes stay compatible
Close requests, leave responses open On the request side a closed schema rejects a mistyped field before it silently disappears. On the response side a closed schema rejects a newly added field, breaking a consumer for a change that was meant to be compatible. request — closed client sends "custmer_id" (typo) 400 with the offending property named without closing: the field is dropped and the order is created for nobody response — closed by mistake provider adds an optional "settled_at" every strict consumer starts failing an additive change became breaking for a reason nothing in the diff shows

Fix 3: Close oneOf Branches Deliberately

The third variant appears with unions. Open branches match more payloads than intended, and oneOf’s exactly-one rule then fails on a payload that should have matched one branch cleanly.

PaymentMethod:
  oneOf:
    - $ref: '#/components/schemas/CardPayment'
    - $ref: '#/components/schemas/BankPayment'
  discriminator:
    propertyName: kind
    mapping: { card: '#/components/schemas/CardPayment',
               bank: '#/components/schemas/BankPayment' }

CardPayment:
  type: object
  required: [kind, last4]
  additionalProperties: false      # correct here — this object is NOT composed
  properties:
    kind: { type: string, const: card }
    last4: { type: string, pattern: '^[0-9]{4}$' }

additionalProperties: false is right on each branch because a branch is a plain object. The distinction is consistent throughout: closed plain objects, unevaluatedProperties on composed ones. More on the union mechanics in choosing between oneOf, anyOf and allOf.

Fix 4: Allow Typed Extras Where They Belong

additionalProperties accepts a schema, not only a boolean. Use it for genuinely open-ended maps:

Three settings, three contracts Omitting the keyword admits anything of any shape; setting it to false admits nothing beyond the declared properties; setting it to a schema admits extras that conform, which is the right model for a metadata map. omitted any extra, any shape no size bound at all right for responses, wrong for requests false nothing beyond the declared properties right for requests, breaking for responses a schema extras allowed, but each must match and be bounded right for an open-ended metadata map
Order:
  type: object
  required: [id, status]
  properties:
    id: { type: string }
    status: { type: string }
    metadata:
      type: object
      description: Caller-defined key/value pairs; keys are opaque to us.
      additionalProperties:
        type: string
        maxLength: 512               # every extra value must be a bounded string
      maxProperties: 50              # and there is a ceiling on how many

This is strictly better than leaving metadata unconstrained: callers get a clear contract, and the API gets a bound on how much arbitrary data one request can carry.

Verification

// additional-properties.test.ts
const validate = ajv.compile(validationProblemSchema);

test("a composed payload with base fields is accepted", () => {
  expect(validate({
    type: "https://example.com/errors/validation",
    title: "Validation failed",
    status: 422,
    errors: [{ path: "email", code: "INVALID_FORMAT" }],
  })).toBe(true);
});

test("a genuinely unknown property is still rejected", () => {
  expect(validate({
    type: "https://example.com/errors/validation",
    title: "Validation failed",
    status: 422,
    errors: [{ path: "email", code: "INVALID_FORMAT" }],
    stack_trace: "…",                              // must not be allowed through
  })).toBe(false);
  expect(validate.errors![0].keyword).toBe("unevaluatedProperties");
});

test("the response schema stays open to additive change", () => {
  const order = ajv.compile(orderSchema);
  expect(order({ id: "ord_42", status: "paid", total: { amount: 1, currency: "EUR" },
                 settled_at: "2026-07-29T10:00:00Z" })).toBe(true);
});

The second test is the one that stops the fix going too far. Swapping to unevaluatedProperties should keep the object closed — if an unknown property now passes, the keyword was dropped rather than replaced.

Edge Cases and Caveats

  • Dereferencing before validation. Some pipelines inline every $ref before handing the schema to a validator. That accidentally “fixes” additionalProperties in one service while another, validating the un-inlined document, still fails — which is why the same payload can behave differently in two places.
  • unevaluatedProperties and if/then. Properties matched inside a then branch count as evaluated. This is usually what you want, but it means a conditional can quietly widen what a closed object accepts.
  • Performance. unevaluatedProperties requires the validator to track annotations across the whole composition, which is measurably slower than a plain property check. It is rarely the bottleneck, but it is worth knowing when validating very large payloads at high rates.
  • Generated clients. A closed request schema usually generates an exact object type; an open one generates a type with an index signature. If your generated types have started accepting arbitrary keys, an additionalProperties change is the likely cause.
  • Strict mode interaction. Ajv’s strict: true warns when it sees additionalProperties alongside allOf, precisely because the combination is almost always a mistake. Leave strict mode on and treat the warning as the diagnosis.

Choosing a Default for New Schemas

Rather than deciding per schema and remembering, adopt a default and encode it in the ruleset. The one that holds up: request bodies closed, response bodies open, nested value objects closed.

Requests closed catches the typo that would otherwise be silently dropped — the single highest-value application of the keyword, because a dropped field usually means data loss the caller believes succeeded.

Responses open preserves the compatibility promise. Any consumer validating a response strictly has opted into breaking on additive change; you cannot stop them, but you should not encode it in the contract yourself.

Nested value objects — a Money, an Address, a Coordinates — closed in both directions, because they are structural types rather than evolving resources, and an unknown key inside one is a bug wherever it appeared.

A lint rule that flags a request schema without a closing keyword makes the default automatic. It is a one-line Spectral rule and it removes the decision from every future schema.

What to Do About an Existing Open Request Schema

Closing a request schema that has been open is technically a breaking change: a caller sending an extra field starts getting a 400. In practice the callers sending extra fields are almost always sending typos, so the pragmatic sequence is to log unknown properties for one release, look at what is actually being sent, and then close. The log usually contains two things — a handful of typos worth telling those callers about, and one field a consumer has been relying on that you never documented.

The One-Line Diagnosis

When this error appears, the first question is whether the schema uses allOf. If it does, the keyword is almost certainly the problem rather than the payload, and switching to unevaluatedProperties resolves it. If it does not, the payload genuinely carries a field the schema never declared, and the fix belongs on the calling side.

Frequently Asked Questions

Why does additionalProperties reject fields my base schema defines?

Because it only considers properties declared in the same schema object. Properties contributed by a branch of an allOf are invisible to it, so they look like unknown extras. unevaluatedProperties is the keyword that sees the whole composition.

Should response schemas ever be closed?

Rarely. Closing a response makes every additive change breaking for a consumer validating strictly, which is the opposite of the compatibility promise most APIs want. Close request bodies; leave responses open.

Does a discriminator require closed branches?

Not technically, but a oneOf whose branches are open will often match more than one branch, which fails the exactly-one rule. Closing the branches or relying on a discriminator to select one is what makes the union deterministic.

Can I allow specific extra fields?

Yes — additionalProperties takes a schema, not just a boolean. Setting it to a schema means any property not otherwise declared must match that schema, which is the right model for open-ended metadata maps.

Why does the same payload pass in one service and fail in another?

Different validator configurations. One is enforcing additionalProperties from the document and the other is not, or one dereferences the composition before validating and the other does not. Compare the compiled schemas, not the source files.