Copying data is sometimes necessary, but unnecessary copying is one of the easiest ways to waste time and memory.
Avoiding Copies
Copying data is sometimes necessary, but unnecessary copying is one of the easiest ways to waste time and memory.
A copy takes bytes from one place and writes them to another place. For small values, this is cheap. For large arrays, large structs, strings, buffers, and file contents, copying can become expensive.
In Zig, you should learn to notice when data is copied and when data is only referenced.
Small Values Are Fine to Copy
Small integers, floats, booleans, enums, and small structs are usually fine to pass by value.
fn add(a: i32, b: i32) i32 {
return a + b;
}This copies two i32 values into the function. That is normal and cheap.
A small struct is also usually fine:
const Point = struct {
x: f32,
y: f32,
};
fn lengthSquared(p: Point) f32 {
return p.x * p.x + p.y * p.y;
}The struct has only two f32 fields. Passing it by value is reasonable.
Large Values Should Usually Be Borrowed
A large array should not usually be passed by value.
fn process(data: [4096]u8) void {
_ = data;
}This copies the whole array into the function.
A better version uses a slice:
fn process(data: []const u8) void {
_ = data;
}A slice does not copy the array contents. It only stores a pointer and a length.
That means this call is cheap:
var buffer: [4096]u8 = undefined;
process(buffer[0..]);The function can read the original memory without copying it.
Slices Are Views
A slice is a view into existing memory.
const text = "hello zig";
const part = text[0..5];part does not contain a new copy of "hello".
It points into the same memory as text.
text: hello zig
part: helloThis is useful for parsing.
Instead of copying every word, token, or field, you can store slices into the original input.
const Token = struct {
text: []const u8,
};A token can refer to part of the source text.
That avoids thousands or millions of small copies.
Use []const T for Read-Only Borrowing
When a function only needs to read data, use a const slice:
fn countSpaces(text: []const u8) usize {
var count: usize = 0;
for (text) |ch| {
if (ch == ' ') {
count += 1;
}
}
return count;
}This communicates two things:
The function does not own the memory.
The function will not modify the memory.
That is an efficient and clear API.
Use []T for Mutable Borrowing
When a function needs to modify data, use a mutable slice:
fn fillZeroes(buffer: []u8) void {
for (buffer) |*byte| {
byte.* = 0;
}
}The function still does not own the memory. It only receives permission to change it.
var buffer: [128]u8 = undefined;
fillZeroes(buffer[0..]);No heap allocation is needed. No large copy is needed.
Copying Strings
In Zig, strings are usually byte slices.
A string literal has type compatible with []const u8.
const name = "zig";If you only need to inspect the string, borrow it:
fn printName(name: []const u8) void {
std.debug.print("{s}\n", .{name});
}If you need to keep the string after the original memory may disappear, then you need a copy.
const owned_name = try allocator.dupe(u8, name);
defer allocator.free(owned_name);This is the core rule:
Borrow when the original memory remains valid.
Copy when you need independent ownership.
Ownership Decides Whether to Copy
A copy is often an ownership decision.
Suppose a function receives input:
fn parseLine(line: []const u8) void {
_ = line;
}This function borrows line. It must not store the slice somewhere that outlives the original memory.
Now compare:
const Record = struct {
name: []u8,
};
fn makeRecord(allocator: std.mem.Allocator, name: []const u8) !Record {
return .{
.name = try allocator.dupe(u8, name),
};
}This function copies name, because the returned Record owns its own memory.
That copy is necessary if the original name may disappear.
Beware Hidden Copies Through Arrays
Arrays in Zig are values.
That means assigning an array copies the whole array.
var a = [_]u8{ 1, 2, 3, 4 };
var b = a;Now b is a separate array.
Changing b does not change a.
b[0] = 99;a[0] is still 1.
This is useful, but it can surprise beginners when the array is large.
For large data, prefer slices or pointers.
Struct Copies
Structs are also values.
const Big = struct {
data: [4096]u8,
};
fn process(x: Big) void {
_ = x;
}Calling process(big) copies the whole struct.
A better version may use a pointer:
fn process(x: *const Big) void {
_ = x;
}Then call:
process(&big);Now the function receives the address of big.
No full struct copy is needed.
Pointer vs Slice
Use a pointer when you refer to one object:
fn updateUser(user: *User) void {
user.score += 1;
}Use a slice when you refer to many items:
fn updateUsers(users: []User) void {
for (users) |*user| {
user.score += 1;
}
}Both avoid copying the full data.
The difference is shape:
A pointer means one object.
A slice means many contiguous objects.
Returning Large Data
Returning a small value is fine:
fn makePoint() Point {
return .{ .x = 1, .y = 2 };
}Returning a large array may copy a lot of data:
fn makeBuffer() [4096]u8 {
var buffer: [4096]u8 = undefined;
return buffer;
}The compiler may optimize some copies, but API design should still be clear.
For large results, consider caller-provided storage:
fn makeBuffer(out: []u8) void {
for (out) |*byte| {
byte.* = 0;
}
}The caller owns the memory.
var buffer: [4096]u8 = undefined;
makeBuffer(buffer[0..]);No heap allocation is needed. No ownership confusion is introduced.
In-Place Modification
Copying is often avoidable when you modify data in place.
Copying version:
fn uppercaseCopy(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
const out = try allocator.dupe(u8, input);
for (out) |*ch| {
if (ch.* >= 'a' and ch.* <= 'z') {
ch.* -= 32;
}
}
return out;
}In-place version:
fn uppercaseInPlace(buffer: []u8) void {
for (buffer) |*ch| {
if (ch.* >= 'a' and ch.* <= 'z') {
ch.* -= 32;
}
}
}The in-place version is faster and allocates nothing, but it changes the input.
That tradeoff must be clear in the API name and type.
const Helps API Design
const helps show whether copying is needed.
Read-only input:
fn hash(data: []const u8) u64 {
_ = data;
return 0;
}Mutable input:
fn normalize(data: []u8) void {
_ = data;
}Owned output:
fn clone(allocator: std.mem.Allocator, data: []const u8) ![]u8 {
return try allocator.dupe(u8, data);
}These signatures tell the reader what happens to memory.
Avoid Copying in Parsers
Parsers often process large input.
Bad parser design copies each piece:
const Field = struct {
value: []u8,
};Every field needs allocation and copying.
Better parser design stores views:
const Field = struct {
value: []const u8,
};Each field points into the original input.
This is much faster, but the original input must remain alive while the parsed fields are used.
That lifetime rule is essential.
Avoid Copying in File Processing
Suppose you read a whole file into memory:
const data = try file.readToEndAlloc(allocator, max_size);
defer allocator.free(data);After this, prefer slicing the buffer.
const header = data[0..16];
const body = data[16..];Do not copy the header and body unless you need independent ownership.
The file buffer already contains the bytes.
Avoid Copying in Collections
When adding data to a collection, decide whether the collection owns the data.
Borrowing collection:
const Entry = struct {
name: []const u8,
};Owning collection:
const Entry = struct {
name: []u8,
};The owning version usually requires copying:
entry.name = try allocator.dupe(u8, input_name);The borrowing version avoids copying but depends on external lifetime.
Neither is always correct. Choose based on ownership.
std.mem.copyForwards
When you do need to copy memory, Zig provides standard library functions.
std.mem.copyForwards(u8, dst, src);This copies from src into dst.
Example:
var dst: [5]u8 = undefined;
const src = "hello";
std.mem.copyForwards(u8, dst[0..], src);Use library functions instead of writing manual byte-copy loops.
They are clearer and may be optimized well.
Do Not Fear Necessary Copies
Avoiding copies does not mean “never copy.”
A copy is correct when:
- you need ownership
- you need to mutate without changing the original
- you need stable data after input memory disappears
- you need compact storage
- you need to send data to another subsystem
The goal is to remove accidental copies, not useful ones.
Mental Model
When you see data move through a Zig program, ask:
- Is this value small or large?
- Is this an array copy?
- Is this a struct copy?
- Could a slice avoid copying?
- Could a pointer avoid copying?
- Who owns this memory?
- How long does the original memory live?
- Does this function need to mutate the data?
- Does this result need independent ownership?
In Zig, performance and ownership are connected.
Borrow with slices and pointers when possible.
Copy when ownership requires it.