zig-allocator-malloc

Allocators in Zig: Dynamic memory without a global malloc

  • 4 min

An Allocator in Zig is an interface that decides how to request, resize, and free dynamic memory. If you come from C, C++, or Rust, you’re probably used to having a default global allocator available.

In Zig there is no default allocator by convention. If you link libc you can use std.heap.c_allocator, but reusable APIs typically receive an Allocator chosen by the caller.

Zig starts from a premise: memory allocation is a dependency that should be made visible.

If a function needs to allocate dynamic memory, it must ask for permission. And the way to ask for permission is by receiving a parameter of type Allocator.

The problem with the global allocator

In almost all languages, there is an invisible “Global Heap”.

  • In C: malloc uses the default allocator from libc.
  • In Python/JS: The Garbage Collector requests memory from the system when it wants.
  • In Rust: Box::new uses the Global Allocator (although Rust is gradually allowing changes).

This has serious problems for systems programming:

  1. Hidden cost: the signature does not indicate whether an operation can allocate, reallocate, or fail due to lack of memory. A malloc does not necessarily make a system call every time, but it is not free either.
  2. Lack of control: What if you want a library to use a specific memory region (e.g., fast SRAM on a chip)? If the library uses malloc internally, you cannot change it.
  3. Less clear ownership: if everything uses the same allocator, it’s harder to determine who should free each result.

The explicit allocator

In Zig, an Allocator is an interface that allows:

  1. Giving you a piece of memory (alloc).
  2. Resizing that piece (resize).
  3. Freeing that piece (free).

If you write a function that needs memory, your signature changes like this:

// In C (hidden malloc)
// char* duplicate(const char* input);

// In Zig (explicit Allocator)
fn duplicate(allocator: Allocator, input: []const u8) ![]u8 {
    // Use the allocator that was PASSED to us to request memory
    const memory = try allocator.alloc(u8, input.len);
    
    @memcpy(memory, input);
    return memory;
}
Copied!

What this design provides

At first it seems verbose (you have to pass the parameter everywhere), but the advantages are immense:

  1. You see which APIs can allocate memory: an allocator parameter warns of that possibility and the ! reflects that allocation can fail.
  2. You choose the strategy: you can provide a general-purpose allocator, one based on a fixed buffer, or one with a very short lifecycle. The function only uses the interface it receives.
  3. Facilitates portability: the same algorithm can work with different memory sources, as long as the rest of its dependencies are also compatible with the target.

This is a design convention, not a compiler restriction. A function without an allocator parameter could still use page_allocator, c_allocator or another global source internally; its documentation should indicate this.

Basic operations: create, alloc, destroy, free

The std.mem.Allocator interface has standard methods that we will use constantly. It is important to distinguish between creating an object and creating an array.

A single object (create and destroy)

We use create when we want a pointer to a single instance (*T). Equivalent to new in C++.

const allocator = ...; // Assume we already have one

// Allocate memory for an i32
const ptr = try allocator.create(i32);
ptr.* = 42;

// Free memory
allocator.destroy(ptr);
Copied!

Multiple elements (alloc and free)

We use alloc when we want a Slice ([]T). Equivalent to malloc(n * sizeof(T)) in C.

// Allocate space for 100 bytes
const buffer = try allocator.alloc(u8, 100);
defer allocator.free(buffer); // Important: use free for slices

// Use the buffer
buffer[0] = 0xFF;
Copied!

Practical tip:

  • If you used create, free with destroy.
  • If you used alloc, free with free.

Complete example

Let’s see what a real program that concatenates two strings looks like. Notice how the allocator travels from main to the logic.

const std = @import("std");
const Allocator = std.mem.Allocator;

// This function does NOT know where the memory comes from.
// It only knows it needs an Allocator to work.
fn joinText(allocator: Allocator, a: []const u8, b: []const u8) ![]u8 {
    const total_len = a.len + b.len;
    
    // 1. Request memory
    const result = try allocator.alloc(u8, total_len);
    // Note: We do NOT put defer free here, because we want to return the result
    // If we fail to copy, we would use errdefer.

    // 2. Copy data
    @memcpy(result[0..a.len], a);
    @memcpy(result[a.len..], b);
    
    return result;
}

pub fn main() !void {
    // 1. Choose our memory strategy (we'll see this in the next article)
    var debug_allocator = std.heap.DebugAllocator(.{}){};
    defer _ = debug_allocator.deinit();
    const allocator = debug_allocator.allocator();

    // 2. Call the function injecting the dependency
    const greeting = try joinText(allocator, "Hello ", "Zig");
    
    // 3. We own the returned memory, we must free it
    defer allocator.free(greeting);
    
    std.debug.print("{s}\n", .{greeting});
}
Copied!