Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Postcard and bincode

bincode and postcard are the two binary formats most Rust programmers encounter when they wire up serde for the first time. They are both serde-compatible, both Rust-first, both small in spec and small in implementation. They differ in target — bincode for general-purpose serde-binary use, postcard for the embedded and no_std contexts that bincode does not address — and in encoding philosophy. They share a significant property: neither is a serialization format in the way Protobuf or Avro are. They are both layouts of serde's data model, which means the schema is implicit in the Rust types being serialized, and cross-language use is barely a thing.

Origin

bincode was created by Ty Overby in 2014, near the dawn of the Rust serde ecosystem. The original goal was simple: provide a binary format that any serde-Serialize type could be encoded into and any serde-Deserialize type could be decoded from. bincode's first wire format was minimal: fixed-width little-endian integers, length- prefixed strings, no metadata. Its original design predated many of serde's more sophisticated features and produced a format that was fast, simple, and not particularly small.

bincode 2.0 was released in 2023 with a substantially revised wire format. The default behavior in 2.0 supports varint encoding for integers, more compact representations for option types, and a configuration system that lets users opt in or out of various encoding choices. The 2.0 format is wire-incompatible with 1.x, which was a deliberate break to enable the smaller default encoding; 1.x continues to be maintained for projects that have on-disk data in the older format.

postcard was created by James Munns in 2019, with the explicit goal of providing a serde-compatible binary format that worked in no_std and embedded contexts where bincode was not a fit. postcard's design constraints were stricter: no allocator dependency, predictable wire size, varint encoding for integers (because embedded systems often handle small values), and a deterministic encoding suitable for embedded firmware checksums and signatures. The format spec is short, the canonical implementation is in pure Rust, and the format has been stable since version 1.0.

Both formats are concentrated in the Rust ecosystem and have minimal cross-language adoption. There are unofficial postcard implementations in C and Python; bincode-compatible reimplementations exist for Go and JavaScript but are not widely used. The lack of cross-language adoption is not a flaw of either format; it is a consequence of their being designed to encode serde's data model, which does not exist outside Rust.

The format on its own terms

bincode 2.0's default configuration produces wire bytes that are structurally similar to postcard's. Integers are varint-encoded, strings are varint-length-prefixed, options are tagged with a discriminant byte, sequences are length-prefixed. The differences are in the details: bincode 2.0 uses LEB128-shaped varints, postcard uses a slightly different varint that aligns better with embedded use cases; bincode 2.0's option encoding takes 1 byte for None and 1+payload for Some; postcard's is the same.

bincode 1.x's default configuration is different. Integers are fixed-width little-endian (u64 always 8 bytes, i32 always 4 bytes), lengths are u64 (8 bytes per length prefix), and the result is dramatically larger than the 2.0 encoding for the same data. Many projects on bincode 1.x configured it to use "VarintEncoding" via the configuration system, which approximated the 2.0 default; many did not, and the larger format is what their on-disk data uses.

postcard's encoding is uniformly varint-based. The varint encoding is LEB128-style: each byte contributes 7 bits to the encoded value, with the high bit indicating whether more bytes follow. Signed integers are zigzag-mapped to unsigned before varint encoding. Lengths use the same varint. There is no fixed-width mode; the format is varint-throughout.

Both formats handle Rust's algebraic data types (enums) by encoding the variant discriminant as a varint integer followed by the variant's data. This is straightforward for non-recursive enums and works fine for recursive ones up to the limits of the serializer's stack depth.

The schema for both formats is the Rust source code. There is no separate IDL, no compiled schema artifact, no schema registry. The encoder is generated by serde's procedural macros from the Rust type's Serialize and Deserialize derive; the decoder similarly. Cross-version compatibility, when it matters, requires the consumer's type definition to match the producer's exactly, or to be wire-compatible according to the format's narrow rules.

Wire tour

Schema (Rust source, identical for both):

#![allow(unused)]
fn main() {
#[derive(serde::Serialize, serde::Deserialize)]
struct Person {
    id: u64,
    name: String,
    email: Option<String>,
    birth_year: i32,
    tags: Vec<String>,
    active: bool,
}
}

postcard encoding:

2a                                           id: varint(42) = 1 byte
0c                                           name length: varint(12)
41 64 61 20 4c 6f 76 65 6c 61 63 65          "Ada Lovelace"
01                                           email Option discriminant: Some
15                                           email length: varint(21)
61 64 61 40 61 6e 61 6c 79 74 69 63
   61 6c 2e 65 6e 67 69 6e 65                "ada@analytical.engine"
ae 1c                                        birth_year: zigzag-varint(1815) = 2 bytes
02                                           tags count: varint(2)
0d                                           tags[0] length: 13
6d 61 74 68 65 6d 61 74 69 63 69 61 6e       "mathematician"
0a                                           tags[1] length: 10
70 72 6f 67 72 61 6d 6d 65 72                "programmer"
01                                           active: bool true = 1 byte

66 bytes. Among the smallest encodings in this book — narrowly beating Avro's 67 bytes and Protobuf's 71. The win comes from the universal varint encoding (lengths and integers both shrink to their natural width) plus the absence of any per-record framing overhead.

bincode 1.x with default (fixed-width) encoding:

2a 00 00 00 00 00 00 00                     id: u64 LE = 8 bytes
0c 00 00 00 00 00 00 00                     name length: u64 LE = 8 bytes
41 64 61 20 4c 6f 76 65 6c 61 63 65          "Ada Lovelace"
01                                           email Some discriminant
15 00 00 00 00 00 00 00                     email length: u64 LE = 8 bytes
61 64 61 40 61 6e 61 6c 79 74 69 63
   61 6c 2e 65 6e 67 69 6e 65                "ada@analytical.engine"
17 07 00 00                                  birth_year: i32 LE = 4 bytes
02 00 00 00 00 00 00 00                     tags count: u64 LE = 8 bytes
0d 00 00 00 00 00 00 00                     tags[0] length: u64 = 8 bytes
6d 61 74 68 65 6d 61 74 69 63 69 61 6e       "mathematician"
0a 00 00 00 00 00 00 00                     tags[1] length: u64 = 8 bytes
70 72 6f 67 72 61 6d 6d 65 72                "programmer"
01                                           active: bool = 1 byte

110 bytes. The fixed-width 8-byte length prefixes eat 32 bytes of overhead that varint encoding eliminates entirely. bincode 1.x deployments that have not configured varint encoding pay this cost on every record.

bincode 2.0 with default settings is wire-similar to postcard, within a few bytes — both use varint encoding, both prefix strings the same way, both encode options identically. The two formats are not byte-identical (the varint encodings differ slightly, and the option discriminant byte may differ in specific edge cases) but the size difference for typical payloads is small.

If email were None, both formats would replace the 01 Some discriminant with 00 and skip the value. postcard saves 23 bytes; bincode 1.x saves 31 bytes (the value plus the 8-byte length); bincode 2.0 saves 23 bytes.

Evolution and compatibility

Neither format has a meaningful schema-evolution story. Both are positional encodings of serde's data model: change the Rust type, change the wire format. Adding a field, removing a field, reordering fields, or changing a field's type all break compatibility with old data.

The conventional pattern for evolving postcard or bincode data is explicit versioning: prepend a version discriminant to every record, dispatch on the version to pick the right Rust type, migrate explicitly. This works but requires application discipline; the formats provide no help.

Some Rust ecosystems handle this with serde's #[serde(default)] attribute, which lets a deserializer accept missing fields and substitute a default value. This is a per-field opt-in and works for forward compatibility (new code reading old data, where the old data lacks new fields). Backward compatibility (old code reading new data) is a different problem and requires either dropping unknown fields explicitly (which is not the default) or encoding the new fields in a way old code can skip.

Both formats are deterministic by construction. Given a value and a configured encoder, exactly one byte sequence is produced. There are no choices in the wire format, no padding, no map-key- ordering issues (Rust's BTreeMap has stable iteration; HashMap does not, and using HashMap with serde produces non-deterministic encoding unless the keys are sorted before serialization).

Ecosystem reality

bincode's user base is broad within the Rust ecosystem. It is the default binary format for many Rust projects: cache files, inter-process communication on a single machine, on-disk record formats. Many crates that need a binary format default to bincode because it is the path of least resistance. The 1.x → 2.0 migration has fragmented this slightly; older crates and older data are 1.x, newer crates default to 2.0, and the two are wire-incompatible. Migration is straightforward but requires attention.

postcard's user base is concentrated in embedded Rust: drone firmware, microcontroller projects, IoT devices that run Rust through frameworks like Embassy, RTIC, and Tock. The no_std constraint matters in those contexts and rules out bincode (which historically had std dependencies — bincode 2.0 has reduced this but not fully). postcard's varint encoding, deterministic behavior, and small implementation make it a reasonable fit for the firmware-checksum and over-the-wire-control use cases that embedded systems care about.

Both formats are used outside their primary niches, but the choice in those cases is usually contingent on the project's dependencies. A general-purpose Rust service might choose either, and the choice rarely has consequences large enough to warrant a formal evaluation.

The ecosystem gotchas worth noting. First, the bincode configuration system: bincode (especially 1.x) has many configuration knobs, and different defaults produce wire- incompatible output. Code that round-trips through a default bincode encoder and a varint-configured bincode decoder will fail. The recommendation is to pick a configuration once, document it, and stick to it.

Second, the no_std story: bincode 2.0's no_std mode works but has subtle differences from the std mode, especially around how allocations are handled. Embedded projects should test carefully. postcard's no_std story is the format's design center and is correspondingly more polished.

Third, the Rust version dependency: both formats depend on serde, and serde's wire-impact-changes are rare but real. A serde update that affects the discriminant encoding for an enum type can change the bytes; the change is typically opt-in but can surprise long-lived data deployments.

When to reach for them

Use postcard when working in embedded Rust or any context where no_std support and varint encoding matter.

Use bincode for general-purpose Rust binary serialization where serde compatibility is the priority and cross-language use is not. New deployments should use bincode 2.0 with the default configuration.

Either is a reasonable choice for inter-process communication between Rust processes on the same machine, on-disk caches that the same Rust binary will read, and similar workloads.

When not to

Neither is the right choice when cross-language use is required; both formats are Rust-only in spirit and pretty much in implementation.

Neither is the right choice when schema evolution is a hard requirement; both formats are positional and provide no in-format mechanism for handling skew.

Neither is the right choice when long-term archival is the goal; the Rust-type-as-schema dependency makes archival fragile.

Position on the seven axes

Schema-required (the schema is the Rust type). Not self-describing. Row-oriented. Parse rather than zero-copy. Codegen via serde's proc macros. Deterministic by construction. No formal evolution mechanism.

Both formats sit in the same cell — Rust-first serde-binary encodings — with the difference being the embedded vs. general- purpose niche. Neither is a serious choice outside Rust.

A note on the broader serde-binary picture

Several other formats live in the same Rust serde-binary niche and deserve brief mention because they show how the niche has been explored differently.

ron (Rusty Object Notation) is a textual serde format that shares the data model but is not binary. It is included here only to clarify that ron is the "human-readable serde format" companion to bincode and postcard, not a binary alternative.

MessagePack via rmp-serde is the cross-language alternative. Rust projects that need to serialize through serde and also interop with non-Rust consumers typically use rmp-serde rather than bincode or postcard. The wire format is MessagePack (covered in chapter 4), not a Rust-specific encoding.

CBOR via ciborium and serde_cbor is the standardized alternative. Same logic as MessagePack: cross-language interop through a non-Rust-specific format, accessed through serde for Rust ergonomics.

speedy is a Rust binary format that prioritizes decode speed, with a custom (non-serde) derive macro. It produces bytes that are smaller than bincode 1.x and faster to decode than either, but with a smaller user base.

abomonation is a Rust zero-copy format predating rkyv. It is strictly unsafe in the Rust safety sense — reading abomonation data requires the reader to trust the bytes — and has been largely superseded by rkyv (chapter 15) for the zero-copy use case.

The shape of the niche is therefore: bincode and postcard for Rust-only serde-binary, rmp-serde and ciborium for cross-language serde-binary, rkyv for zero-copy, and a long tail of specialized options. The choice is usually forced by one of the above constraints, and the format is then determined.

A note on schema-as-source-code

bincode and postcard share with rkyv the property that the schema is the source code. This is not unique to Rust — many language-specific binary formats do this — but Rust's strong type system and explicit derive macros make the source-as-schema approach particularly clean. The cost is that the format is unusable outside the language; the benefit is that schema changes are simply Rust changes, which the type system tracks and which the developer is going to make anyway.

The trade is sharper for the Rust-binary formats than for any other category in this book because Rust's type system is so strong. A type change in a Rust struct produces compile errors in every consumer in the same workspace; the formats inherit this property and let it serve the role of schema-evolution checking. Outside Rust, a similar pattern is achievable but less clean (Java's serialization, Python's pickle, Go's gob — all share aspects of source-as-schema, all are clunkier than the Rust equivalents).

The lesson is not that source-as-schema is universally good. The lesson is that it is particularly good in Rust, because the language's existing type machinery is strong enough to substitute for what other formats need a separate IDL to provide. The right question to ask before choosing a Rust-only binary format is whether the no-cross-language-use is acceptable for the workload; if it is, the source-as-schema approach is materially nicer than the alternatives.

Epitaph

postcard and bincode are Rust-flavored serde-binary formats: small, fast, deterministic, and shaped by the language's type system to the point of being effectively unportable.