Zig gives you direct control over memory. That control is useful, but it also means you must follow clear rules.
Zig gives you direct control over memory. That control is useful, but it also means you must follow clear rules.
This checklist gives you a practical way to avoid common memory bugs.
E.1 Initialize Before Reading
Do not read a value before it has a real value.
var x: i32 = undefined;This is allowed, but x has no meaningful value yet.
Bad:
var x: i32 = undefined;
std.debug.print("{}\n", .{x});Good:
var x: i32 = undefined;
x = 10;
std.debug.print("{}\n", .{x});Use undefined only when you will definitely assign a valid value before reading.
E.2 Free What You Allocate
If you allocate memory, you are responsible for freeing it.
const buffer = try allocator.alloc(u8, 1024);
defer allocator.free(buffer);The defer makes cleanup happen when the current scope ends.
This is one of the most important Zig habits:
const value = try makeSomething(allocator);
defer destroySomething(allocator, value);Allocate and clean up in the same visible area when possible.
E.3 Use the Same Allocator to Free
Memory should be freed by the allocator that created it.
Bad:
const buffer = try allocator_a.alloc(u8, 1024);
allocator_b.free(buffer);Good:
const buffer = try allocator.alloc(u8, 1024);
defer allocator.free(buffer);A simple rule: the allocator that allocates owns the cleanup path.
E.4 Do Not Use Memory After Free
After memory is freed, it is no longer valid.
Bad:
const buffer = try allocator.alloc(u8, 10);
allocator.free(buffer);
buffer[0] = 1;This is a use-after-free bug.
Good:
const buffer = try allocator.alloc(u8, 10);
buffer[0] = 1;
allocator.free(buffer);After free, do not read or write through the old slice or pointer.
E.5 Avoid Returning Pointers to Local Variables
Local variables live only while the function is running.
Bad:
fn makePtr() *i32 {
var x: i32 = 10;
return &x;
}When the function returns, x is gone. The returned pointer is invalid.
Good:
fn writeValue(out: *i32) void {
out.* = 10;
}Or allocate memory with an allocator if the value must live longer.
E.6 Know Who Owns the Memory
Every allocated object needs an owner.
Ask this question:
Who is responsible for freeing this?
If the answer is unclear, the API design needs work.
Clear ownership example:
fn makeBuffer(allocator: std.mem.Allocator) ![]u8 {
return try allocator.alloc(u8, 1024);
}This function returns allocated memory. The caller must free it.
Caller:
const buffer = try makeBuffer(allocator);
defer allocator.free(buffer);The ownership rule should be documented in the function name, signature, or comments.
E.7 Prefer Slices Over Raw Pointers
A slice stores both pointer and length.
[]u8A pointer alone does not know how many items are valid.
[*]u8Prefer this:
fn fill(buffer: []u8) void {
for (buffer) |*b| {
b.* = 0;
}
}Instead of this:
fn fill(ptr: [*]u8, len: usize) void {
// more error-prone
}Slices make bounds clearer.
E.8 Check Slice Bounds
Do not assume a slice has enough items.
Bad:
fn firstByte(bytes: []const u8) u8 {
return bytes[0];
}This crashes if bytes.len == 0.
Good:
fn firstByte(bytes: []const u8) ?u8 {
if (bytes.len == 0) return null;
return bytes[0];
}Use ?T when a value may be absent.
E.9 Be Careful with Sub-Slices
A sub-slice points into the same memory as the original slice.
const part = buffer[0..4];If buffer becomes invalid, part also becomes invalid.
Bad:
const buffer = try allocator.alloc(u8, 10);
const part = buffer[0..4];
allocator.free(buffer);
std.debug.print("{any}\n", .{part});Good:
const buffer = try allocator.alloc(u8, 10);
defer allocator.free(buffer);
const part = buffer[0..4];
std.debug.print("{any}\n", .{part});Sub-slices do not own memory. They borrow it.
E.10 Use defer for Cleanup
defer is one of the best tools for memory safety.
const file = try std.fs.cwd().openFile("data.txt", .{});
defer file.close();
const buffer = try allocator.alloc(u8, 1024);
defer allocator.free(buffer);This keeps cleanup close to acquisition.
The usual pattern is:
const resource = try acquire();
defer release(resource);E.11 Use errdefer for Partial Failure
Use errdefer when cleanup should happen only if the function returns an error.
Example:
fn makeThing(allocator: std.mem.Allocator) !*Thing {
const thing = try allocator.create(Thing);
errdefer allocator.destroy(thing);
thing.buffer = try allocator.alloc(u8, 1024);
errdefer allocator.free(thing.buffer);
return thing;
}If something fails before return thing, errdefer cleans up.
If the function succeeds, ownership passes to the caller.
E.12 Match create with destroy
For one object:
const p = try allocator.create(i32);
defer allocator.destroy(p);For many objects:
const items = try allocator.alloc(i32, 100);
defer allocator.free(items);Use the matching pair:
| Allocation | Cleanup |
|---|---|
create(T) | destroy(ptr) |
alloc(T, n) | free(slice) |
Do not mix them.
E.13 Avoid Hidden Allocation
A function that allocates should usually take an allocator.
Clear:
fn joinNames(allocator: std.mem.Allocator, a: []const u8, b: []const u8) ![]u8 {
return try std.fmt.allocPrint(allocator, "{s} {s}", .{ a, b });
}Unclear:
fn joinNames(a: []const u8, b: []const u8) ![]u8 {
// where does memory come from?
}In Zig, allocation should be visible in the API.
E.14 Use Arena Allocators for Shared Lifetimes
When many objects live and die together, an arena allocator is often simpler.
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();Then allocate many objects:
const a = try allocator.alloc(u8, 100);
const b = try allocator.alloc(u8, 200);
const c = try allocator.alloc(u8, 300);You do not free each one separately. arena.deinit() frees them all.
Use arenas for parsers, request handling, temporary data, and build steps.
E.15 Do Not Store Borrowed Slices Too Long
A borrowed slice points to memory owned by someone else.
Bad pattern:
const User = struct {
name: []const u8,
};This is fine only if the memory behind name lives long enough.
If you store a slice in a long-lived object, ask:
Where does this memory come from?
How long does it live?
Who frees it?
If the source memory is temporary, copy it:
user.name = try allocator.dupe(u8, input_name);Then free it later.
E.16 Be Careful with std.ArrayList.items
list.items is a slice into the list’s internal buffer.
const items = list.items;If the list grows, its buffer may move.
Bad:
const items = list.items;
try list.append(99);
std.debug.print("{}\n", .{items[0]});After append, the old items slice may be invalid.
Good:
try list.append(99);
const items = list.items;
std.debug.print("{}\n", .{items[0]});Do not keep old slices across operations that may reallocate.
E.17 Be Careful with Pointers Into Containers
This is similar to ArrayList.items.
Bad:
const ptr = &list.items[0];
try list.append(99);
ptr.* = 10;If append reallocates, ptr may point to old memory.
Fix by taking the pointer after all growth:
try list.append(99);
const ptr = &list.items[0];
ptr.* = 10;E.18 Prefer Explicit Lifetimes in API Design
Zig does not have Rust-style lifetime annotations. You must design lifetimes clearly.
Good comments help:
/// Returns a slice that points into `input`.
/// The returned slice must not outlive `input`.
fn firstWord(input: []const u8) []const u8 {
// ...
}Or:
/// Returns newly allocated memory.
/// Caller owns the returned slice and must free it with `allocator.free`.
fn copyWord(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
// ...
}These two functions have different ownership rules. Make that visible.
E.19 Use Debug Builds While Learning
Debug builds include safety checks.
Use:
zig build-exe main.zigor:
zig run main.zigRelease-fast builds remove some checks for speed.
Do not start by optimizing. Start by making the program correct.
E.20 Use the General Purpose Allocator for Leak Checks
During development, use GeneralPurposeAllocator.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const status = gpa.deinit();
if (status == .leak) {
std.debug.print("memory leak detected\n", .{});
}
}
const allocator = gpa.allocator();This helps catch memory leaks while testing.
E.21 Prefer Small Ownership Boundaries
Do not spread allocation and cleanup across many files unless necessary.
Hard to follow:
// allocated in one module
// stored in another
// freed somewhere else laterEasier:
fn run(allocator: std.mem.Allocator) !void {
const data = try loadData(allocator);
defer freeData(allocator, data);
try process(data);
}Keep the lifetime visible.
E.22 Checklist Before Returning a Slice
Before returning []T or []const T, ask:
| Question | Safe answer |
|---|---|
| Does it point to local stack memory? | No |
| Does it point to freed memory? | No |
| Does the caller know who owns it? | Yes |
| Does the caller know how long it lives? | Yes |
| If allocated, does caller know how to free it? | Yes |
Bad:
fn bad() []const u8 {
var buf = [_]u8{ 'h', 'i' };
return buf[0..];
}Good:
fn good(allocator: std.mem.Allocator) ![]u8 {
const buf = try allocator.alloc(u8, 2);
buf[0] = 'h';
buf[1] = 'i';
return buf;
}Caller frees the result.
E.23 Checklist Before Storing a Pointer
Before storing *T, ask:
| Question | Safe answer |
|---|---|
| What object does it point to? | Known |
| Who owns that object? | Known |
| Can the object move? | No, or pointer is refreshed |
| Can the object be freed first? | No |
| Can another thread mutate it? | Controlled |
Pointers are powerful. They should have clear ownership and lifetime rules.
E.24 Checklist Before Using undefined
Before using undefined, ask:
| Question | Safe answer |
|---|---|
| Will every field or byte be written before reading? | Yes |
| Is this needed for performance or API shape? | Yes |
| Would a normal initializer be clearer? | No |
| Can tests catch misuse? | Yes |
Prefer normal initialization when possible.
var x: i32 = 0;Use undefined only when you need it.
E.25 The Core Memory Rule
Most memory bugs come from one of four mistakes:
| Bug | Meaning |
|---|---|
| Use before init | Reading memory before it has a real value |
| Use after free | Reading memory after cleanup |
| Out of bounds | Reading outside valid range |
| Wrong owner | Freeing or storing memory with unclear ownership |
Zig gives you tools to avoid these bugs, but it does not remove your responsibility.
A safe Zig program is built from visible ownership, careful lifetimes, explicit allocation, and consistent cleanup.