Why We Removed Protobuf From a Large Rust Codebase
We deleted 484 .proto files and every line of generated gRPC code, and moved stored and transmitted data to plain Rust types encoded as canonical CBOR. The deciding reason was hashing, not taste. Here is what that bought us and what it cost.
Our platform is built around an append-only data engine where every stored record has a content hash and many records are signed. For most of its life, those records were protobuf messages. In August we removed protobuf from our own code entirely: 484 .proto files, the build scripts that compiled them, the generated Rust, Python and TypeScript, and every first-party gRPC service.
The change that deleted the files touched 2,605 files, added about 196,000 lines and removed about 257,000. This post explains why we did it, how the replacement works, and what broke on the way.
The problem: protobuf has no canonical encoding
If you hash or sign data, the same logical value must always produce the same bytes. Protobuf does not promise that. The specification explicitly disclaims a canonical encoding: field order, how map entries are ordered, whether default values are written, and whether unknown fields are kept can all vary between implementations and versions.
We were computing record hashes as blake3(message.encode_to_vec()). That works as long as every writer and reader uses the same library at the same version with the same schema. Add a field, upgrade prost, or decode and re-encode in another language, and the same record can produce a different hash. For a system whose main promise is that a record’s identity never changes, that is a bug waiting for a trigger.
You can bolt canonicalisation onto protobuf, but at that point you are maintaining your own encoder for a format whose main benefits are code generation and cross-language tooling. We decided to keep the hash rules and drop the format.
What replaced it
Every stored or transmitted type is now an ordinary Rust struct with serde derives, kept in a small schema crate per domain. Records go over the wire and into hot storage as CBOR, a compact binary encoding of the same data model as JSON. For hashing and signing there is one extra step: a canonical CBOR profile based on RFC 8949 section 4.2.
- Map keys are text, sorted bytewise, and never duplicated. The same struct always serializes its fields in the same order.
- Floats, tags and raw byte strings are rejected in hashed data. Binary values are carried as base64 text, so Rust, Swift and WASM implementations cannot disagree about edge cases like not-a-number values or negative zero.
- Nulls are stripped before hashing, so null and absent mean the same thing. That one rule makes schema evolution safe: adding an optional field to a type does not change the hash or signature of any existing record.
- Hashing is a blanket trait. Any serializable type gets a BLAKE3 content hash over its canonical bytes, and signatures bind to the type as well as the bytes, so schema crates stay plain serde.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PrimaryKeyId {
pub transaction_key_id: Option<KeyId>,
pub record_element_key_id: Option<KeyId>,
}
// Any serde type gets a stable content address:
let id = record.content_hash()?; // BLAKE3 over canonical CBOROption.The evolution rule replaces protobuf’s field numbers: once a type has shipped, every field added to it must be optional. Anything that cannot follow that rule gets a new versioned type. A CI gate enforces these schema rules on every pull request.
Columnar storage is derived from the same types. Older records move into Arrow/Lance files, with the Arrow representation derived from the serde types, so there is one definition of each record across the wire, the hot database and cold storage.
What it bought us
Show the data
| seconds | |
|---|---|
| prost codegen (88-crate dependency tree) | 126 |
| serde derive (10-crate dependency tree) | 18 |
- Hashes that do not drift. A record’s identity now depends on its logical value, not on which library version wrote it.
- Faster, simpler builds. In the pilot, a schema crate went from a two-minute cold build with an 88-crate dependency tree to about 18 seconds with 10. There is no
protoctoolchain to install or pin on builders. - One definition per type. No
.protofile, generated struct and hand-written wrapper to keep in sync. Code review sees the real type.
What it cost
The losses are real, and they are exactly the things protobuf is good at.
- Cross-language clients. There is no language-neutral schema file any more. Before the change, about 82 crates depended on gRPC and there were more than 24 services; only one component, a Python gRPC server, actually relied on the cross-language support.
- gRPC tooling. Server reflection and
grpcurlare gone. We use HTTP and WebSocket endpoints with CBOR or JSON bodies instead, which ordinary tools can inspect. - Old data. We chose not to keep a compatibility reader for every historical protobuf record. Readers were changed to skip rows they cannot decode instead of stalling on them. That was acceptable for us; it would not be for many systems.
What broke during the migration
Most of the work was mechanical. These were the bugs that cost real time:
- Empty input. Protobuf decodes zero bytes as a message with every field at its default. CBOR treats zero bytes as an error. Several call sites relied on the protobuf behaviour, and a small shim restored it.
- Streaming decode past the end. Sparse maps sent by the browser caused the streaming CBOR decoder to read past the end of the request body. Buffering the body before decoding fixed it.
- Two sides, two key orders. One client hashed entity operations with its own key ordering while the server used the canonical one, so valid requests failed verification. The fix was to make every hash go through the single canonical encoder.
- Tests that pinned protobuf bytes. Fixtures with hard-coded encoded bytes had to be regenerated, which is the right outcome but a long tail of small changes.
- An unmaintained CBOR library. The serde CBOR crate we started with is unmaintained. We later moved to a maintained fork with an identical wire format, which changed no stored bytes.
Would we do it again?
Yes, for this system. If your data crosses many languages, protobuf’s generated clients are worth a lot, and you should keep it and canonicalise only what you hash. If your system is mostly one language and its records need stable hashes or signatures, a serde-native schema with a strict canonical encoding is less code, faster to build, and removes a class of bugs you may not have hit yet.