Skip to content
datahacks.dev
JSON

Versioning a JSON Schema Without Breaking Consumers

Which schema changes break old producers and old consumers: a tested compatibility matrix, open versus closed objects, version numbers and a CI check.

8 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JSON logo.

A JSON Schema is a contract between whoever produces a document and whoever consumes it. The first version is easy. The second is where trouble starts: a producer adds a field, a consumer tightens a rule, and one side's deployment is a week behind the other's. Whether that change breaks anything depends on which keyword you touched and which direction the data flows. Rather than reason about it abstractly, I tested five changes to a small schema against four kinds of documents and wrote down the result as a table. After that come the version numbering, the compatibility check you can run in CI, and the situations where no schema discipline will save you.

Two directions of compatible

Two words get confused, so define them in terms of documents and schemas.

Backward compatible: a document that was valid under the old schema is still valid under the new one. This protects old producers. A consumer upgraded to the new schema can still read what the not-yet-upgraded producers send.

Forward compatible: a document valid under the new schema is also valid under the old one. This protects old consumers. A producer upgraded to the new schema can send data that consumers on the old schema still accept.

Which one you need depends on who deploys first. If consumers upgrade before producers (common for an internal service you control), you need backward compatibility. If producers can upgrade first (a public API, a partner feed, a mobile app that sends events), you need forward compatibility too, and you usually do not control the upgrade order at all. A change that is both is safe to deploy in any order. A change that is neither needs a coordinated release or a new schema version side by side.

The matrix

I built v1 and five variants, and validated four documents against each: an old document ({"id": 1, "event": "view"}), one with a new amount field, one using a new enum value buy, and one with an unknown key x.

Schemaold docwith amountwith event: buywith unknown key
v1 (closed, additionalProperties: false)okFAILFAILFAIL
v2, adds optional amountokokFAILFAIL
v2, adds required amountFAILokFAILFAIL
v2, adds enum value buyokFAILokFAIL
v2, drops additionalProperties: falseokokFAILok

Read each row as "what the schema in the first column accepts". The row for v1 is the schema as an old consumer sees it: it rejects every new field and every new enum value. That is the whole story of why closed schemas break consumers. Every row below it that adds something is backward compatible (the old document column stays ok) except one, the new required property, which rejects the old document because it has no amount.

Translating into rules:

  1. Adding an optional property is backward compatible. It is forward compatible only if the consumer's schema is open. With additionalProperties: false in v1, a document with amount fails against v1.

  2. Adding a required property is not backward compatible. All existing documents lack it. It is only safe once every producer sends it, which you cannot enforce from the schema.

  3. Adding an enum value is backward compatible and not forward compatible. Old consumers reject the new value.

  4. Removing an enum value, a property or a permitted type is the mirror image: forward compatible, not backward compatible. Existing documents that use it now fail.

  5. Tightening a limit (maximum from 100 to 50, a new pattern, a smaller maxLength) breaks backward compatibility. Loosening one breaks forward compatibility.

  6. Changing a type, integer to string for example, breaks both ways. Do not rename in place; add a new property and deprecate the old one.

Open or closed, decided early

The table shows why additionalProperties: false is a decision with consequences. A closed schema is excellent for catching typos in documents you receive: a client that sends nmae is told at once. It also means every future field is a breaking change for any consumer that validates with the old schema. A schema for a stream consumed by many parties should usually stay open, and validate only the fields it depends on. The consumer then ignores unknown keys, which is Postel's law applied to data: be strict about what you rely on and tolerant of what you do not recognize.

Where you do close an object, plan the escape hatch: a reserved extensions or x- property that stays open, or a schema version in the document so a consumer can choose which schema to apply. If you build schemas with allOf, close them with unevaluatedProperties, not additionalProperties, or the base's properties are rejected as unexpected (JSON Schema guide: closed objects and composition).

Numbering versions

Number the schema, not just the code. A scheme that works for contracts:

  • Patch (1.0.1): documentation changes, clearer descriptions. No validation behavior changes.

  • Minor (1.1.0): backward compatible changes: optional properties, new enum values (if consumers tolerate unknown ones), loosened limits.

  • Major (2.0.0): anything that makes a previously valid document invalid, or changes meaning.

Put the version in the schema's $id (https://example.com/schemas/event/1.1.json), and optionally in the data ("schema": "event/1.1") so a consumer can pick the right schema for each document. With the version in the data, an old consumer that sees event/2.0 can refuse the document by name instead of failing on a random field. The $id also anchors $ref resolution, so a new major version is a new file rather than an edit (reusing definitions with $ref in JSON Schema).

Keep old versions published and immutable. A schema that has been referenced by documents in the wild must keep validating them. When you release a major version, run both side by side for a deprecation period, and log which version each producer is still sending.

A script that checks both directions

The check does not need a framework. It needs two schemas and two sets of sample documents: documents that were valid under the old schema, and documents that are new. For v1 to v2 (v2 adds an optional amount and the enum value buy):

Python
import json, pathlib, sys
from jsonschema import Draft202012Validator as V

old, new = (V(json.load(open(p))) for p in sys.argv[1:3])
old_docs = sorted(pathlib.Path(sys.argv[3]).glob("*.json"))
new_docs = sorted(pathlib.Path(sys.argv[4]).glob("*.json"))
bad = 0
for d in old_docs:
    ok = new.is_valid(json.load(open(d))); bad += not ok
    print(f"backward  {d.name:12} against new schema: {'ok' if ok else 'FAIL'}")
for d in new_docs:
    ok = old.is_valid(json.load(open(d)))
    print(f"forward   {d.name:12} against old schema: {'ok' if ok else 'FAIL'}")
sys.exit(1 if bad else 0)
Text
backward  a-basic.json against new schema: ok
backward  b-click.json against new schema: ok
forward   a-basic.json against old schema: ok
forward   b-amount.json against old schema: FAIL
forward   c-buy.json   against old schema: FAIL

The exit status comes from the backward direction only: breaking old documents fails the build. The forward failures are printed as information, because whether they matter depends on whether old consumers exist. For a public contract, make forward failures fatal too, and the build will insist on a major version. The sample sets are the part that takes care. Keep a real example for every shape that has ever been produced, taken from production (with the personal data removed), and add one for each bug report. A compatibility check run against three toy documents proves little.

Tools exist that compare two schemas structurally, without samples, and report breaking changes. They are good at the clear cases (a removed property, a changed type) and weaker at semantic ones such as a changed pattern. Use them as a first filter and keep the sample-based check.

Changing the data, not just the schema

Some changes cannot be made compatible with a schema trick, and the data has to move.

Expand, then contract. To rename amount to total, first add total as an optional property and send both. Move consumers to total. Stop sending amount, mark it deprecated in the schema ("deprecated": true), and remove it in the next major version.

Make required in two steps. To require a new field, first make producers send it, while the schema still treats it as optional. Measure how many documents lack it (count in your logs or with jq), and when the count is zero for a full cycle, add it to required in a major version.

Tolerate unknown enum values on read. A consumer that maps events to behavior should have a default branch for values it does not know. The schema can stay open for the enum by listing documented values in a description and requiring only type: string, with validation of known values left to the code. That moves the compatibility from the contract to the consumer, where you can log and carry on.

Migrate stored documents. When JSON is stored in a database, old documents live forever. Either migrate them when the schema changes, or keep the reader tolerant of every version it has ever stored. A CHECK constraint on the column (see checking a JSON column against a schema in PostgreSQL) must be changed with NOT VALID first and validated after the cleanup.

When versioning is not the problem

The meaning changed, not the shape. An amount that used to be in cents and is now in dollars passes every schema. A schema cannot detect it. Rename the field, or version the meaning in the documentation and the data.

Nobody owns the contract. If producers and consumers are different teams with no process, schema versions are a bureaucracy that nobody follows. Start with a compatibility check in the producer's CI and a place consumers can see changes.

Only one party ever reads the data. For an internal file read by the code that wrote it, migrations in the code are simpler than a versioned contract.

The JSON Schema specification says how $id and the $schema keyword identify a version in the core specification, and the common compatibility terms are defined in the Confluent documentation on schema evolution and compatibility. For a visual comparison of two versions of a sample document, the JSON diff tool helps.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.