Encoding and evolution
One record, six encodings
encoding-serializationtextual-formatsbinary-jsonthrift-protobufvariable-length-integeravroThe same record, six ways
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.
Schema evolution: what can you change?
schema-evolutionbackward-compatibilityforward-compatibilityfield-tagsCompatibility matrix (computed by actually encoding and decoding)
| Protobuf / Thrift | Avro | |||
|---|---|---|---|---|
| backward | forward | backward | forward | |
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- required string userName = 1
- optional int64 favoriteNumber = 2
- repeated string interests = 3
- required string userName = 1
- optional int64 favoriteNumber = 2
- repeated string interests = 3
- optional string photoURL = 4
required entirely and why you should never reuse a deleted field's tag number.Writer's bytes → field matching → reader's record
- ● 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.
Rolling upgrades and the vanishing field
rolling-upgradeunknown-field-preservationforward-compatibilityLoad balancer → app servers → one database record
- 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.
users table · each row tagged with the schema version that wrote it
| written | userName | favoriteNumber | interests | photoURL | |
|---|---|---|---|---|---|
| v1 · 2016 | martin | skipped | null (default) | null (default) | null (default) |
| v1 · 2016 | ada | skipped | null (default) | null (default) | null (default) |
| v1 · 2016 | grace | skipped | null (default) | null (default) | null (default) |
| v1 · 2016 | linus | skipped | null (default) | null (default) | null (default) |
| v2 · 2018 | barbara | skipped | daydreaming | null (default) | null (default) |
| v2 · 2018 | ken | skipped | hacking | null (default) | null (default) |
| v2 · 2018 | radia | skipped | daydreaming | null (default) | null (default) |
| v3 · 2021 | edsger | skipped | hacking | edsger.jpg | null (default) |
| v3 · 2021 | leslie | skipped | daydreaming | leslie.jpg | null (default) |
| v3 · 2021 | frances | skipped | hacking | null | null (default) |
| v4 · 2024 | donald | daydreaming | donald.jpg | donald@example.com | |
| v4 · 2024 | margaret | hacking | margaret.jpg | margaret@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.
120 calls to chargeCard(customer, $10)
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.
Asynchronous message passing
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.
- 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- 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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