Skip to content

Memory Debugging

Memory debugging means finding mistakes in how a program uses memory.

Memory debugging means finding mistakes in how a program uses memory.

In Zig, memory bugs usually come from one of these problems:

using memory after it has been freed

freeing the same memory twice

forgetting to free allocated memory

reading or writing past the end of a slice

returning a pointer to memory that no longer exists

using an allocator incorrectly

Zig gives you tools and habits that make these bugs easier to find.

Start with the Testing Allocator

In tests, use std.testing.allocator.

const std = @import("std");

test "allocate and free memory" {
    const allocator = std.testing.allocator;

    const bytes = try allocator.alloc(u8, 16);
    defer allocator.free(bytes);

    try std.testing.expectEqual(@as(usize, 16), bytes.len);
}

This allocator is useful in tests because it can help detect leaks.

The rule is simple:

Every allocation must have a matching cleanup, unless ownership is clearly transferred.

A Memory Leak

A leak happens when you allocate memory and never free it.

Bad:

const std = @import("std");

test "leaks memory" {
    const allocator = std.testing.allocator;

    const bytes = try allocator.alloc(u8, 16);
    _ = bytes;
}

The allocation is never freed.

Fix it with defer:

const std = @import("std");

test "does not leak memory" {
    const allocator = std.testing.allocator;

    const bytes = try allocator.alloc(u8, 16);
    defer allocator.free(bytes);

    _ = bytes;
}

defer is one of the most important tools for memory cleanup in Zig.

Use errdefer for Error Paths

Sometimes cleanup is needed only if the function fails.

Use errdefer.

const std = @import("std");

fn makeBuffer(allocator: std.mem.Allocator) ![]u8 {
    const bytes = try allocator.alloc(u8, 1024);
    errdefer allocator.free(bytes);

    // More work could fail here.

    return bytes;
}

If the function returns an error after allocation, errdefer frees the memory.

If the function succeeds, ownership of bytes is returned to the caller.

The caller must then free it:

test "makeBuffer returns owned memory" {
    const allocator = std.testing.allocator;

    const bytes = try makeBuffer(allocator);
    defer allocator.free(bytes);

    try std.testing.expectEqual(@as(usize, 1024), bytes.len);
}

This pattern is common in Zig APIs.

Out-of-Bounds Access

A slice has a length.

You may only access valid indexes.

const values = [_]i32{ 10, 20, 30 };
const slice = values[0..];

const x = slice[3];
_ = x;

This is wrong.

Valid indexes are:

0
1
2

The index 3 is one past the end.

The correct last index is:

slice.len - 1

So this is valid when the slice is not empty:

const x = slice[slice.len - 1];

Always check empty slices before using slice.len - 1:

fn last(items: []const i32) ?i32 {
    if (items.len == 0) return null;
    return items[items.len - 1];
}

Use Slices Instead of Raw Pointers

For beginner code, prefer slices.

A slice carries both:

a pointer to memory

a length

That length helps Zig check bounds.

Prefer this:

fn sum(items: []const i32) i32 {
    var total: i32 = 0;

    for (items) |item| {
        total += item;
    }

    return total;
}

Avoid this unless you really need it:

fn sum(ptr: [*]const i32, len: usize) i32 {
    var total: i32 = 0;
    var i: usize = 0;

    while (i < len) : (i += 1) {
        total += ptr[i];
    }

    return total;
}

The slice version is safer and clearer.

Use After Free

Use after free means using memory after giving it back to the allocator.

Bad:

const std = @import("std");

test "use after free" {
    const allocator = std.testing.allocator;

    const bytes = try allocator.alloc(u8, 4);
    allocator.free(bytes);

    bytes[0] = 42;
}

After allocator.free(bytes), the program no longer owns that memory.

The fix is simple:

Do not use a slice after freeing it.

A good pattern is to free at the end of the scope:

const bytes = try allocator.alloc(u8, 4);
defer allocator.free(bytes);

bytes[0] = 42;

With defer, the cleanup happens after normal use.

Double Free

Double free means freeing the same allocation twice.

Bad:

const std = @import("std");

test "double free" {
    const allocator = std.testing.allocator;

    const bytes = try allocator.alloc(u8, 4);

    allocator.free(bytes);
    allocator.free(bytes);
}

The memory is returned twice.

This is a serious ownership bug.

A good rule:

The code that owns memory frees it exactly once.

Ownership

Ownership answers this question:

Who is responsible for freeing this memory?

Example:

fn makeName(allocator: std.mem.Allocator) ![]u8 {
    const name = try allocator.dupe(u8, "zig");
    return name;
}

This function returns allocated memory.

The caller owns it:

test "makeName returns owned memory" {
    const allocator = std.testing.allocator;

    const name = try makeName(allocator);
    defer allocator.free(name);

    try std.testing.expectEqualStrings("zig", name);
}

Document ownership through code shape.

If a function returns allocated memory, the caller usually frees it.

If a function only borrows memory, it should not free it.

Borrowed Memory

Borrowed memory means the function can use the memory but does not own it.

fn printName(name: []const u8) void {
    std.debug.print("{s}\n", .{name});
}

This function receives a slice.

It does not allocate it.

It does not free it.

It only reads it.

That is borrowed memory.

Returning Pointers to Local Data

Do not return a pointer to local stack data.

Bad:

fn bad() []const u8 {
    var buffer = [_]u8{ 'z', 'i', 'g' };
    return buffer[0..];
}

buffer lives only while bad is running.

After bad returns, that memory is no longer valid.

Correct options:

Return a string literal:

fn name() []const u8 {
    return "zig";
}

Or allocate memory and return ownership:

const std = @import("std");

fn makeName(allocator: std.mem.Allocator) ![]u8 {
    return try allocator.dupe(u8, "zig");
}

Initialize Before Reading

Do not read uninitialized memory.

Bad:

const std = @import("std");

pub fn main() void {
    var x: i32 = undefined;
    std.debug.print("{}\n", .{x});
}

undefined means “this value is not initialized.”

Use it only when you will assign a valid value before reading:

var x: i32 = undefined;
x = 42;

std.debug.print("{}\n", .{x});

For beginners, avoid undefined until you understand why you need it.

Debugging Memory with Small Tests

When you suspect a memory bug, write a small test.

Instead of debugging a large program, reduce the problem.

Bad starting point:

The whole parser sometimes crashes.

Better starting point:

This function crashes when input is empty.

Write the smallest test:

test "last returns null for empty input" {
    const values = [_]i32{};
    try std.testing.expectEqual(null, last(values[0..]));
}

Small tests make memory bugs easier to isolate.

Practical Checklist

When debugging memory, ask these questions:

Who allocated this memory?

Who owns it?

Who frees it?

Can it be freed twice?

Can it be used after free?

Does the slice length match the actual data?

Can the input be empty?

Is any value read before initialization?

Most Zig memory bugs become clearer when you answer those questions.

Complete Example

const std = @import("std");

fn copyText(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
    const copy = try allocator.dupe(u8, text);
    return copy;
}

test "copyText returns an owned copy" {
    const allocator = std.testing.allocator;

    const copied = try copyText(allocator, "hello");
    defer allocator.free(copied);

    try std.testing.expectEqualStrings("hello", copied);
}

This example has clear ownership:

copyText allocates memory.

copyText returns ownership.

The test receives ownership.

The test frees the memory.

That is the core habit of memory debugging in Zig: make ownership visible, keep lifetimes small, and clean up exactly once.