“Zero-copy” is one of those systems terms that sounds more absolute than it really is. It can mean avoiding an application-level allocation, parsing by borrowing from an input buffer, sharing one immutable byte buffer between tasks, mapping file pages into a process, or asking the kernel to move data without first copying it through a user-space staging buffer.
Those are related optimizations, but they are not identical.
Rust is particularly good at making the ownership side of zero-copy explicit. References and lifetimes let an API return views into existing storage without giving up memory safety. The interesting engineering work is deciding which copy is actually expensive, which lifetime owns the bytes, and whether eliminating the copy improves the whole system rather than one microbenchmark.
Start with the simplest zero-copy operation: borrow
If a function only needs to inspect bytes, it should usually accept a slice rather than take ownership of a new Vec<u8>.
fn checksum(data: &[u8]) -> u64 {
data.iter().map(|byte| *byte as u64).sum()
}
let payload = vec![1, 2, 3, 4];
let value = checksum(&payload);
The call does not copy the payload. &[u8] is a view consisting conceptually of a pointer and a length, with a lifetime tied to the underlying allocation.
The same principle applies to strings. Prefer &str when a callee only needs a borrowed UTF-8 view; require String when ownership is part of the contract.
This sounds trivial, but API shape determines how much copying becomes unavoidable later. A function that accepts String by value may force callers to allocate even when the source data already lives in a larger input buffer.
Parse by returning views into the original input
Parsers are a natural zero-copy use case. If a field can be represented as a slice of the input, there is no reason to allocate a second string merely to name it.
fn split_once<'a>(input: &'a str, separator: char) -> Option<(&'a str, &'a str)> {
let index = input.find(separator)?;
Some((&input[..index], &input[index + separator.len_utf8()..]))
}
The returned strings borrow from input. Rust’s lifetime makes the central safety condition explicit: those views cannot outlive the source buffer.
Libraries such as nom make this pattern ergonomic. The performance benefit is not only fewer allocations. Borrowed parsing can also improve cache locality and reduce allocator contention when a service processes many small messages.
The trade-off is lifetime coupling. If a parsed object must outlive the network buffer or cross a boundary that requires 'static ownership, the application eventually needs to copy or otherwise promote the bytes into owned storage.
Zero-copy often moves a copy rather than removing it forever. That can still be valuable if the copy is moved off the hot path or avoided for the common case.
Cow: borrow until mutation is actually necessary
Cow<'a, T> expresses a useful two-mode contract: the value may be borrowed, but the function is allowed to create an owned representation when transformation is required.
use std::borrow::Cow;
fn normalize(input: &str) -> Cow<'_, str> {
if input.bytes().all(|b| !b.is_ascii_uppercase()) {
Cow::Borrowed(input)
} else {
Cow::Owned(input.to_ascii_lowercase())
}
}
The fast path returns a view into the caller’s string. Only the path that needs a changed representation allocates.
This pattern works especially well for normalization, escaping, canonicalization, and protocol layers where most values already satisfy the expected form.
Shared ownership is different from copying
Sometimes a buffer must be passed between independently owned components. A reference is no longer enough because the receiving task may outlive the current stack frame.
An Arc<[u8]>, Arc<Vec<u8>>, or a reference-counted byte type such as bytes::Bytes can let many owners share one allocation.
use bytes::Bytes;
fn frame(payload: Bytes) -> (Bytes, Bytes) {
let header = payload.slice(..4);
let body = payload.slice(4..);
(header, body)
}
Slices of Bytes share the underlying storage rather than copying the selected range. That is very useful in network stacks where parsers, protocol handlers, and queues all need ownership without cloning the payload itself.
Reference counting is not free. Cloning the handle changes a counter, and retaining a tiny slice can keep a much larger allocation alive. Memory lifetime is therefore part of the performance model.
Memory mapping: avoid an explicit read buffer, not physical I/O
A memory-mapped file lets a process access file-backed pages through its address space.
use memmap2::MmapOptions;
use std::fs::File;
fn map_file(path: &str) -> std::io::Result<memmap2::Mmap> {
let file = File::open(path)?;
unsafe { MmapOptions::new().map(&file) }
}
The application no longer needs to call read into a separately allocated user buffer before indexing the contents. The kernel’s virtual-memory subsystem brings pages into memory as needed.
Calling this “zero-copy” requires care. Storage still has to deliver data into memory, page faults can be expensive, and the kernel may still move data through caches and device buffers. The optimization is that the application avoids an explicit intermediate copy and can access page-cache-backed memory directly.
Memory mapping also introduces operational concerns:
- access can fault long after
mmapitself succeeds; - truncating or otherwise invalidating a mapped file can make later access unsafe at the OS level;
- random access patterns can generate poor page locality;
- mapping many large files consumes virtual address space and page-table resources.
That is why memmap2 exposes file mapping as unsafe: the memory safety story depends partly on what happens to the underlying file outside Rust’s ownership model.
Sending a mapped file is not automatically kernel zero-copy
Consider:
socket.write_all(&mapped_bytes)?;
The application avoided a read into its own staging Vec, but the normal socket write path may still copy bytes from user-visible memory into kernel networking buffers.
For file-to-socket transfer, operating systems provide mechanisms such as Linux sendfile or splice that can avoid the traditional user-space read/write loop. The exact number of physical copies depends on the kernel, networking stack, device features, and protocol path.
The useful distinction is:
application-level zero-copy
avoid allocating/copying between your own data structures
kernel-assisted zero-copy
avoid routing payload bytes through a user-space staging buffer
They solve different bottlenecks.
Serialization can borrow too
Serde supports borrowed fields when the input format and deserializer can expose them safely.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Message<'a> {
#[serde(borrow)]
topic: &'a str,
}
A deserializer may be able to point topic directly into the input buffer. But this is format-dependent: escaped strings, transformations, or streaming input may require allocation.
A “zero-copy deserializer” should therefore document which fields can borrow and which cases materialize owned values.
Zero-copy and asynchronous I/O
Async I/O adds a lifetime problem: a buffer must remain valid until the kernel or runtime has finished using it.
Traditional readiness-based APIs typically let the application perform a read or write only while it owns a buffer for the call. Completion-based APIs can submit an operation that retains responsibility for that memory until a later completion event.
Rust wrappers often encode that relationship in ownership. Passing a buffer into an asynchronous operation and receiving it back on completion can be safer than exposing raw pointers whose lifetime the compiler cannot understand.
This is one reason zero-copy designs should start from ownership, not from pointer arithmetic.
When copying is actually faster
Eliminating copies is not a universal optimization.
A small contiguous copy can be extremely cheap. It may even improve later execution by packing the relevant bytes into a cache-friendly representation and releasing a large backing buffer sooner.
Zero-copy can lose when:
- shared ownership extends the lifetime of large allocations;
- fragmented buffers increase scatter/gather overhead;
- parsing through indirect views hurts locality;
- bookkeeping and reference counting cost more than copying a tiny payload;
- the next API boundary requires a contiguous owned representation anyway;
- lifetime complexity makes the code harder to reason about or prevents useful concurrency.
The right question is not “can I remove this copy?” It is “what resource is this copy consuming, and is it on the critical path?”
A useful ownership model
I like to think about buffers as moving through stages:
network / file
|
v
owned input buffer
|
+--> borrowed parser views
|
+--> shared immutable slices
|
+--> copied owned value only when retention/mutation requires it
This keeps the common path cheap while making ownership transitions explicit.
Measure bytes copied, not just allocations
Allocation counts are useful, but they do not capture the whole picture. For a high-throughput service I would also look at:
- bytes allocated per request;
- bytes copied or materialized between stages;
- retained buffer capacity;
- CPU time in memcpy-like paths;
- cache-miss behavior;
- page faults for mapped workloads;
- system-call rate;
- network throughput and tail latency.
A zero-copy optimization that removes allocations but increases retained memory or tail latency is not a win.
The Rust advantage
Rust does not make data movement disappear. What it does well is let us express the safe lifetime of existing data.
Borrowing lets a parser return a view instead of an allocation. Cow delays ownership until mutation. Reference-counted buffers share storage across asynchronous components. Memory mapping can expose file-backed pages without an explicit read buffer. Kernel APIs can remove additional copies across the system-call boundary.
The important discipline is to name the boundary precisely.
“Zero-copy” is not one feature. It is a collection of techniques for avoiding unnecessary movement while preserving a correct ownership model. In Rust, the best version of that optimization is the one where the type system still makes it obvious who owns the bytes and how long they remain valid.