Skip to lesson

learningzig.org / intermediate / 07-memory-and-allocators · lesson 11 of 25

TL;DR

Understand Zig memory management — compare stack vs heap allocation, use allocator interfaces, and avoid leaks with explicit manual memory control.

Key concepts

  • zig memory management
  • zig allocators
  • zig heap allocation
  • zig stack vs heap

Memory and Allocators

Zig gives you full control over memory allocation through its allocator system. Instead of hiding memory management behind a garbage collector, Zig makes every allocation explicit and configurable.

What You'll Learn

By the end of this lesson you will build a Series type that collects numbers on the heap through an allocator you pass it, releases that memory when it is done, and then asks the allocator whether every byte actually came back. That last step is the one that matters: a program can print every correct number and still be leaking, and nothing will tell you unless you ask.

This is the milestone the capstone's Record Analyzer calls its aggregation engine. The analyzer keeps a running summary per category — a count, a total, a min, a max — in allocator-backed storage it has to hand back when the report is printed, and your Series is one of those groups in miniature. You arrive here able to move a value through a pointer and read a slice as a pointer-plus-length pair (Pointers and References, Slices and Iteration); what is new is that the memory now outlives the scope that made it, so someone has to decide when it dies.

Stack vs Heap

Stack allocation is automatic and fast. Heap allocation is manual and flexible.

zig
const std = @import("std");

pub fn main() !void {
    // Stack allocation — automatic, fast, fixed size
    var stack_array: [5]i32 = .{ 1, 2, 3, 4, 5 };
    stack_array[0] = 10;

    std.debug.print("Stack array: ", .{});
    for (stack_array) |num| {
        std.debug.print("{} ", .{num});
    }
    std.debug.print("\n", .{});

    // Stack variables are cleaned up automatically
    const x: i32 = 42;
    const y: f64 = 3.14;
    std.debug.print("Stack values: x={}, y={d:.2}\n", .{ x, y });

    // Strings on the stack (string literals are compile-time known)
    const message: []const u8 = "Hello from the stack!";
    std.debug.print("{s}\n", .{message});
}

Using Allocators

Zig's allocators provide heap memory. Every allocator implements the same std.mem.Allocator interface, so your code stays the same no matter which one you pick. The choice you make depends on your goals:

  • std.heap.DebugAllocator — a safety-checking allocator for development. It tracks every allocation, detects double-frees, and reports leaks with a stack trace. It is the successor to the old GeneralPurposeAllocator, which was rewritten and renamed to DebugAllocator in Zig 0.14.0. It is slow by design; use it while you are testing.
  • std.heap.smp_allocator — a fast, thread-safe general-purpose allocator intended for release builds. Unlike DebugAllocator, it is a ready-made value, not a type you instantiate.
  • std.heap.page_allocator — asks the OS for whole memory pages on every call. Simple but coarse; good as a backing allocator for others.
  • std.heap.FixedBufferAllocator — hands out memory from a fixed byte buffer you provide (often on the stack). No heap, no free needed per item.
  • std.heap.ArenaAllocator — wraps another allocator and frees everything at once (covered below).

For development we reach for DebugAllocator because its leak detection catches mistakes early. You instantiate it, get its allocator() interface, and check the result of deinit() for leaks.

zig
const std = @import("std");

pub fn main() !void {
    // Create a debug allocator — tracks allocations and reports leaks
    var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
    defer {
        const check = debug_allocator.deinit();
        if (check == .leak) {
            std.debug.print("Memory leak detected!\n", .{});
        }
    }
    const allocator = debug_allocator.allocator();

    // Allocate a single value on the heap
    const num = try allocator.create(i32);
    defer allocator.destroy(num);
    num.* = 42;
    std.debug.print("Heap value: {}\n", .{num.*});

    // Allocate a dynamic array (slice)
    const size: usize = 5;
    const slice = try allocator.alloc(i32, size);
    defer allocator.free(slice);

    for (slice, 0..) |*item, i| {
        item.* = @intCast(i * 10);
    }

    std.debug.print("Dynamic array: ", .{});
    for (slice) |item| {
        std.debug.print("{} ", .{item});
    }
    std.debug.print("\n", .{});
}

Reading an allocation failure

Every one of those alloc and create calls is written with try, because allocation is a call that can fail. Its error set has exactly one member — error{OutOfMemory} — so when it fails you already know what went wrong; the work is finding where. The routine is the same one you will use for every error union in Zig:

  1. Read the error name. error.OutOfMemory is the only failure alloc and create can produce, so seeing it tells you an allocator refused a request rather than that some unrelated call broke. @errorName(err) gives you that name as a printable string when you want to log it.
  2. Find which allocator refused. The name does not say. A page_allocator failing means the machine is genuinely out; a FixedBufferAllocator failing means the buffer you supplied was too small, which is a sizing bug in your program rather than a resource problem.
  3. Find the try that propagated it. The error travels up through every try on the way out, so the message surfaces wherever you finally stopped propagating — not where the allocation happened. Look for the innermost allocating call on that path.
  4. Decide handle or propagate. try says the caller must deal with it. catch lets you recover here — fall back to a smaller request, drop optional work, or report and exit.
zig
const std = @import("std");

pub fn main() !void {
    // A 64-byte buffer, so the second request cannot possibly fit.
    var buffer: [64]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&buffer);
    const allocator = fba.allocator();

    const small = allocator.alloc(u8, 32) catch |err| {
        std.debug.print("first alloc failed: {s}\n", .{@errorName(err)});
        return;
    };
    std.debug.print("first alloc ok: {} bytes\n", .{small.len});

    // Handled here rather than propagated, so the program can report and stop
    // on its own terms instead of unwinding out of main.
    const big = allocator.alloc(u8, 1000) catch |err| {
        std.debug.print("second alloc failed: {s}\n", .{@errorName(err)});
        return;
    };
    std.debug.print("second alloc ok: {} bytes\n", .{big.len});
}

That prints first alloc ok: 32 bytes and then second alloc failed: OutOfMemory. Nothing about the message names the buffer, the size, or the line — step 2 is yours to do. Here the answer is that a FixedBufferAllocator over 64 bytes was asked for 1000, which is a sizing mistake in the program and not a machine that ran out of memory.

That program stacks up three defer statements, and the order they fire in is what decides whether the cleanup is even legal. Work out the print order before reading on.

Predict

Three defer statements are registered in source order, and one of them sits inside an if that is never taken. In what order do the messages print?

zig
const std = @import("std");

pub fn main() !void {
  std.debug.print("start\n", .{});

  defer std.debug.print("first defer\n", .{});
  defer std.debug.print("second defer\n", .{});

  if (false) {
      defer std.debug.print("third defer\n", .{});
  }

  std.debug.print("end of body\n", .{});
}

Continue learning

The output is start, end of body, second defer, first defer. Defers unwind in reverse registration order — last registered, first to run — and a defer inside a branch that is never entered never registers at all. That reverse ordering is precisely what allocator code needs: in the program above, defer allocator.free(slice) was registered after the allocator's own defer, so the slice is freed before the allocator that owns it is torn down.

Now assemble that chain yourself. The lines below are the whole life of one heap allocation, shuffled. Most positions are forced by what each line needs from the line before it; two are not, and telling the two kinds apart is the point.

Arrange the code

These lines set up a debug allocator, allocate a three-element slice through it, and read one value back. The pieces are shuffled. Reorder them so the program runs clean with no leak reported — then answer the question the order is really testing: which of the two defer lines could move one position without changing what the program does, which could not, and what happens to the one that cannot?

  1. const allocator = debug_allocator.allocator();
  2. var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
  3. const values = try allocator.alloc(i32, 3);
  4. defer _ = debug_allocator.deinit();
  5. defer allocator.free(values);
  6. values[0] = 7;
Continue learning

ArrayList

ArrayList is Zig's dynamic array, similar to Vec in Rust or std::vector in C++.

In Zig 0.16 std.ArrayList does not store its allocator. You start it from the .empty value and pass the allocator to each method that might allocate (append, appendSlice, deinit). Keeping the allocator out of the list means the list itself is just data — cheap to copy and store. (An older allocator-storing form still exists as std.array_list.Managed, but it is deprecated — new code uses the allocator-less std.ArrayList.)

pop() returns an optional (?T) so it can hand back null on an empty list — we print it with the optional format specifier {?}.

zig
const std = @import("std");

pub fn main() !void {
    var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
    defer _ = debug_allocator.deinit();
    const allocator = debug_allocator.allocator();

    // Create an empty ArrayList — the allocator is passed per call
    var list: std.ArrayList(i32) = .empty;
    defer list.deinit(allocator);

    // Append elements
    try list.append(allocator, 10);
    try list.append(allocator, 20);
    try list.append(allocator, 30);
    try list.append(allocator, 40);

    std.debug.print("List items: ", .{});
    for (list.items) |item| {
        std.debug.print("{} ", .{item});
    }
    std.debug.print("\n", .{});
    std.debug.print("Length: {}, Capacity: {}\n", .{ list.items.len, list.capacity });

    // Remove last element — pop() returns an optional
    const last = list.pop();
    std.debug.print("Popped: {?}\n", .{last});

    // Append a slice
    try list.appendSlice(allocator, &.{ 50, 60, 70 });
    std.debug.print("After append: ", .{});
    for (list.items) |item| {
        std.debug.print("{} ", .{item});
    }
    std.debug.print("\n", .{});
}

The next program prints exactly the right numbers and still reports a problem. Because it asks the DebugAllocator whether anything leaked, the failure is visible instead of silent — commit a hypothesis about what leaked before you go looking.

Debug

This program builds a list, prints the right numbers, and then reports a leak. The printing is correct, so the bug is not in the loop. Commit a hypothesis about what was never released, then fix it so the numbers are followed by 'No leaks.'

zig
const std = @import("std");

pub fn main() !void {
  var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
  // Ask the allocator on the way out whether every allocation was returned.
  defer {
      if (debug_allocator.deinit() == .leak) {
          std.debug.print("Memory leak detected!\n", .{});
      } else {
          std.debug.print("No leaks.\n", .{});
      }
  }
  const allocator = debug_allocator.allocator();

  var list: std.ArrayList(i32) = .empty;

  try list.append(allocator, 10);
  try list.append(allocator, 20);
  try list.append(allocator, 30);

  std.debug.print("Items: ", .{});
  for (list.items) |item| {
      std.debug.print("{} ", .{item});
  }
  std.debug.print("\n", .{});
}

Expected output:

Items: 10 20 30 
No leaks.
Continue learning

Nothing ever released the list's backing buffer. Because std.ArrayList in 0.16 does not store its allocator, it cannot clean up after itself — so debug_allocator.deinit() finds the buffer still checked out and prints Memory leak detected! instead of No leaks.. The fix is one line right after the list is created: defer list.deinit(allocator);. It names the allocator explicitly, and because it is registered after the allocator's own defer, the reverse-order unwind runs it first — the buffer goes back before the allocator is asked whether anything is missing. That is the pairing rule this whole lesson turns on: every allocating call needs a matching release, defer is how you write the pair next to each other so you cannot forget one, and the last-in-first-out order is what makes the pair legal.

Arena Allocator

Arena allocators free all memory at once — perfect for temporary work.

zig
const std = @import("std");

pub fn main() !void {
    var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
    defer _ = debug_allocator.deinit();

    // Arena allocator — all memory freed at once
    var arena = std.heap.ArenaAllocator.init(debug_allocator.allocator());
    defer arena.deinit(); // Frees ALL arena allocations at once

    const allocator = arena.allocator();

    // Many small allocations — no need to free individually
    const a = try allocator.alloc(u8, 100);
    const b = try allocator.alloc(u8, 200);
    const c = try allocator.alloc(u8, 50);

    @memset(a, 'A');
    @memset(b, 'B');
    @memset(c, 'C');

    std.debug.print("a[0]: {c}, b[0]: {c}, c[0]: {c}\n", .{ a[0], b[0], c[0] });
    std.debug.print("Total allocated: {} bytes\n", .{ a.len + b.len + c.len });

    // Build a list of strings — the arena owns every allocation
    var names: std.ArrayList([]const u8) = .empty;
    try names.append(allocator, "Alice");
    try names.append(allocator, "Bob");
    try names.append(allocator, "Charlie");

    std.debug.print("Names: ", .{});
    for (names.items) |name| {
        std.debug.print("{s} ", .{name});
    }
    std.debug.print("\n", .{});
    // Everything freed when arena.deinit() is called
}

Notice something about allocator.free(slice) in the earlier example: you never told it how many bytes to release. Close the code and work out why it does not need to be told.

Recall

Without scrolling up: back in Slices and Iteration you learned exactly what a slice value consists of. Given that, why does allocator.free(slice) need no size argument, while C's free() has to look the size up itself?

Continue learning

alloc hands back a slice, and a slice is a pointer and a length — so the size travels with the memory. free(slice) reads slice.len off the value it was given; there is no hidden header to consult and nothing for you to remember. That is also why create/destroy exist as a separate pair for single values: one item has no length to carry, so it is a plain pointer rather than a slice.

Rust reaches the same destination — memory released exactly once, at a point the compiler can see — by a visibly different route. Before the build task, work out which part of that actually carries across.

Transfer

In Rust, a Vec frees its buffer when it goes out of scope: the compiler inserts the call to drop for you, at the closing brace, and it will not let you use the value afterwards. In Zig you write defer list.deinit(allocator) yourself, and it runs at the closing brace too. Both release at scope exit. Which statement names what the two mechanisms genuinely share, rather than a resemblance that stops at the surface?

Continue learning

Try It Yourself

Reading about allocators is not the same as pairing an allocation with its release under your own name. This is a build task: a small program that reports its own pass/fail. A Series struct collects values on the heap and summarises them, and three of its methods are stubbed out. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each method until every check passes and it prints All checks passed.

The final check is the strict one: it asks the DebugAllocator whether every byte came back. You can make every arithmetic check pass and still fail that one, which is exactly the point — correct output and correct memory management are two separate obligations in Zig, and only the second one is invisible until you ask.

Build

Finish the build. Three methods on Series are stubbed out and the checks below them fail until each one behaves. Run it as-is to see which check fails first, decide what that method is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. The leak check is last: do not skip it.

zig
const std = @import("std");

// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
  if (!ok) {
      std.debug.print("CHECK FAILED: {s}\n", .{message});
      return error.CheckFailed;
  }
}

const Series = struct {
  values: std.ArrayList(i32) = .empty,

  // TODO 1: append one value to the heap-backed list.
  //   std.ArrayList does not store its allocator, so pass it through.
  fn add(self: *Series, allocator: std.mem.Allocator, value: i32) !void {
      _ = self;
      _ = allocator;
      _ = value;
  }

  // TODO 2: release the list's backing buffer.
  //   This is the release half of every add above.
  fn deinit(self: *Series, allocator: std.mem.Allocator) void {
      _ = self;
      _ = allocator;
  }

  // TODO 3: return the mean of the values as an f64, or 0 for an empty series.
  //   Sum into an i64 first, then convert both parts with @floatFromInt.
  fn average(self: Series) f64 {
      _ = self;
      return 0;
  }
};

// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
  var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
  const allocator = debug_allocator.allocator();

  var series: Series = .{};

  try series.add(allocator, 150);
  try series.add(allocator, 200);
  try series.add(allocator, 175);
  try series.add(allocator, 225);

  try check(series.values.items.len == 4, "after four add calls the list should hold 4 items");
  try check(series.values.items[0] == 150, "the first item should be 150 — append adds to the END, in call order");
  try check(series.values.items[3] == 225, "the fourth item should be 225");

  var total: i64 = 0;
  for (series.values.items) |v| total += v;
  try check(total == 750, "the four values should sum to 750");

  try check(series.average() == 187.5, "average() should be 187.5 for 150, 200, 175, 225");

  var empty: Series = .{};
  try check(empty.average() == 0, "average() on an empty series should be 0, not a division by a zero length");

  // Read every value we still want to print BEFORE releasing the memory it
  // lives in — after deinit, series.values is no longer ours to read.
  const count = series.values.items.len;
  const mean = series.average();

  // Release before asking about leaks, so the buffer has a chance to come back.
  series.deinit(allocator);
  empty.deinit(allocator);

  try check(debug_allocator.deinit() == .ok, "the allocator still holds memory — deinit must release the list's buffer");

  std.debug.print("All checks passed.\n", .{});
  std.debug.print("Count: {}, Total: {}\n", .{ count, total });
  std.debug.print("Average: {d:.2}\n", .{mean});
}

Expected output:

All checks passed.
Count: 4, Total: 750
Average: 187.50
Continue learning

Once it passes, try two variations and predict each before running:

  1. Drop the release. Comment out the body of deinit so it releases nothing. Predict which check fails before running. Every arithmetic check still passes — the list contents and the average are unaffected — and the program gets all the way to the last check before failing with the allocator still holds memory. A leak changes nothing about your output, which is exactly why it needs a check of its own.
  2. Remove the empty guard. In average, delete the if (self.values.items.len == 0) return 0; line. Predict what happens to the empty series before running. The four-value average is still 187.5, so that check passes; then empty.average() divides 0 by a length of 0, and IEEE float division makes that nan, which is not equal to 0 — so the empty-series check fails. Note the failure is not a crash: float division by zero is defined behaviour, which is what lets an unguarded average be quietly wrong rather than loudly broken.

Capstone milestone

Milestone — the aggregation engine. The capstone's Record Analyzer groups records by category and accumulates a count, a total, a min, a max, and an average for each group, all on allocator-backed storage it has to release. The Series you just built is one group of that engine. Confirm you can own heap memory correctly.

Hint: You don't need the full engine yet — this confirms the allocate-and-release discipline it is built on. In the capstone, a Stats value per category accumulates count, total, min, and max, and the map holding them has to be released the same way this list does.

  • Threaded an allocator explicitly through every call that grows a std.ArrayList
  • Paired each allocation with a matching release, and registered the release with defer where the scope allowed it
  • Asked DebugAllocator whether anything leaked, rather than assuming it did not
  • Guarded an average against an empty collection instead of dividing by a zero length
Continue learning

Key Takeaways

  • Stack allocation is automatic and fast; heap allocation gives dynamic sizing
  • Allocators are passed explicitly — no hidden global allocator
  • DebugAllocator (formerly GeneralPurposeAllocator) is the go-to choice during development because it detects leaks; smp_allocator is the fast pick for release builds
  • ArenaAllocator frees everything at once — great for temporary work
  • std.ArrayList is allocator-less in 0.16: start from .empty and pass the allocator to append, appendSlice, and deinit
  • Always pair allocations with defer for cleanup
  • errdefer frees memory when a function returns an error

Pro Tip: Pass allocators as function parameters rather than storing them globally. This makes your code testable (you can inject a test allocator that detects leaks) and composable (callers choose the allocation strategy).

Next Steps

With memory management under your belt, you're ready to tackle one of the most common data types built on top of it: strings. In Zig, strings are just byte slices -- and working with them effectively uses exactly the slices and allocators you now know.

Ready to try it? Continue in your learning studio to practice these ideas, save your progress, and experiment with code.