Encoding and Evolution: How Old Code and New Code Share Data
You deploy version 2 that adds a photoURL field while version 1 is still serving traffic in a rolling upgrade. A version 1 node reads a version 2 record, updates favoriteNumber, and writes it back. If your format is wrong, photoURL disappears.
81 bytesJSON for Example 4-1 with whitespace removed
66 bytesSame record in MessagePack
59 bytesSame record in Thrift BinaryProtocol
34 bytesSame record in Thrift CompactProtocol
33 bytesSame record in Protocol Buffers
32 bytesSame record in Avro
2 to the power 53Integer precision limit cited for JSON in JavaScript
Key points
Data lives longer than the code that wrote it. Mixed versions are normal.
Text formats carry field names. Schema based binary formats can omit names.
Tags allow old readers to skip new fields. Names plus defaults allow Avro readers to adapt.
O1Derive the byte savings of tag based formats from the 81 byte JSON baseline.
O2Break down how a reader skips a field it does not recognize.
O3Argue when to choose tags with Protocol Buffers or Thrift versus names with Avro.
O4Predict which schema change breaks backward reading or forward reading.
Running exampleExample 4-1 used throughout: userName Martin, favoriteNumber 1337, interests [daydreaming, hacking]. The JSON below is the exact record measured at 81 bytes when whitespace is removed.
Encoding is translation from in memory objects to a byte sequence. Decoding is translation back from bytes to objects.
A relational database assumes one schema in force at one time. Schema on read databases allow mixed old and new formats.
Code also mixes. Server code uses rolling upgrade where a few nodes update at a time. Client code waits on users who may update late.
So old code, new code, old data, and new data coexist. Safe change has numbered requirements:
(1)New code can read old data. This is called backward compatibility.
(2)Old code can read new data. This is called forward compatibility.
(3)Old code that rewrites new data keeps fields it does not understand.
(4)Data written years ago remains readable after many code deploys.
Try it: drag the slider to roll the upgrade forward. Then switch the format. Watch which of the four requirements stays green.
One fleet, two code versions, two data versions. Tagged formats keep all four requirements green mid roll. A naive object model loses requirement (3) as soon as old code rewrites new data.
02
Mental model: bytes plus a dictionary
Think of in memory data as structures with pointers for fast CPU access. Think of encoded data as a self contained byte sequence for files or networks.
A pointer means nothing to another process. So you must flatten structures to bytes on write. You must rebuild structures from bytes on read.
The dictionary for that rebuilding is the schema. Text formats like JSON, XML, and CSV include field names in every record. Tag based formats replace names with small numbers. Name based Avro omits both names and tags and relies on the reader having the right dictionary.
Try it: select an encoding for the same Martin record. Bar length is bytes. The readout derives savings from the 81 byte JSON baseline.
Selected size
81 bytes
names in every record
Saved vs JSON
0 bytes
0 percent smaller
Where bytes go
field names
dictionary is inline
Same logical record, smaller physical record. Names cost bytes. Tags and schema order remove that cost. 1337 fits in two bytes instead of eight in the compact binary forms.
03
Mechanism: how tags keep (1) and (2) alive
Thrift and Protocol Buffers require a schema before encoding. Example Thrift schema:
A code generator turns this schema into classes for Java, C++, C#, and other languages.
Encoded data is a concatenation of fields. Each field has a field tag such as 1, 2, or 3. Each field has a type annotation. Each string or list has a length prefix. Unset fields are omitted.
Thrift BinaryProtocol writes type plus tag plus value and takes 59 bytes for Example 4-1. Thrift CompactProtocol packs type and tag delta into one byte and uses variable length integers and takes 34 bytes. Protocol Buffers uses a similar bit packing and takes 33 bytes. In all three, 1337 fits in two bytes instead of eight.
Schema resolution is simple. New tags can be added. Old code sees an unknown tag, reads the type annotation, and skips that many bytes. That preserves (2). New code still knows old tags. That preserves (1).
Failure cases:
Add a field as required. New code reading old data fails the required check. This breaks (1).
Remove a required field or reuse its tag. Old data with that tag becomes invalid or misread. This breaks (1) and (2).
Change int32 to int64. New code reads old data by filling missing bits with zero. Old code reading new data may truncate a value that does not fit in 32 bits.
Protocol Buffers allows optional to repeated evolution because repeated means the same tag appears many times. New code sees zero or one values from old data. Old code sees only the last value from new data. Thrift has a dedicated list type and does not allow that same evolution, but it supports nested lists.
Play it: follow one v2 record as a v1 reader walks it. Press Next. The reader knows tags 1 to 3 and must skip tag 4.
tag 1 : stringlen 6 + Martin
tag 2 : i64varint 1337 in 2 bytes
tag 3 : listlen 2 + daydreaming + hacking
tag 4 : string NEWlen + photoURL value
endstop, keep tag 4 opaque
Reader sees tag 1 string, length 6, decodes Martin. Known tag, so it keeps the value.
Reader sees tag 2 varint, decodes 1337 in two bytes. Known tag, so it keeps the value.
Reader sees tag 3 list, length prefix says two strings. Known tag, so it decodes both.
Reader sees unknown tag 4. It reads the type annotation string plus length, then skips that many bytes without parsing photoURL. This is the forward compatibility moment for (2).
Reader finishes and carries the skipped bytes forward opaque. Rewrite keeps photoURL, so (3) holds.
Skipping is possible because every field carries its own tag, type, and length. Old code does not need to understand tag 4 to preserve it.
04
Weakness and fix: Avro trades tags for reader and writer schemas
Tags have a price. Tags must be assigned by hand and never reused. That is painful for dynamically generated schemas such as dumping a relational table where columns map to fields.
Avro removes tags. Avro schema in IDL:
record Person {
string userName;
union { null, long } favoriteNumber = null;
array<string> interests;
}
Same schema in JSON form lists name and type for each field and gives favoriteNumber a default of null.
Avro encoding for Example 4-1 is 32 bytes. It is just values in schema order. A string is length plus UTF-8 bytes. An integer is variable length. Nothing marks field boundaries except the schema.
So decoding needs two schemas. The writer's schema is the version used to encode. The reader's schema is the version the reading code expects. The Avro library compares them side by side. This is called schema resolution. It matches fields by name, not by order. It ignores writer only fields. It fills reader only fields from defaults in the reader schema.
Rules to keep (1) and (2):
You may only add or remove a field that has a default value.
To allow null you must use a union type such as union {null, long}. Null alone is not a default unless null is a branch of the union.
Changing a field name needs an alias. That change stays backward compatible but not forward compatible.
Adding a union branch stays backward compatible but not forward compatible.
Price of the fix: the reader must learn the writer schema. Avro answers by context. Large files with many records store the writer schema once at the start in object container files. Databases with individually written records store a version number per record plus a registry of schemas, as in Espresso. Network connections negotiate the version once at setup, as in Avro RPC.
Try it: toggle fields on each side. Avro matches by name and fills gaps from reader defaults. Uncheck a default to see the break.
Writer schema v2, used to encode
Reader schema v1, expected by code
Avro has no tags in the bytes, only values in order. Resolution happens by comparing names. Writer only fields are ignored. Reader only fields need defaults.
05
Code: the read modify write trap
This runnable Python mirrors Figure 4-7. Old code knows only three fields. New data adds photoURL.
Show code
import json
import copy
# Data written by new code. Includes photoURL.
db_value = {
"userName": "Martin",
"favoriteNumber": 1337,
"interests": ["hacking"],
"photoURL": "http://example.com/martin.jpg"
}
# BAD: decode into narrow model object, then re-encode. Loses unknown field.
class PersonOld:
def __init__(self, d):
self.userName = d["userName"]
self.favoriteNumber = d["favoriteNumber"]
self.interests = d["interests"]
def toJSON(self):
return json.dumps({
"userName": self.userName,
"favoriteNumber": self.favoriteNumber,
"interests": self.interests
})
old = PersonOld(copy.deepcopy(db_value))
old.favoriteNumber = 42
print("BAD:", old.toJSON()) # photoURL is gone. Breaks (3).# GOOD: keep unknown fields opaque and carry them forward.
def update_keep_unknown(record, new_fav):
out = copy.deepcopy(record) # keep all keys, even unknown ones
out["favoriteNumber"] = new_fav
return json.dumps(out)
print("GOOD:", update_keep_unknown(db_value, 42)) # photoURL survives.
Notes on the lines that matter:
PersonOld.__init__ selects only known keys. That models old generated code.
toJSON re-encodes only known keys. That is where (3) is lost.
copy.deepcopy in the good path preserves unknown keys without interpreting them. That is what Thrift, Protocol Buffers, and Avro do at the format level when they skip and retain unknown fields.
Try it: run both paths on the same Martin record with favoriteNumber set to 42. This mirrors the Python above.
BAD: narrow model
Press Run BAD path.
GOOD: carry unknown
Press Run GOOD path.
BAD drops photoURL and breaks (3). GOOD keeps it opaque.
Before and after on one record. The only difference is whether unknown keys are dropped or carried.
06
Field guide: who uses what and why
Language builtins such as Java Serializable, Ruby Marshal, Python pickle, and Kryo for Java: convenient for transient data. Avoid for durable data because they tie you to one language, allow instantiation of arbitrary classes on decode which creates security risk, treat versioning as an afterthought, and are often bloated and slow.
JSON, XML, CSV for organization to organization interchange: good enough when agreement matters more than size. Watch numbers. XML and CSV cannot tell numbers from digit strings without external schema. JSON cannot tell integers from floats and loses precision past 2 to the power 53, which is why Twitter sends tweet IDs both as number and decimal string. Watch binary data. JSON and XML lack binary strings so binary is wrapped as Base64 text. Watch schemas. XML Schema and JSON Schema exist but many JSON tools skip them, so validation code moves into the app. CSV has no schema at all and vague escaping for commas and newlines.
Binary JSON variants such as MessagePack, BSON, BJSON, UBJSON, BISON, Smile, and XML variants WBXML and Fast infoset: slightly smaller and faster but still include field names, so Example 4-1 only shrinks from 81 to 66 bytes in MessagePack. Useful in niches, not a big win.
Thrift CompactProtocol and Protocol Buffers for internal services with stable schemas and code generation: omit field names, use tags 1, 2, 3, and reach 34 and 33 bytes. Good for statically typed languages like Java, C++, and C# where generated classes give type checking and autocomplete.
Avro for Hadoop files, Espresso storage, and Pig analysis: reaches 32 bytes with no tags. Good for dynamically generated schemas from database tables and for dynamic languages like JavaScript, Ruby, and Python where code generation is unwanted. Files are self describing because they embed the writer schema.
Archival and analytics copies: dump with the latest schema into Avro object container files, then optionally into column oriented Parquet for analytics. Source databases may hold mixed schema versions on disk but appear as one schema through evolution rules.
Network and drivers: relational protocols with ODBC or JDBC drivers, SSL certificates with ASN.1 DER and X.509 tags, and web APIs over HTTP with URLs plus HTML for browsers or JSON for native apps and Ajax XMLHttpRequest clients. All need (1) and (2) because clients and servers update independently.
Try it: pick your situation. The panel names the fit and the price, using the rules above.
There is no best format, only best fit for stability, language, and tooling.
07
Interview ears
Why is forward compatibility harder than backward compatibility?
New code knows old formats and can handle them. Old code never saw the new addition and must ignore it safely.
Why can you not add a required field?
New readers would reject old records that lack it. That breaks (1). Every post launch addition must be optional or have a default.
Why must you never reuse a tag?
Old files may still contain the old tag. New code would misread the old value as the new field. That breaks (1).
When does Avro beat Thrift or Protobuf?
When schemas are generated from database tables that change often. Name matching plus defaults adapts without manual tag management.
What must you store besides Avro bytes?
The writer schema or its version. Without it the bytes are just concatenated values with no field markers.
08
Judgment quiz
Q1Predict: v1 has optional int32 age tag 4. v2 changes it to int64 and writes 5 billion. v1 reads it. What happens and which requirement is at risk?
Int32 cannot hold 5 billion. Old code truncates. The read path for new data is forward compatibility, requirement (2).
Q2Compare: you add interests as repeated versus adding photoURL as optional string. Which is safer for old readers that do read modify write?
Protobuf repeated shows old code only the last value, so a rewrite can shrink the list. An opaque optional string can be skipped and carried intact.
Q3Choose under tradeoff: your team dumps 10 million relational rows daily and columns change weekly, with readers in Python and Pig and no build step. Choose tag based or Avro and state the price.
Weekly column churn plus Python and Pig plus no build step favors Avro dynamic schemas. The price is schema management and defaults on every addition.
Sources: Martin Kleppmann, Designing Data Intensive Applications, Chapter 4, Encoding and Evolution, Figures 4-1 to 4-7 and Example 4-1. Byte sizes 81, 66, 59, 34, 33, 32 and limit 2 to the power 53 are from that chapter.