Zero-Copy Serialization Borrows Memory Instead of Owning It

- 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, andbob@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; // ③①
ArrayInputStreamis 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); // ②①
Bytesmakes 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); // ④③
MappedByteBufferkeeps the program buffer-oriented and off-heap, outside the managed Java heap.④
MemorySegmentwraps 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] # ⑤⑤
memoryviewborrows 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.
| Option | What is borrowed | Where the zero-copy claim is strongest | Validation / unchecked trade-off | Main costs | Evolution posture (editorial shorthand) | Best for |
|---|---|---|---|---|---|---|
| Arrow | Standardized value, offset, and validity buffers | Cross-language typed memory and scan-heavy shared data | Validate untrusted buffers before use | Validation, offset correctness, edge boxing, retained parents | Stable analytic schemas | Shared-memory analytics, IPC, dataframe interchange |
| FlatBuffers | Original serialized buffer | Direct accessor reads over trusted or verified blobs | Verifier recommended; Rust safe roots verify, _unchecked skips | Verifier cost, offset correctness, mutation hazards | Evolvable tables, fixed structs | Local binary blobs, mobile/runtime-friendly RPC |
| Cap'n Proto | Original serialized message words | In-place traversal of structured messages | Traversal limits and trust-boundary checks on untrusted input | Pointer chasing, traversal limits, trust-boundary complexity | Good with strict field discipline | RPC and structured message passing |
| Protobuf | Input stream segments more than final object graph | Stream-level copy avoidance plus cheaper allocation | Parser checks wire-format structure; semantic validation remains yours | Parse cost still remains | Strongest for long-lived public contracts | Compatibility-first APIs and storage |
rkyv 0.8.x | Archived Rust-native layout | Rust-only immutable snapshots and caches | Optional validation via bytecheck; raw archived access is faster and riskier | Validation, version/config drift, Rust-local ecosystem | Most version-sensitive here | Internal 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
- FFI lifetime safety turns into use-after-free — Arrow's C Data Interface requires exporters to coordinate ownership through explicit
releasecallbacks. Free the buffers first, and your borrowed reader now points at dead memory. - Unchecked direct access turns offsets into an attack surface — FlatBuffers' C++ docs recommend the verifier for untrusted buffers, and the Rust docs separate verified access from
_uncheckedentry points. - Pointer-rich messages need traversal limits, not only bounds checks — Cap'n Proto's C++ docs document traversal limits for untrusted input. The failure mode is not just out-of-bounds access. It is hostile graph shape and traversal cost.
- Mapped files borrow from the file, not from your process —
mmap(2)says access to a mapped page beyond the current end of the underlying object can raiseSIGBUS. If another process truncates the file after you map it, an accessor that looked safe at startup can fault later. - Tiny slices can pin giant parents —
bytes::Bytesshares immutable backing storage across slices, and Python 3.14memoryviewkeeps exporter constraints in force until release. A small borrowed field can keep an otherwise disposable allocation alive. - The "trusted" fast path tends to widen over time —
rkyv0.8.17 documents optional validation and compatibility caveats for direct reads. The internal path you skip checks on today often becomes tomorrow's semi-trusted ingestion path. - Managed runtimes still have lifetime cliffs — Java arenas make deallocation explicit when you choose them, but direct buffers and mapped regions still live outside ordinary heap ownership.
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.
| Dimension | Cases to test |
|---|---|
| Payload shape | Fixed-width, string-heavy, nested, optional-field heavy |
| Trust level | Trusted internal bytes, validated untrusted file, untrusted network payload |
| Backing store | Owned heap bytes, direct/off-heap, read-only mmap |
| Access pattern | First field, random point lookup, full scan, retain-one-small-field |
| Cost metrics | Throughput, 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
mmapexpose the real contract through slices, segments, exporters, and release events. - Validation, mutation, alignment, schema drift, and retained-parent memory often dominate the saved copy.
- Draw the ownership ledger for one real payload in your system.
- Mark the trust boundary where validation must happen.
- Measure retained memory and cold-cache faults, not only throughput.
- Copy out small long-lived fields instead of pinning large borrowed parents.
- 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
- Arrow buffer layout: validity bitmaps use one bit per value; Arrow recommends 8- or 64-byte alignment and padding, preferring 64 bytes, and enforces alignment for IPC — Apache Arrow Columnar Format
- Arrow FFI ownership: imported arrays and schemas use explicit
releasecallbacks — Apache Arrow C Data Interface - Arrow untrusted-input warning: invalid offsets and invalid UTF-8 can break downstream consumers — Apache Arrow Security Considerations
- Arrow immutability and zero-copy slicing: arrays are immutable and slices share buffers — Arrow C++ Arrays
- PyArrow zero-copy reads: memory-mapped and buffer-backed IPC reads can stay zero-copy — PyArrow IPC Docs
- FlatBuffers direct access model: serialized buffers are meant for direct field access without unpacking — FlatBuffers Overview
- FlatBuffers physical layout: scalars are little-endian and aligned to their size — FlatBuffers Internals
- FlatBuffers evolution rules: tables evolve; structs do not — FlatBuffers Schema Guide
- FlatBuffers verifier guidance: untrusted buffers should be verified before access — FlatBuffers C++ Guide
- FlatBuffers Rust safety split: verified safe APIs differ from
_uncheckedentry points — FlatBuffers Rust Guide - Cap'n Proto layout: messages use 8-byte words and pointer-based traversal — Cap'n Proto Encoding
- Cap'n Proto compatibility rules: field numbering and schema evolution are constrained — Cap'n Proto Language Guide
- Cap'n Proto traversal limits: readers are non-owning and untrusted input needs limits — Cap'n Proto C++ Docs
- Protobuf wire format: tags, varints, and length-delimited fields require parse work — Protocol Buffers Encoding Guide
- Protobuf stream-level zero-copy:
ZeroCopyInputStreamborrows stream-owned buffers — Protobuf C++ ZeroCopy Streams - Protobuf arenas: arenas reduce allocation cost after parse — Protobuf C++ Arena Allocation Guide
- Protobuf compatibility discipline: don't renumber or reuse tags; reserve deleted ones — Protobuf Proto3 Updating Guide and Protobuf Best Practices
Runtime memory and language-ownership APIs
- Shared immutable byte slices: cheap clones and slices share backing storage —
bytes1.12.1 docs - Layout-aware byte reinterpretation: alignment and layout traits make direct reads explicit —
zerocopy0.8.55 docs - Archived direct reads in Rust: validation and format-compatibility caveats define the safe envelope —
rkyv0.8.17 docs - Foreign memory lifetime in Java: JEP 454 finalized FFM in JDK 22; JDK 25 docs are the current API reference — JEP 454, Java SE 25
MemorySegmentAPI, and Java SE 25ArenaAPI - Shared backing buffers and direct-memory trade-offs: direct buffers cost more to allocate and free — Java SE 25
ByteBufferAPI - Python borrowed-memory contract: exporters and consumers coordinate raw buffer access — Python 3.14 Buffer Protocol C-API
- Python view lifetime behavior: outstanding
memoryviewobjects keep exporter constraints active — Python 3.14memoryviewdocs
Operating-system mapping semantics
- File-backed borrowed pages:
mmapuses shared or private mappings with page-backed lifetime rules — Linuxmmap(2) - Mapped-file fault behavior: accesses beyond the current end of the mapped object can raise
SIGBUS— Linuxmmap(2) - Python mapping wrapper: page alignment, flush, and resize behavior still matter at the language layer — Python 3.14
mmapdocs



