OpenAPI 3.0 vs 3.1 Schema Dialect Differences

This comparison belongs to the URL Path vs Header Versioning section of the API Versioning & Deprecation reference. Choosing where a version lives — path, header, or media type — is only half the problem; the spec dialect you write that contract in decides how it validates and what your generated clients look like. OpenAPI 3.1 is not a cosmetic point release. It realigns the specification’s schema object with full JSON Schema 2020-12, which changes nullable syntax, examples, numeric bounds, and adds top-level webhooks. This page lays out those differences side by side and explains their downstream effect on validators like ajv and on code generation.

Why the dialect changed

OpenAPI 3.0 defined its own schema object as a subset-and-superset of JSON Schema Draft 4/5 — close enough to look familiar, different enough that no standard JSON Schema validator could fully validate it. That mismatch forced every tool to ship an OpenAPI-specific schema engine. OpenAPI 3.1 ends the divergence: its schema object is JSON Schema 2020-12, so any compliant 2020-12 validator can check payloads directly. The cost of that alignment is a handful of syntax changes that make a 3.1 document reject if you feed it 3.0 constructs.

The decision trigger

You are reading this because one of these situations forced the question:

Symptom / decision trigger What it means
A validator rejects nullable: true as an unknown keyword Your tool loaded the 3.1 (2020-12) dialect against a 3.0 document
Generated client marks a field non-nullable that should accept null nullable was silently dropped during a partial migration
You need to document async/event callbacks the server sends 3.1’s top-level webhooks is the clean fit
exclusiveMinimum: true produces a validation error The boolean form is gone in 2020-12; it is now numeric
You want $ref alongside sibling keywords like description 3.1 allows it; 3.0 ignores siblings of $ref

If any row applies, the underlying cause is a dialect mismatch. The right response is a deliberate migration, governed the same way you would govern any contract change — see Contract Linting & Governance for enforcing dialect rules in CI so a stray nullable never merges. If the switch changes the wire contract for consumers, coordinate it through your changelog automation pipeline.

Side-by-side comparison

The core differences at a glance:

Concern OpenAPI 3.0 OpenAPI 3.1
openapi field 3.0.3 3.1.0
Schema dialect Custom subset (Draft 4/5-ish) JSON Schema 2020-12
Nullable field nullable: true type: [string, "null"]
Single example (schema) example: "abc" examples: ["abc"] (array)
exclusiveMinimum boolean flag beside minimum numeric bound (self-contained)
Sibling keywords beside $ref ignored allowed and applied
Event callbacks from server not modeled top-level webhooks object
Declaring the dialect implicit jsonSchemaDialect / $schema
Standard validator support none (needs OAS engine) any 2020-12 validator
type with multiple values not allowed array of types allowed

Nullable: the change that bites first

In 3.0, a field that may be null was expressed with the OpenAPI-only nullable keyword:

# OpenAPI 3.0.3
components:
  schemas:
    User:
      type: object
      properties:
        middle_name:
          type: string
          nullable: true

In 3.1 the nullable keyword does not exist. JSON Schema 2020-12 models “or null” by listing null as a member of the type array:

# OpenAPI 3.1.0
components:
  schemas:
    User:
      type: object
      properties:
        middle_name:
          type: [string, "null"]

This is the difference most likely to slip through a half-finished migration. A 3.1 parser silently ignores the unknown nullable, so middle_name becomes a plain non-nullable string — and a generated client then types it as string instead of string | null, crashing the first time the server returns null.

Examples, bounds, and webhooks

example (singular) on a schema object becomes examples (an array) in 2020-12:

# 3.0            →            # 3.1
# example: 42                  examples: [42]

Note that example still exists on media type and parameter objects in 3.1 — only the schema object switches to examples. This asymmetry trips up bulk find-and-replace migrations.

exclusiveMinimum and exclusiveMaximum change from a boolean modifier to a self-contained numeric bound:

# OpenAPI 3.0.3 — boolean modifier of `minimum`
minimum: 0
exclusiveMinimum: true

# OpenAPI 3.1.0 — numeric bound, no separate `minimum`
exclusiveMinimum: 0

OpenAPI 3.1 also adds a top-level webhooks object to describe requests your API sends to consumers — inverting the usual client-to-server direction that paths models:

# OpenAPI 3.1.0
openapi: 3.1.0
info:
  title: Billing API
  version: "2.0.0"
webhooks:
  invoicePaid:
    post:
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [invoice_id, paid_at]
              properties:
                invoice_id: { type: string }
                paid_at:
                  type: [string, "null"]
                  format: date-time
      responses:
        "200":
          description: Consumer acknowledged the event.

Declaring the dialect is now explicit. A 3.1 document may set jsonSchemaDialect at the root, and individual schema resources may carry $schema:

openapi: 3.1.0
jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base"

Step-by-step migration

Step 1 — Bump the version field

Change openapi: 3.0.3 to openapi: 3.1.0. This alone tells conformant tooling to load the 2020-12 dialect, which is why every other change below becomes necessary at once.

Step 2 — Rewrite every nullable

Convert nullable: true into a null member of type. A codemod is safer than hand edits at scale:

# migrate_nullable.py — rewrite 3.0 nullable into 3.1 type arrays
import sys, yaml

def fix(node):
    if isinstance(node, dict):
        if node.pop("nullable", False) is True and "type" in node:
            t = node["type"]
            node["type"] = [t, "null"] if isinstance(t, str) else list(t) + ["null"]
        for v in node.values():
            fix(v)
    elif isinstance(node, list):
        for v in node:
            fix(v)
    return node

doc = yaml.safe_load(open(sys.argv[1]))
doc["openapi"] = "3.1.0"
yaml.safe_dump(fix(doc), sys.stdout, sort_keys=False)

Step 3 — Rename schema-level example to examples

Only inside schema objects. Wrap the old singular value in an array. Leave example on parameter and media-type objects untouched.

Step 4 — Fix numeric bounds

Collapse the minimum + exclusiveMinimum: true pair into a single numeric exclusiveMinimum, and likewise for the maximum.

Step 5 — Revalidate with a 2020-12 validator

Confirm the migrated document validates under the new dialect. In Node, ajv needs its 2020-12 build:

import Ajv2020 from "ajv/dist/2020";

const ajv = new Ajv2020({ strict: false, allErrors: true });

// A 3.1 schema object is now a first-class 2020-12 schema.
const validate = ajv.compile({
  type: "object",
  required: ["id"],
  properties: {
    id: { type: "string" },
    middle_name: { type: ["string", "null"] },
    age: { type: "integer", exclusiveMinimum: 0 },
  },
});

console.log(validate({ id: "u1", middle_name: null, age: 33 })); // true

The equivalent in Python uses the jsonschema library with the 2020-12 validator:

from jsonschema import Draft202012Validator

schema = {
    "type": "object",
    "required": ["id"],
    "properties": {
        "id": {"type": "string"},
        "middle_name": {"type": ["string", "null"]},
        "age": {"type": "integer", "exclusiveMinimum": 0},
    },
}
Draft202012Validator.check_schema(schema)          # schema itself is legal 2020-12
Draft202012Validator(schema).validate({"id": "u1", "middle_name": None, "age": 33})

RFC and standard compliance

The alignment is with the JSON Schema specification suite rather than an IETF RFC per keyword:

Concern Standard Note
Schema dialect in OpenAPI 3.1 JSON Schema 2020-12 Core + Validation drafts
type arrays including "null" JSON Schema Validation 2020-12 §6.1.1 replaces nullable
exclusiveMinimum as a number JSON Schema Validation 2020-12 §6.2.3 replaces boolean form
$ref with sibling keywords JSON Schema Core 2020-12 §8.2.3 siblings now apply
OpenAPI base dialect URI OpenAPI Specification 3.1 §4.9 jsonSchemaDialect

Because 3.1 is standards-conformant, you can lint schema objects with generic JSON Schema tooling — which is exactly what the linting rules in Contract Linting & Governance rely on.

Validation and codegen impact

The practical fallout lands in two places: validators and generators.

Validators. Under 3.0 you needed an OpenAPI-aware engine that special-cased nullable. Under 3.1 a stock ajv/2020-12 validator works, so you can validate request and response bodies with the same library your non-OpenAPI JSON pipelines use. The catch: a validator loaded with the 2020-12 meta-schema will reject leftover 3.0 keywords, which is precisely why partial migrations fail loudly at validate time (good) or silently at codegen time (bad).

Codegen. Generators translate schema keywords into types. The nullabletype: [T, "null"] change flows straight through:

  // Generated TypeScript, from the User schema
- middle_name: string;          // 3.0 nullable dropped by a 3.1 generator
+ middle_name: string | null;   // 3.1 type: [string, "null"] preserved

A generator running in 3.1 mode against an unmigrated 3.0 document drops nullable and produces the non-nullable top line — a runtime crash waiting for the first null. Pin the generator’s OpenAPI mode to match the document, and add a smoke test that asserts nullable fields render as unions.

Common mistakes

Mistake Correct approach
Bumping openapi to 3.1.0 but leaving nullable: true in place Rewrite every nullable to a type array including "null"
Find-and-replacing exampleexamples everywhere Only rename on schema objects; leave parameter and media-type example alone
Keeping minimum + exclusiveMinimum: true Collapse into a single numeric exclusiveMinimum
Validating a 3.1 doc with a Draft-4 validator Use a 2020-12 validator (Ajv2020, Draft202012Validator)
Assuming 3.1 is a breaking API change It is a document/tooling migration; the wire contract is unchanged

FAQ

Is upgrading from OpenAPI 3.0 to 3.1 a breaking change for my API?

The runtime API contract — the URLs, methods, and payloads clients actually exchange — does not change. What changes is the document. Keywords such as nullable, the boolean exclusiveMinimum form, and the singular schema-level example are removed or altered, so a strict 3.1 parser rejects an unmigrated 3.0 document. Treat the upgrade as a tooling and governance migration rather than an API version bump, and it will not disturb consumers.

How do I express a nullable field in OpenAPI 3.1?

Use a JSON Schema 2020-12 type array that includes null, for example type: [string, "null"], in place of the 3.0 nullable: true keyword. The nullable keyword does not exist in 3.1; a conformant validator ignores or rejects it, and a code generator will produce a non-nullable type if you leave it in. Rewriting every nullable is the single most important step of the migration.

Do OpenAPI 3.1 documents validate with a standard JSON Schema validator?

Yes. Because 3.1 adopts the JSON Schema 2020-12 dialect for its schema objects, any compliant 2020-12 validator — ajv with the draft 2020-12 build, or Python’s jsonschema Draft202012Validator — can validate payloads against those schemas directly. This was not possible with the 3.0 subset dialect, which required an OpenAPI-specific validation engine that special-cased keywords like nullable.