Zig programs use memory in different places. The two most important places are the stack and the heap.
Zig programs use memory in different places. The two most important places are the stack and the heap.
The stack is used for local values and function calls.
The heap is used for memory requested from an allocator.
Both are ordinary memory, but they are managed differently. Understanding this difference is necessary before you write programs that allocate buffers, build dynamic arrays, return data from functions, or manage long-lived objects.
Stack Memory
Stack memory is used automatically when functions run.
Example:
pub fn main() void {
const x: i32 = 10;
const y: i32 = 20;
const z = x + y;
_ = z;
}Here, x, y, and z are local values. They live while main is running.
You do not allocate them manually.
You do not free them manually.
When the function exits, the stack space used by those local values is no longer valid.
That is the basic stack rule:
Local stack data lives only inside its scope.
A Stack Array
This array is stored as a local value:
pub fn main() void {
var buffer: [16]u8 = undefined;
buffer[0] = 1;
buffer[1] = 2;
_ = buffer;
}The array has a fixed size known at compile time:
[16]u8It stores 16 bytes.
This is simple and fast. No allocator is involved.
Stack memory is good for small, fixed-size, temporary data.
Stack Memory Has a Lifetime
A local value becomes invalid when its scope ends.
This function is wrong:
fn bad() *i32 {
var x: i32 = 10;
return &x;
}The function returns a pointer to x.
But x lives on the stack inside bad. When bad returns, x is gone. The returned pointer points to invalid memory.
This is called a dangling pointer.
The problem is not the pointer syntax. The problem is lifetime.
The pointer outlives the value it points to.
Heap Memory
Heap memory is requested explicitly from an allocator.
Example:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const buffer = try allocator.alloc(u8, 16);
defer allocator.free(buffer);
buffer[0] = 1;
buffer[1] = 2;
}This line asks for heap memory:
const buffer = try allocator.alloc(u8, 16);It returns a slice:
[]u8The slice views 16 u8 values stored in heap memory.
This line releases the memory:
defer allocator.free(buffer);Heap memory stays valid until it is freed.
That is the basic heap rule:
Allocated memory lives until you free it, or until the allocator is destroyed.
Why Heap Memory Exists
Stack memory is easy, but it has limits.
Use heap memory when:
The size is known only at runtime.
The data must live after the current function returns.
The data may grow or shrink.
The data is too large for comfortable stack use.
Many objects need to share ownership rules through a larger program.
Example:
const buffer = try allocator.alloc(u8, count);Here, count may be computed at runtime. The compiler may not know the size in advance. Heap allocation handles that.
Returning Heap Memory
A function can return heap-allocated memory, but the ownership must be clear.
const std = @import("std");
fn makeBuffer(allocator: std.mem.Allocator, len: usize) ![]u8 {
const buffer = try allocator.alloc(u8, len);
return buffer;
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const buffer = try makeBuffer(allocator, 32);
defer allocator.free(buffer);
buffer[0] = 42;
}This is valid because makeBuffer returns memory that was allocated from the heap.
But the caller must free it.
The function should be understood as saying:
I allocate memory for you. You now own it.
In Zig, that ownership rule should be documented by the function name, parameters, and surrounding code.
Returning Stack Memory Is Wrong
Compare the heap version with this wrong stack version:
fn makeBadBuffer() []u8 {
var buffer: [32]u8 = undefined;
return buffer[0..];
}This returns a slice into a local array.
The array dies when the function returns.
The returned slice points to invalid memory.
This is one of the most important mistakes to avoid in Zig.
Never return a pointer or slice to local stack data.
Caller-Provided Stack Memory
Often, the best design is to let the caller provide the memory.
fn writeHello(buffer: []u8) []u8 {
buffer[0] = 'h';
buffer[1] = 'i';
return buffer[0..2];
}Use it like this:
const std = @import("std");
fn writeHello(buffer: []u8) []u8 {
buffer[0] = 'h';
buffer[1] = 'i';
return buffer[0..2];
}
pub fn main() void {
var storage: [16]u8 = undefined;
const result = writeHello(storage[0..]);
std.debug.print("{s}\n", .{result});
}Output:
hiNo heap allocation is needed.
The caller owns the storage. The function only borrows it.
This is a common Zig style. It makes memory ownership simple and visible.
Stack vs Heap Table
| Feature | Stack | Heap |
|---|---|---|
| Managed by | Function calls and scopes | Allocators |
| Size | Usually fixed and known locally | Can be decided at runtime |
| Lifetime | Ends with scope or function | Ends when freed or allocator is destroyed |
| Speed | Very fast | Usually slower than stack |
| Manual free needed | No | Yes |
| Good for | Small temporary values | Dynamic or long-lived data |
| Common type | [N]T, local struct, local variables | []T, allocated objects, dynamic containers |
Allocation Can Fail
Stack local variables usually do not return errors when created.
Heap allocation can fail.
const buffer = try allocator.alloc(u8, 1024);The try is necessary because the allocator may not be able to provide the requested memory.
That means heap allocation affects function signatures.
If a function allocates, it often returns an error union:
fn makeBuffer(allocator: std.mem.Allocator) ![]u8 {
return try allocator.alloc(u8, 1024);
}The ![]u8 return type means:
This function either returns a []u8, or it returns an error.
Memory allocation is not invisible in Zig.
Defer Is Common with Heap Memory
When you allocate memory, you often use defer to free it.
const buffer = try allocator.alloc(u8, 1024);
defer allocator.free(buffer);defer means:
Run this cleanup when the current scope exits.
This is useful because it keeps allocation and cleanup close together.
Example:
const buffer = try allocator.alloc(u8, 1024);
defer allocator.free(buffer);
// use buffer hereEven if the function later returns early with an error, the deferred cleanup still runs.
This reduces leaks.
Ownership Transfer
Sometimes a function allocates memory and returns it. In that case, it should not free it before returning.
fn makeName(allocator: std.mem.Allocator) ![]u8 {
const name = try allocator.alloc(u8, 3);
name[0] = 'z';
name[1] = 'i';
name[2] = 'g';
return name;
}Do not write this:
fn makeNameBad(allocator: std.mem.Allocator) ![]u8 {
const name = try allocator.alloc(u8, 3);
defer allocator.free(name);
return name;
}That version returns memory and then frees it as the function exits. The caller receives a slice to memory that has already been freed.
The correct rule is:
If you return allocated memory, the caller usually becomes responsible for freeing it.
Heap Memory and Containers
Many standard library containers use heap memory.
For example, an ArrayList grows dynamically, so it needs an allocator.
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var list = std.ArrayList(u8).init(allocator);
defer list.deinit();
try list.append(10);
try list.append(20);
try list.append(30);
std.debug.print("{any}\n", .{list.items});
}The ArrayList owns heap memory internally.
This line cleans it up:
defer list.deinit();The pattern is the same:
Create something that owns memory.
Use it.
Deinitialize it.
Large Stack Values
The stack is fast, but not unlimited.
This may be a bad idea:
pub fn main() void {
var huge: [100_000_000]u8 = undefined;
_ = huge;
}That asks for a very large local array.
Large buffers usually belong on the heap, or inside a long-lived allocator strategy.
const huge = try allocator.alloc(u8, 100_000_000);
defer allocator.free(huge);Use stack memory for small, local data. Use heap memory for large or dynamic data.
The Cost of Heap Allocation
Heap allocation is flexible, but it has costs.
It can fail.
It is usually slower than stack allocation.
It requires cleanup.
It can fragment memory.
It makes ownership more complicated.
This does not mean heap allocation is bad. It means heap allocation should be intentional.
A good Zig program does not allocate casually. It chooses where memory lives.
Common Mistake: Allocating When a Stack Buffer Is Enough
This is unnecessary:
const buffer = try allocator.alloc(u8, 32);
defer allocator.free(buffer);if the buffer is fixed, small, and local.
Use:
var buffer: [32]u8 = undefined;Then pass it as a slice if needed:
useBuffer(buffer[0..]);This avoids allocation entirely.
Common Mistake: Forgetting to Free
This leaks memory:
const buffer = try allocator.alloc(u8, 1024);
buffer[0] = 1;The program allocated memory and never released it.
Correct:
const buffer = try allocator.alloc(u8, 1024);
defer allocator.free(buffer);
buffer[0] = 1;When a function owns allocated memory, it needs a cleanup path.
Common Mistake: Freeing Too Early
This is also wrong:
const buffer = try allocator.alloc(u8, 1024);
allocator.free(buffer);
buffer[0] = 1;After free, the memory is no longer yours.
Using it after freeing is a use-after-free bug.
The rule is direct:
After memory is freed, no pointer or slice to it should be used.
Main Idea
Stack memory is automatic and tied to scope.
Heap memory is explicit and tied to an allocator.
Use the stack for small, fixed-size, temporary values.
Use the heap for dynamic, large, or longer-lived data.
Do not return pointers or slices to local stack data.
When you allocate heap memory, make ownership clear and free it exactly once.