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.
Stack vs Heap
Stack allocation is automatic and fast. Heap allocation is manual and flexible.
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 oldGeneralPurposeAllocator(which was renamed in Zig 0.16). 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. UnlikeDebugAllocator, 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, nofreeneeded 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.
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", .{});
}
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 {?}.
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", .{});
}
Arena Allocator
Arena allocators free all memory at once — perfect for temporary work.
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
}
Try It Yourself
const std = @import("std");
const Student = struct {
name: []const u8,
grades: std.ArrayList(f32),
fn init(name: []const u8) Student {
return Student{
.name = name,
.grades = .empty,
};
}
fn deinit(self: *Student, allocator: std.mem.Allocator) void {
self.grades.deinit(allocator);
}
fn addGrade(self: *Student, allocator: std.mem.Allocator, grade: f32) !void {
try self.grades.append(allocator, grade);
}
fn average(self: Student) f32 {
if (self.grades.items.len == 0) return 0;
var sum: f32 = 0;
for (self.grades.items) |g| sum += g;
return sum / @as(f32, @floatFromInt(self.grades.items.len));
}
};
pub fn main() !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const allocator = debug_allocator.allocator();
var alice = Student.init("Alice");
defer alice.deinit(allocator);
try alice.addGrade(allocator, 92.5);
try alice.addGrade(allocator, 88.0);
try alice.addGrade(allocator, 95.5);
std.debug.print("{s}'s grades: ", .{alice.name});
for (alice.grades.items) |g| {
std.debug.print("{d:.1} ", .{g});
}
std.debug.print("\nAverage: {d:.2}\n", .{alice.average()});
}
Key Takeaways
- Stack allocation is automatic and fast; heap allocation gives dynamic sizing
- Allocators are passed explicitly — no hidden global allocator
DebugAllocator(formerlyGeneralPurposeAllocator) is the go-to choice during development because it detects leaks;smp_allocatoris the fast pick for release buildsArenaAllocatorfrees everything at once — great for temporary workstd.ArrayListis allocator-less in 0.16: start from.emptyand pass the allocator toappend,appendSlice, anddeinit- Always pair allocations with
deferfor cleanup errdeferfrees 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 explore one of Zig's most distinctive features: compile-time code execution, which lets you generate types, unroll loops, and eliminate runtime overhead entirely.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.