04

Encoding and evolution

Applications change, and in a large system they never change all at once. During a rolling upgrade, or when a database holds rows written five years ago, old and new code must read each other's bytes. This chapter compares how JSON, MessagePack, Thrift, Protocol Buffers and Avro turn a record into bytes, what each lets you change in a schema without breaking anyone, and the three ways data flows between processes: through databases, through service calls, and through asynchronous messages. Every byte below is produced by a real encoder running in your browser.
The book's example record, encoded byte by byte. Each step down removes something from the bytes: first the text, then the field names (replaced by tags), then fixed widths (replaced by varints), and finally tags and types altogether.

The same record, six ways

structurefield namefield tagtype / unionlength / countvalue
JSON81 bytesTextual. Field names spelled out in every record, numbers as decimal digits.
7b{22"75u73s65e72r4eN61a6dm65e22"3a:22"4dM61a72r74t69i6en22"2c,22"66f61a76v6fo72r69i74t65e4eN75u6dm62b65e72r22"3a:3113333333772c,22"69i6en74t65e72r65e73s74t73s22"3a:5b[22"64d61a79y64d72r65e61a6dm69i6en67g22"2c,22"68h61a63c6bk69i6en67g22"5d]7d}
MessagePack66 bytesBinary JSON: compact type+length prefixes, but still carries every field name.
83·a8·75u73s65e72r4eN61a6dm65ea6·4dM61a72r74t69i6enae·66f61a76v6fo72r69i74t65e4eN75u6dm62b65e72rcd·05·399a9·69i6en74t65e72r65e73s74t73s92·ab·64d61a79y64d72r65e61a6dm69i6en67ga7·68h61a63c6bk69i6en67g
Thrift Binary59 bytesNames replaced by numeric field tags from the schema; fixed-width tags, lengths and ints.
0b0001000000064d617274696e0a000200000000000005390f00030b000000020000000b646179647265616d696e67000000076861636b696e6700
Thrift Compact34 bytesTag delta and type packed into one byte; integers as zigzag varints.
18064d617274696e16f21419280b646179647265616d696e67076861636b696e6700
Protocol Buffers33 bytesTag number and wire type in one varint key; ints as varints.
0a064d617274696e10b90a1a0b646179647265616d696e671a076861636b696e67
Avro32 bytesNo tags, no types in the bytes. Just values, concatenated in schema order.
0c4d617274696e02f2140416646179647265616d696e670e6861636b696e6700
Hover a byte to see what it encodes.

Hovering a byte highlights the same field in every format. Try: hover the 1337 in JSON (4 digit characters), then in Thrift Binary (8 fixed bytes) and Protobuf (2-byte varint). Then clear favoriteNumber: the tagged formats simply omit the field, Avro spends one union byte saying "null", and JSON still spells out the name.

MessagePack
81%
of JSON · 40 non-value bytes
Thrift Binary
73%
of JSON · 27 non-value bytes
Thrift Compact
42%
of JSON · 8 non-value bytes
Protocol Buffers
41%
of JSON · 7 non-value bytes
Avro
40%
of JSON · 6 non-value bytes
Each cell encodes a sample record with one schema and decodes it with the other, using the same encoders as above. Pick a change and flip whether the field is required; the matrix is recomputed from the actual decode results.

Compatibility matrix (computed by actually encoding and decoding)

Protobuf / ThriftAvro
backwardforwardbackwardforward
✓ compatible~ silent data loss✗ breaks

Backward = new code reads data written by old code. Forward = old code reads data written by new code. Try: "add a field" is fine until you make it required; "rename" is free for tagged formats but hurts Avro (until you add an alias); "change a tag" is the reverse.

Protobuf / Thrift · backward

compatible
new schema adds photoURL (tag 4). Old code writes, new code reads.
writer's schema
  • required string userName = 1
  • optional int64 favoriteNumber = 2
  • repeated string interests = 3
reader's schema
  • required string userName = 1
  • optional int64 favoriteNumber = 2
  • repeated string interests = 3
  • optional string photoURL = 4
bytes written (33)
0a064d617274696e10b90a1a0b646179647265616d696e671a076861636b696e67
Hover a byte to see what it encodes.
reader sees
{
userName: "Martin"
favoriteNumber: 1337
interests: ["daydreaming","hacking"]
photoURL: null
}
The yellow cells are the dangerous ones. Nothing throws; the reader just gets a default or a truncated number where the writer stored real data. This is why Protobuf 3 dropped required entirely and why you should never reuse a deleted field's tag number.

Avro: writer's schema meets reader's schema

avrowriter-reader-schema
Avro bytes contain nothing but values, so decoding needs the exact schema that wrote them. The library then resolves that writer's schema against the reader's: matching by name, skipping, defaulting and promoting types.
writer's schema (old or new code that wrote the file)
reader's schema (the code decoding it now)

Writer's bytes → field matching → reader's record

0c4d617274696e02f214020416646179647265616d696e670e6861636b696e6700020c6d652e6a7067
Hover a byte to see what it encodes.
WRITER (byte order)READERuserNamefavoriteNumberinterestsphotoURL (skipped)interestsfavoriteNumberuserName
{
interests: ["daydreaming","hacking"]
favoriteNumber: 1337
userName: "Martin"
}
  • ● interests: matched by name
  • ● favoriteNumber: matched by name
  • ● userName: matched by name
  • ● writer's field 'photoURL' is not in reader's schema → ignored

Try: the reader's field order is reversed, yet resolution matches by name. Switch to "reader's schema only" to see garbage. Then give the writer an int favoriteNumber and the reader a long (promoted, fine) — and the other way around (fails). Untick "photoURL has default" on the reader while the writer lacks it.

encoded size
41 B
values only: no names, no tags
resolution
ok
0 defaulted · 1 ignored
schemas in play
W userName: string
W favoriteNumber: union{null, long} = null
W interests: union{null, array<string>} = null
W photoURL: union{null, string} = null
R interests: union{null, array<string>} = null
R favoriteNumber: union{null, long} = null
R userName: string
Four app servers share one database record. Some run v2, which knows about photoURL (tag 4); the rest still run v1. Every request is a read-modify-write. Reads never fail, which is exactly what makes the bug hard to spot.

Load balancer → app servers → one database record

load balancernode 1v2 · knows photoURLnode 2v2 · knows photoURLnode 3v1 · old schemanode 4v1 · old schemadb: user 42 · no photo
stored bytes (33)
0a064d617274696e10b90a1a0b646179647265616d696e671a076861636b696e67
Hover a byte to see what it encodes.
field taglength / countvalueunknown field
as v2 code reads it
{
userName: "Martin"
favoriteNumber: 1337
interests: ["daydreaming","hacking"]
photoURL: null
}
  1. No requests yet.

Try: upload a photo, then click "Next request" until a v1 node handles one. With "drop" the photo vanishes even though every read succeeded. Switch to "preserve" (what Protobuf's generated classes do) and it survives in red as an opaque unknown field.

writes by v1 code
0
writes by v2 code
0
photos silently lost
0
no error was ever raised
unknown fields in record
0
as seen by v1 code

Dataflow through databases: data outlives code

data-outlives-codebackward-compatibility
Writing to a database is sending a message to your future self. The table below holds rows written by four schema versions over eight years; the deployed code decodes each row with its writer's schema and resolves it to its own.

users table · each row tagged with the schema version that wrote it

writtenuserNamefavoriteNumberinterestsphotoURLemail
v1 · 2016martinskippednull (default)null (default)null (default)
v1 · 2016adaskippednull (default)null (default)null (default)
v1 · 2016graceskippednull (default)null (default)null (default)
v1 · 2016linusskippednull (default)null (default)null (default)
v2 · 2018barbaraskippeddaydreamingnull (default)null (default)
v2 · 2018kenskippedhackingnull (default)null (default)
v2 · 2018radiaskippeddaydreamingnull (default)null (default)
v3 · 2021edsgerskippedhackingedsger.jpgnull (default)
v3 · 2021leslieskippeddaydreamingleslie.jpgnull (default)
v3 · 2021francesskippedhackingnullnull (default)
v4 · 2024donalddaydreamingdonald.jpgdonald@example.com
v4 · 2024margarethackingmargaret.jpgmargaret@example.com
  • v1 · 2016: initial: userName, favoriteNumber
  • v2 · 2018: + interests (default null)
  • v3 · 2021: + photoURL (default null)
  • v4 · 2024: − favoriteNumber, + email

Every row is decoded with the schema that wrote it, then resolved to today's schema. Try: read with v4 and notice that 2016 rows still work, with defaults filled in on the fly. Roll back to v2 (forward compatibility: old code meets newer rows) — the new fields are skipped. Migrating rewrites everything; usually nobody bothers.

rows older than code
10
need backward compat
rows newer than code
0
need forward compat
defaults filled on read
21
10 written fields skipped
bytes rewritten by migrations
0
table is 297 B

Dataflow through services: a remote call is not a function call

rpcrestidempotent-retries
RPC frameworks try to make a network request look like calling a local function. Simulate 120 calls that each charge a card $10 over a network that loses messages and has heavy-tailed latency, with a client that retries on timeout.

120 calls to chargeCard(customer, $10)

ok, charged once · 110ok, but charged twice+ · 10error, not charged · 0error, yet charged · 0brighter = needed a retry
Hover a call to see its attempts.

Try: with retries on and the idempotency key off, find a yellow square — the response was lost, the client retried, and the customer paid twice. Set retries to 0 and the duplicates turn into red: errors for calls that actually happened. Only the idempotency key fixes it.

customer charged
$1300
intended $1200
client saw success
100%
outcome unknown to client
0
timed out, but it happened
latency p50 / p99
36 / 276 ms

Dataflow through asynchronous messages

async-message-brokeractor-model
A producer sends one-way messages. Without an intermediary they must reach the recipient right away; with a broker they are stored and delivered when the consumer is ready. The encoding question doesn't go away: the broker just carries bytes, so producers and consumers on different versions still need compatible schemas.

Asynchronous message passing

producerdoesn't waitbroker queue · 0consumer8/sbroker buffers while the consumer is slow or down; delivers when it's back
00.510secondsqueued

Try: press Play, crash the consumer for a few seconds, then restart it. Via the broker, the backlog builds and drains with nothing lost; switch to direct and the same outage drops every message. Then set send rate above consumer speed: the broker absorbs the overload, but the queue (and delay) grows without bound.

sent
0
delivered
0
lost
0
worst end-to-end delay
0.0 s
sim time 0 s
  • takeawaySchema-based binary formats are smaller than JSON because they move information out of the bytes and into the schema: field names become tags, then Avro removes even tags. The schema is also living documentation and lets you check compatibility before deploying.
  • takeawayEvery format has evolution rules. Add only optional fields or fields with defaults, remove only optional ones, never reuse a tag (Protobuf/Thrift) and rename only with aliases (Avro). Old code that re-writes a record must preserve fields it doesn't know.
  • takeawayData flows via databases (old rows read by new code), services (requests and responses across versions, over an unreliable network) and message brokers (buffered, one-way). In all three, both backward and forward compatibility are what make independent, rolling deployment possible.

Key concepts · 20

book chapter 4
  1. Encoding (serialization)

    Translating an in-memory data structure into a self-contained sequence of bytes, and back (decoding).

    Pointers and objects only make sense inside one process. Anything written to disk or sent over a network has to be encoded, and the format you choose decides size, speed and how painfully your schema can change.

    ↑ see it in the visualization
  2. Language-specific formats

    Built-in serializers such as Java Serializable, Python pickle or Ruby Marshal.

    Convenient but they tie your data to one language, often allow decoding to instantiate arbitrary classes (a security hole), and treat versioning and efficiency as afterthoughts. Avoid them for anything that persists or crosses a team boundary.

    java.io.SerializablepickleMarshal
  3. JSON, XML and CSV

    Human-readable textual encodings that are widely supported across languages.

    Good enough as a lingua franca between organizations, but verbose, ambiguous about numbers (integers above 2⁵³ break in JavaScript), lacking binary strings, and schema support is optional and complicated.

    JSONXMLCSV
    ↑ see it in the visualization
  4. Binary JSON variants

    Binary encodings of the JSON data model, such as MessagePack, BSON or CBOR.

    They shave some bytes and parsing cost, but because there is no schema every record still carries all its field names, so the savings are modest (81 → 66 bytes in the book's example).

    MessagePackBSONCBOR
    ↑ see it in the visualization
  5. Field tags

    Small numbers in a Thrift or Protocol Buffers schema that identify each field in the encoded bytes instead of its name.

    Tags make records compact and make renaming a field free. In exchange the tag is forever: you may never change a field's tag or reuse a removed field's tag, because old data still carries it.

    ThriftProtocol Buffers
    ↑ see it in the visualization
  6. Thrift and Protocol Buffers

    Schema-driven binary formats with code generation, from Facebook and Google respectively.

    Thrift's CompactProtocol and Protobuf pack tag and type into one byte and write integers as variable-length varints, getting the example record down to 34 and 33 bytes.

    Apache ThriftProtocol BuffersgRPC
    ↑ see it in the visualization
  7. Variable-length integers (varints)

    Integers encoded 7 bits per byte with a continuation bit, often after zigzag mapping so small negatives stay small.

    Most numbers are small, so most take one or two bytes instead of a fixed eight. A side effect: widening a field from 32 to 64 bits is wire-compatible, but old 32-bit readers silently truncate large values.

    ↑ see it in the visualization
  8. Avro

    A Hadoop-born binary format whose encoding contains no tags and no types, only values concatenated in schema order.

    It is the most compact (32 bytes), and because there are no tags, schemas can be generated from database tables and evolve freely. The cost: you cannot decode a single byte without knowing exactly which schema wrote it.

    Apache AvroKafka Schema Registry
    ↑ see it in the visualization
  9. Writer's schema and reader's schema

    In Avro, the schema the data was encoded with and the (possibly different) schema the application expects when decoding.

    Avro resolves the two side by side: fields are matched by name, so order doesn't matter; fields the reader doesn't know are skipped; fields the writer lacked get the reader's default. The writer's schema travels in the file header, a schema registry, or the connection handshake.

    ↑ see it in the visualization
  10. Backward compatibility

    Newer code can read data that was written by older code.

    Usually easy because the new code's author knows the old format. Rule: any field you add must be optional or have a default, otherwise new code fails on old records.

    ↑ see it in the visualization
  11. Forward compatibility

    Older code can read data that was written by newer code.

    Harder, because old code must tolerate additions it could not have anticipated: it has to ignore unknown fields, and you can only remove fields that are optional or have a default.

    ↑ see it in the visualization
  12. Schema evolution

    Changing a schema over time (adding, removing, renaming, retyping fields) while keeping old and new data mutually readable.

    Each format has its own rules: tag-based formats tolerate renames but not tag changes; Avro tolerates reordering and tag-free generation but needs aliases for renames and defaults for any field it adds or drops.

    ↑ see it in the visualization
  13. Rolling upgrade

    Deploying a new version to a few nodes at a time, so old and new code run side by side for a while.

    It enables zero-downtime deploys and quick rollbacks, but it means data flows in both directions between versions, so both backward and forward compatibility are required.

    ↑ see it in the visualization
  14. Preserving unknown fields

    When old code reads a record, modifies it and writes it back, keeping the fields it didn't understand.

    If old code decodes into a model object that drops unknown fields and then re-encodes, it silently destroys data written by newer code — without any error. Protobuf's generated classes keep unknown fields; many ORMs do not.

    ↑ see it in the visualization
  15. Data outlives code

    A database contains values written over years by many versions of the application, while only the latest code is deployed.

    Rewriting (migrating) everything to the new schema is expensive, so most databases evolve lazily: old rows stay as they are and missing columns are filled in with defaults when read.

    Avro object container filesLinkedIn EspressoParquet archives
    ↑ see it in the visualization
  16. Remote procedure call (RPC)

    Making a request to a remote service look like calling a local function.

    The abstraction is leaky: a network call can be lost, time out with an unknown outcome, take wildly variable time, and be retried — so without idempotence a retry may run the action twice. Treat a remote call as a different kind of thing, not as a function.

    gRPCThrift RPCFinagleJava RMICORBA
    ↑ see it in the visualization
  17. REST and web services

    A design style built on HTTP's own features (URLs, methods, caching, content types), usually with JSON bodies.

    Easy to experiment with and debug, supported everywhere, and good for public APIs. Compatibility is maintained by versioning the API, often in the URL or headers, because you can't force clients to upgrade.

    OpenAPI/SwaggerHTTP/JSON APIs
    ↑ see it in the visualization
  18. Idempotence

    An operation that has the same effect whether it is performed once or several times.

    Retries are the only way to cope with lost responses, and they are only safe if the operation is idempotent, e.g. by attaching a unique request ID the server deduplicates on.

    ↑ see it in the visualization
  19. Message broker

    An intermediary that stores messages from producers and delivers them to consumers asynchronously.

    It buffers when the recipient is slow or down, redelivers after crashes, decouples sender from recipient (sender doesn't need its address), and fans one message out to many consumers. The sender doesn't wait for a reply.

    Apache KafkaRabbitMQAmazon SQSNATS
    ↑ see it in the visualization
  20. Distributed actor frameworks

    A programming model where actors with local state communicate only by asynchronous messages, transparently across nodes.

    Message loss is assumed even locally, so going distributed changes less than with RPC, but rolling upgrades still need the message encoding to be forward and backward compatible.

    AkkaMicrosoft OrleansErlang/OTP