- Published on
An Added Field Can Still Be a Breaking Change
- Authors

- Name
- Mehdi Akiki
Reference
“We only added a field” is not enough evidence that an API or event change is compatible.
Many formats let old readers ignore unknown fields. Real applications can reject them, sign the entire object, route from field presence, or deserialize and reserialize through a model that discards data.
I test the complete consumer path.
Strict readers reject unknown fields intentionally
A Rust consumer may use Serde:
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Account {
id: String,
active: bool,
}
This old reader rejects:
{
"id": "acc_17",
"active": true,
"region": "eu"
}
The deny_unknown_fields documentation makes the behaviour explicit. Teams enable it to detect provider drift or typos. Adding region is breaking for this consumer even though JSON syntax permits extra properties.
JSON Schema can make the same closed-world choice with additionalProperties: false.
Signatures can cover the whole representation
Some systems canonicalize a JSON object and sign or hash it:
signature = HMAC(canonical_json(payload))
An old intermediary that parses only known fields and reserializes them removes the new field. The signature no longer matches downstream, or worse, different components calculate identity from different representations.
I define whether a signature covers raw bytes, a canonical complete object, or selected named fields. “Unknown fields are ignored” in application code does not mean cryptographic inputs ignore them.
Exhaustive application logic can still fail
The added field may introduce meaning through presence:
{"status": "paid", "dispute": {"state": "open"}}
An old consumer that only checks status == "paid" may release goods even though the new producer expects an open dispute to block fulfilment.
The field is optional structurally and mandatory semantically for some states.
A compatible change must preserve what old readers conclude, not only whether they can parse bytes.
Defaults can alter old writes
Suppose a server adds:
{"notifications_enabled": true}
An old client sends a full replacement without this unknown field. Does absence mean:
keep the current value
reset to default true
set false
field not supported by this client version
For PUT, merge, and patch APIs, omission semantics must be explicit. Otherwise an old client can erase or reset a value it never displayed.
Field masks, optimistic versions, or server-owned fields can protect the update boundary.
Round trips can silently delete the field
An old service can successfully parse new data by ignoring region, then emit its known object later:
new payload with region
-> old reader drops unknown field
-> old writer emits payload without region
If the service is a transparent relay, this is data loss. Some binary formats preserve unknown fields in generated messages specifically to make evolution safer; JSON-to-domain conversions often do not.
I test read-modify-write, not only read.
Size and cost are contract dimensions
One optional field can contain:
- an unbounded string;
- a large array;
- embedded base64 data;
- deeply nested objects;
- high-cardinality labels copied into indexes and logs.
The old request may fit a broker, database row, cache, signature buffer, or model context limit while the new one does not.
I define maximum encoded bytes, element counts, depth, and downstream expansion. Optional does not mean free.
Identity and ordering fields are especially dangerous
Adding tenant_id, version, or partition_key may reveal that old consumers used the wrong identity boundary.
For example:
old uniqueness: external_id
new uniqueness: tenant_id + external_id
Old consumers can now merge two tenants' objects despite accepting the new field. That is a security and correctness break with no parse error.
I version the contract or introduce a new route when old interpretation cannot remain safe.
Enum-like strings grow inside added structures
An added optional object may contain a kind field. Old generic code can log or store the object but fail when it assumes every nested variant is known.
I test unknown enum values and unknown nested fields independently. A top-level tolerant parser can contain a strict nested deserializer.
Deploy tolerant readers before writers
When adding a truly compatible field, I use an expand-and-contract rollout:
1. deploy readers that tolerate and preserve/ignore the field safely
2. verify old and new fixture tests in every critical consumer
3. begin producing the field within size and semantic limits
4. observe old-version traffic and round trips
5. only later make the field required, if ever, through a versioned change
A schema registry passing the producer schema does not prove all application consumers completed step one.
My added-field test matrix
I run:
| Path | Assertion |
|---|---|
| old reader, new payload | parses or rejects according to declared contract |
| new reader, old payload | absence has truthful meaning |
| old read-modify-write | unknown field preservation is as designed |
| signature verification | canonical representation remains consistent |
| full replacement from old client | new server-owned field is not erased |
| maximum new value | every transport and store remains within bounds |
| old business logic | new state cannot be misinterpreted dangerously |
| identity/partitioning | routing and uniqueness remain correct |
I keep a real fixture from each supported version and run it through the deployed adapters, not only a schema checker.
Adding a field expands the possible states of the message. It is compatible only when old readers can safely parse, interpret, preserve or ignore, route, sign, store, and rewrite those states according to the product contract.