Private beta: Request early access - join the beta

zenitya
All posts

Guide3 min read

Designing JSON schemas that survive change

Practical conventions for designing JSON data formats that can evolve over time without breaking the things that read them.

Zenitya TeamZenitya

When it comes to passing structured data between systems, JSON is the common choice, however the format itself does not protect you from the problems that appear as a schema evolves over time. A payload that two services agree on today will need to change, and the difficulty is rarely the first version, it is the fifth, by which point several producers and consumers depend on the shape. In this guide we set out a few conventions that, in our experience, make a JSON format easier to evolve without breaking the code that reads it.

Prefer additive changes

The single most useful habit is to treat the schema as append-only wherever possible, which means adding new optional fields rather than renaming or removing existing ones. This is because consumers tend to ignore fields they do not recognize, so adding an optional field is usually safe, while renaming a field is a breaking change for everyone who still reads the old name. When a field genuinely must be removed, it is safer to deprecate it first, keep emitting it for a transition period, and remove it only once the consumers have moved on. This way the producer and the consumers do not have to be deployed in lockstep, which is often impractical.

Be explicit and avoid overloading

A second convention is to keep each field's type stable and explicit, and to avoid overloading a single field with several meanings. For example, a field that is sometimes a string and sometimes an object is difficult to consume safely, because every reader has to branch on the runtime type, and those branches drift apart over time. It is usually better to introduce a discriminator, for example a `type` field on each object, so the reader can decide how to interpret the rest of the structure from one predictable place:

block.jsondiscriminated
{
  "type": "metric",
  "title": "Active users",
  "value": 12480,
  "delta": { "direction": "up", "percent": 4.2 }
}

With a discriminator in place, a consumer can switch on `type` and handle each variant explicitly, and new variants can be added later without disturbing the existing ones. This pattern also makes validation straightforward, because each `type` can have its own set of required fields, and anything that does not match a known variant can be rejected early rather than failing somewhere deep in the rendering code.

Version deliberately, validate at the edge

When breaking changes are unavoidable, it is better to version the format deliberately than to let it drift, for example by including a `schemaVersion` field so a consumer can detect which shape it is reading and respond accordingly. Alongside versioning, validating the payload at the boundary, as it enters the system rather than deep inside it, tends to save a great deal of debugging, because a malformed payload is caught with a clear error at the edge instead of producing a confusing failure later. We have found that a single schema definition shared by the producer and the consumer (e.g., one Zod or JSON Schema definition used on both sides) is the most reliable way to keep the two ends in agreement.

In summary, a JSON format that lasts is mostly the result of a few disciplined habits: add rather than rename, keep types explicit and discriminated, version intentionally, and validate at the edge. None of these are difficult on their own, however applying them consistently is what keeps a format usable after several rounds of change, and it is far cheaper to adopt them early than to retrofit them once many systems already depend on the shape.

Further reading

Zenitya Team writes about generated reports, structured output for agents, and the practical side of turning analysis into something a team can open.