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 nullable → type: [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 example → examples 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.
Related
- URL Path vs Header Versioning — up-link: the section on where the version identifier lives, which the spec dialect underpins
- API Versioning & Deprecation — up-link: the parent reference covering versioning strategy, deprecation signals, and changelog automation
- Media-Type Versioning with Content Negotiation — the sibling technique for versioning representations via the
Acceptheader - Generating API Changelogs from OpenAPI Diffs — diff a 3.0 and 3.1 document to surface the dialect changes as a changelog
- Contract Linting & Governance — enforce dialect rules in CI so a stray
nullablenever merges into a 3.1 spec