Skip to content

Appendix E. Memory Safety Checklist

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.

[]u8

A pointer alone does not know how many items are valid.

[*]u8

Prefer 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:

AllocationCleanup
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.zig

or:

zig run main.zig

Release-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 later

Easier:

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:

QuestionSafe 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:

QuestionSafe 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:

QuestionSafe 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:

BugMeaning
Use before initReading memory before it has a real value
Use after freeReading memory after cleanup
Out of boundsReading outside valid range
Wrong ownerFreeing 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.