Skip to main content
José David Baena

On this page

Zero-Copy Serialization Borrows Memory Instead of Owning It

Banner.jpeg
Published on
/20 mins read

You map one file once and everyone smiles. Rust reads a header without rebuilding structs, Python slices the payload with memoryview, and a Java Virtual Machine (JVM) service points a MemorySegment at the same bytes. Then the fast path turns strange: one tiny slice keeps a large mapping alive, another process truncates the file, and the foreign function interface (FFI) release callback fires before the last reader is done.

The copy disappeared, but ownership did not. Zero-copy serialization is a borrowed-memory contract: Arrow is strongest when several languages share typed buffers; FlatBuffers and Cap'n Proto are strongest when accessors read a serialized blob in place; Protobuf usually saves staging and allocation work without removing the structural parse.

The real design question is who proves that the bytes stay alive, validated, aligned, unchanged, and schema-compatible long enough to read them. Episode six ended at a DMA boundary. This episode moves the same ownership ledger into formats, runtimes, and FFI.

The version anchors are Apache Arrow 25.0.0, bytes 1.12.1, zerocopy 0.8.55, rkyv 0.8.17, Java 25, and Python 3.14, all checked on August 1, 2026.

Borrowed reads delete deserialization work only when lifetime is explicit

A borrowed read is fast because you don't build a second object graph. It is safe only when your API makes lifetime part of the contract.

An owned decode turns bytes into language-native objects that can outlive the source buffer. A borrowed view reads fields directly from existing bytes instead. That can remove allocation, field copies, and some cache pressure. It also means the reader no longer owns an independent representation.

Three unrelated APIs land on the same rule. The Arrow C Data Interface uses an explicit release callback on imported arrays and schemas. Java's MemorySegment gives foreign memory, memory not owned by the Java heap, explicit spatial and temporal bounds. Python 3.14's buffer protocol defines an exporter/consumer contract around a raw pointer, length, shape, strides, and format. Different stacks. Same rule: borrowed access is a liveness protocol.

mmap / file / socket buffer                               // ①
    -> schema or layout validation at the trust boundary  // ②
    -> borrowed accessors over existing bytes
    -> release callback / exporter release / arena close  // ③

① The source bytes stay where they already are. That is the saved work.

② The reader still has to prove, at the trust boundary, that lengths, offsets, alignment, and encoding rules make sense for this format and input source.

③ The end of the read is not "the function returned." It is the event that proves no borrower still needs the bytes.

That last line is where "zero-copy" stops being a benchmark win and becomes a production design problem. Arrow's security notes warn that invalid offsets or invalid UTF-8 in untrusted buffers can trigger invalid memory access in downstream consumers. Python 3.14 memoryview objects can keep exporter restrictions in place until the view is released. Java arenas can free memory deterministically when the arena closes. If your API can't say when the bytes die, you don't have a safe borrowed path.

For your cache, RPC layer, or analytics worker, this means: the fastest reader is the one that can explain exactly when the bytes become invalid.

Arrow works best when the in-memory layout already is the interface

Arrow gets closest to cross-language zero-copy because the physical buffer layout is the product, not an implementation detail.

Arrow's columnar format, a buffer-based layout optimized for typed arrays and batch processing, standardizes fixed-width value buffers, variable-width offset buffers, and validity bitmaps, one-bit-per-value null masks. The format recommends 8- or 64-byte alignment and padding, prefers 64 bytes, and enforces the chosen alignment for inter-process communication (IPC). That gives Rust, C++, Python, and JVM code a shared description of what those bytes mean before anyone allocates rows.

That is why Arrow feels different from parser-heavy formats. In practice, Arrow is closer to "shared typed memory" than to "deserialize this object for me." Arrow arrays are immutable and support zero-copy slicing in the C++ API. The C Data Interface exists specifically for zero-copy sharing between runtimes in the same process. PyArrow's IPC docs call out zero-copy reads when the source supports it, including memory maps and buffer readers. When your workload is "scan this typed column in three languages," Arrow's in-memory layout is already the contract.

Int32 column
values buffer    [10][20][30][40]                          // ①
validity bitmap  [ 1  1  0  1 ]                            // ②
 
Utf8 column
offsets buffer   [0][5][5][16]                             // ③
data buffer      [alicebob@example]

① Fixed-width values live in one contiguous buffer, so scans can stay cache-friendly.

② Nullability costs one bit per value, not a boxed object or side table per row.

③ The offsets delimit alice, an empty string, and bob@example; the final offset matches the 16-byte data buffer. Variable-width values still pay an extra indirection.

The win is not that Arrow deletes every copy everywhere. The win is that several readers can agree on one backing store. A memory-mapped file, a file exposed as pages in a process address space, makes that visible: one process maps the file, and each runtime walks the same Arrow buffers instead of rebuilding rows. The caveat matters just as much. Strings, nested arrays, dictionary encodings, and wrapper APIs can still allocate at the edges. Arrow's format docs make endianness, alignment, and buffer invariants part of the deal, and its security guidance says untrusted data should be validated before use.

If you need a shared-memory format for analytical scans, Arrow is the first thing to test. If you need a general object graph that evolves freely and gets touched one field at a time, Arrow is often the wrong shape.

FlatBuffers and Cap'n Proto skip parse work by freezing more layout into the format

FlatBuffers and Cap'n Proto are fast because accessors already know where to look in the serialized bytes. That speed comes from stricter layout rules, not magic.

FlatBuffers stores tables behind offset-based accessors and keeps scalars little-endian and aligned to their own size per the format internals. Cap'n Proto stores messages as 8-byte words with pointer-based traversal rules per its encoding spec. In both cases, generated code treats the serialized layout as something the runtime can traverse directly instead of rebuilding an owned tree first.

That is a powerful trade. You save parse and allocation work because the format carries more physical structure. You also move more correctness burden into verification, pointer rules, traversal limits, and evolution discipline. FlatBuffers documents direct access without parsing as a core design goal. Cap'n Proto's C++ docs describe Reader and Builder as non-owning views over message memory. These aren't "deserialize later" systems. They are "the bytes already are the thing you read" systems.

// FlatBuffers: verify first, then borrow accessors
flatbuffers::Verifier verifier(buf, len);                  // ①
if (!VerifyMonsterBuffer(verifier)) return false;
auto monster = GetMonster(buf);
auto name = monster->name()->string_view();                // ②
 
// Cap'n Proto: bound traversal before following pointers
capnp::ReaderOptions opts;                                 // ③
opts.traversalLimitInWords = 1 << 20;
capnp::FlatArrayMessageReader reader(words, opts);
auto person = reader.getRoot<Person>();
auto email = person.getEmail();

① Verification is part of the cost model for untrusted input, not optional ceremony.

② This accessor borrows the underlying buffer. It doesn't manufacture an owned string first.

③ Cap'n Proto's traversal limit exists because pointer-rich messages can be malicious even when simple bounds checks pass.

The details differ, but the pattern doesn't. FlatBuffers' C++ docs recommend the verifier for untrusted buffers, and the Rust docs distinguish safe root access from _unchecked APIs that skip verification. Cap'n Proto's C++ guidance recommends traversal limits for untrusted messages. In both ecosystems, "direct access" is the optimization and "safe on arbitrary bytes" is a separate problem.

These formats earn their keep when object construction is the bottleneck and the buffer can be trusted or cheaply verified. They stop looking cheap when the trust boundary is hostile, the schema changes casually, or the runtime wrapper boxes everything on the way out.

Protobuf's "zero-copy" usually ends at the parser boundary

Protobuf can remove some buffer churn, but most real applications still parse wire bytes into an owned message structure.

That is not a flaw. It is the point of the format. Protobuf's wire-format guide describes field tags, varints, and length-delimited fields as the core wire model. Those rules are great for compatibility and space efficiency. They are not a universal recipe for direct, in-place object traversal.

This is where Protobuf's terminology trips people. The C++ ZeroCopyInputStream API is about I/O buffer access where the stream owns the backing memory. Arena allocation reduces allocation cost by keeping parsed objects in one managed region. Both save work. Neither turns the wire format into "borrow bytes forever and call getters on them" in the FlatBuffers or Cap'n Proto sense. Calling Protobuf a zero-copy serialization format is usually a category error.

google::protobuf::io::ArrayInputStream raw(buf, len);      // ①
google::protobuf::io::CodedInputStream in(&raw);
google::protobuf::Arena arena;                             // ②
auto* msg =
    google::protobuf::Arena::CreateMessage<MyMessage>(&arena);
if (!msg->ParseFromCodedStream(&in)) return false;         // ③

ArrayInputStream is a zero-copy stream abstraction: the parser reads from caller-provided bytes without an extra staging buffer.

② The arena reduces object-allocation churn after parse.

③ The parser still does real structural work to build a usable message.

That distinction matters in architecture reviews. If your question is "can I parse this evolving API payload cheaply and safely across many languages," Protobuf is often the right answer. If your question is "can I point an accessor at serialized bytes and keep reading them in place," Protobuf usually isn't. Its schema update guide and its best-practices guide on tag reuse and deleted fields are blunt about tag stability. That is a different strength.

The language boundary often costs more than the format

Formats tell you what can be borrowed. The runtime decides whether that borrowed path survives contact with real code.

People collapse shared ownership, layout reinterpretation, archived object layouts, mapped files, and FFI buffers into one bucket called "zero-copy." That hides the useful question: what exactly are you borrowing — storage, type layout, or object identity?

Rust makes the split explicit. bytes::Bytes 1.12.1 gives shared immutable storage with cheap clones and slices. zerocopy 0.8.55 provides byte-to-type conversions with explicit layout and alignment traits. rkyv 0.8.17 archives Rust-native layouts for direct reads and documents optional validation, often via bytecheck, plus format-compatibility caveats. Those are three different tools for three different risks.

In Rust, the first question is often whether you only need shared storage or whether you also need typed reinterpretation.

let frame = Bytes::from(payload);  // ①
let tag = frame.slice(0..16);      // ②

Bytes makes cloning and slicing cheap because handles share immutable backing storage.

② That same trick creates the classic retained-parent problem: one tiny slice can keep a huge allocation alive.

On the JVM, the Java Virtual Machine, mapping and lifetime are the interesting parts. JEP 454 finalized the Foreign Function & Memory API in JDK 22; the Java SE 25 MemorySegment docs are the current reference for that finalized API surface.

MappedByteBuffer mapped =
    channel.map(FileChannel.MapMode.READ_ONLY, 0, size); // ③
MemorySegment seg = MemorySegment.ofBuffer(mapped);      // ④

MappedByteBuffer keeps the program buffer-oriented and off-heap, outside the managed Java heap.

MemorySegment wraps that buffer in explicit bounds and lifetime rules.

Python says the quiet part out loud: the exporter owns the memory and the consumer borrows it.

view = memoryview(mm)[128:160]  # ⑤

memoryview borrows bytes from the exporter. It does not own them, and it can keep exporter restrictions in place until release.

That is why the handoff API can cost more than the format. In Rust, Bytes is great for framed I/O and shared payload slices, zerocopy is great for fixed-layout headers, and rkyv can look excellent for Rust-only immutable caches. They are not interchangeable. On the JVM, direct buffers and mappings can save copies but still leave you with off-heap lifetime and accounting concerns; Java's docs explicitly say direct buffers have higher allocation and deallocation cost and work best for large, long-lived native I/O buffers. In Python 3.14, outstanding memoryview objects can block resizing operations until released, and the mmap module still inherits page-alignment, flush, and resize rules.

A buffer protocol is still an ownership protocol. A direct buffer is still a lifetime decision. An archived object is still a compatibility bet.

Schema evolution gets harder as physical layout becomes part of the contract

The more directly today's code reads yesterday's bytes, the less room you have for casual schema changes.

Protobuf is strongest when long-lived compatibility matters most. Its update guide and best-practices docs are blunt: don't renumber fields, don't reuse tags, reserve deleted numbers. The parser does more work up front, but that buys you room to evolve the logical schema without freezing too much physical layout.

FlatBuffers and Cap'n Proto also support evolution, just with more layout discipline. FlatBuffers schema docs say tables are evolvable while structs are fixed-layout and not evolvable. Cap'n Proto's language guide puts hard rules on field numbering and compatibility. Arrow is excellent when the shared object is a stable typed batch of columns. It is less natural when the data is a long-lived, free-form application object graph. rkyv pushes hardest on this boundary because the archived representation stays close to Rust's type model and configuration.

My bias here is practical: if only one small field must survive request scope, copy that field and release the large parent. A copy-out, an owned copy of just the long-lived value, is often cheaper than pinning the larger borrowed source for an unrelated downstream lifetime.

Pick the format by ownership boundary, not by hype

These tools optimize different boundaries. None wins everywhere.

OptionWhat is borrowedWhere the zero-copy claim is strongestValidation / unchecked trade-offMain costsEvolution posture (editorial shorthand)Best for
ArrowStandardized value, offset, and validity buffersCross-language typed memory and scan-heavy shared dataValidate untrusted buffers before useValidation, offset correctness, edge boxing, retained parentsStable analytic schemasShared-memory analytics, IPC, dataframe interchange
FlatBuffersOriginal serialized bufferDirect accessor reads over trusted or verified blobsVerifier recommended; Rust safe roots verify, _unchecked skipsVerifier cost, offset correctness, mutation hazardsEvolvable tables, fixed structsLocal binary blobs, mobile/runtime-friendly RPC
Cap'n ProtoOriginal serialized message wordsIn-place traversal of structured messagesTraversal limits and trust-boundary checks on untrusted inputPointer chasing, traversal limits, trust-boundary complexityGood with strict field disciplineRPC and structured message passing
ProtobufInput stream segments more than final object graphStream-level copy avoidance plus cheaper allocationParser checks wire-format structure; semantic validation remains yoursParse cost still remainsStrongest for long-lived public contractsCompatibility-first APIs and storage
rkyv 0.8.xArchived Rust-native layoutRust-only immutable snapshots and cachesOptional validation via bytecheck; raw archived access is faster and riskierValidation, version/config drift, Rust-local ecosystemMost version-sensitive hereInternal Rust caches and snapshots

That evolution-posture column is my shorthand, not spec vocabulary. I'm compressing several cited rules into one planning label: Protobuf favors additive, tag-stable evolution, FlatBuffers tables evolve but structs do not, Cap'n Proto imposes stricter numbering rules, Arrow shines when the columnar schema itself is stable, and rkyv 0.8.17 warns that archive compatibility depends on type and format-control choices.

If you need shared typed memory across languages, start with Arrow. If you need direct access to serialized structured blobs, start with FlatBuffers or Cap'n Proto. If compatibility beats raw read speed, start with Protobuf. If the data never leaves Rust and you control both writer and reader, rkyv can be compelling.

That isn't fence-sitting. It is the only honest answer.

What Could Go Wrong

Benchmark retained memory and page faults before you celebrate

A fair benchmark prices trust, retention, and memory topology. Bytes per second alone won't tell you enough.

Run the same logical dataset through three paths: owned decode, borrowed read with validation, and borrowed read without validation on truly trusted internal data. Then vary the payload shape. Fixed-width columns reward Arrow differently than string-heavy blobs. A single-field point lookup rewards FlatBuffers or Cap'n Proto differently than a full scan. A "keep one small field from one large parent" case often makes the copy-out baseline look much better than the microbenchmark predicted.

Warm-cache numbers are only half the story. Linux mmap(2) makes page-backed access part of the contract, as does Python 3.14's mmap wrapper. Cold-cache runs tell you about faults and I/O latency. JVM runs should also log direct-memory and GC effects because direct buffers are optimized for large, long-lived native I/O buffers, not free allocation churn. Python runs should log object creation at the wrapper edge because the format may be zero-copy while the binding still boxes values.

DimensionCases to test
Payload shapeFixed-width, string-heavy, nested, optional-field heavy
Trust levelTrusted internal bytes, validated untrusted file, untrusted network payload
Backing storeOwned heap bytes, direct/off-heap, read-only mmap
Access patternFirst field, random point lookup, full scan, retain-one-small-field
Cost metricsThroughput, p95/p99, allocations, retained resident set size (RSS), validation time, major/minor faults

As a first benchmark pass, Arrow usually deserves the first run for cross-language scans. FlatBuffers and Cap'n Proto usually deserve it for direct field reads from local blobs. Protobuf deserves to be the compatibility baseline. Then episode eight prices the rest of the bill: small payloads, non-uniform memory access (NUMA) misses, pinning, and fallback.

Key takeaways

  • Borrowed serialization removes allocation and materialization work only while lifetime and trust stay explicit.
  • Arrow, FlatBuffers, Cap'n Proto, and Protobuf optimize different boundaries. They are not interchangeable "zero-copy formats."
  • Rust, Java, Python, and mmap expose the real contract through slices, segments, exporters, and release events.
  • Validation, mutation, alignment, schema drift, and retained-parent memory often dominate the saved copy.
  1. Draw the ownership ledger for one real payload in your system.
  2. Mark the trust boundary where validation must happen.
  3. Measure retained memory and cold-cache faults, not only throughput.
  4. Copy out small long-lived fields instead of pinning large borrowed parents.
  5. Choose the format by boundary: shared memory, direct blob access, or compatibility.

What's next: Episode eight turns these ownership rules into a cost model and shows when copying is the faster path.

The copy is not the whole cost model. It is just the first line item.


Sources and References

Format specifications and serialization contracts

Runtime memory and language-ownership APIs

Operating-system mapping semantics

  • File-backed borrowed pages: mmap uses shared or private mappings with page-backed lifetime rules — Linux mmap(2)
  • Mapped-file fault behavior: accesses beyond the current end of the mapped object can raise SIGBUSLinux mmap(2)
  • Python mapping wrapper: page alignment, flush, and resize behavior still matter at the language layer — Python 3.14 mmap docs

Share this post

HNPost to Hacker News
Subscribe:RSS feed

Keep reading