Zig Memory Management: Allocators, Ownership, and Freeing Memory
0. The Core Zig Idea (this is the mental unlock)
Zig has no hidden memory allocation. If memory is allocated, you explicitly pass an allocator.
No GC
No implicit new
No "magic" ownership rules
Instead:
fn foo(allocator: std.mem.Allocator) !void {
const buf = try allocator.alloc(u8, 1024);
defer allocator.free(buf);
}
If a function needs memory → it must ask for an allocator. If it doesn't ask → it cannot allocate.
This is why Zig code is: - predictable - debuggable - fast - honest
1. What is an Allocator in Zig?
In Zig, an allocator is just an interface (struct of function pointers):
pub const Allocator = struct {
allocFn: fn (...)
resizeFn: fn (...)
freeFn: fn (...)
};
You don't care about the internals most of the time.
You just pass it around:
fn parse(allocator: std.mem.Allocator, input: []const u8) !AST
Think of allocators as memory strategies, not memory itself.
2. The Big Picture: Types of Allocators
Here's the high-level map:
| Allocator | What it's good at | |-----------|-------------------| | page_allocator | OS-backed, low-level | | GeneralPurposeAllocator | Default heap | | ArenaAllocator | Fast bulk allocation | | FixedBufferAllocator | No heap, no surprises | | Stack-like allocators | Predictable lifetimes | | Custom allocators | Games, engines, kernels |
3. std.heap.page_allocator – Raw OS Pages
What it is- Talks directly to the OS (mmap, VirtualAlloc) - Allocates whole pages (usually 4KB+) - No caching, no reuse
Example
const allocator = std.heap.page_allocator;
const buf = try allocator.alloc(u8, 4096);
defer allocator.free(buf);
When to use it
✅ Very low-level tooling
✅ Bootstrapping another allocator
❌ High-frequency allocations
❌ Small objects
Think: "I need raw memory, once in a while."
4. GeneralPurposeAllocator (GPA) – The Default Heap
What it is- Zig's main malloc/free replacement - Debug features: double-free detection, leaks - Configurable (thread-safe, debug mode, etc.)
Setup (important)
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const leaked = gpa.deinit();
if (leaked) std.debug.print("Memory leaked!\n", .{});
}
const allocator = gpa.allocator();
Example use
const list = try std.ArrayList(u8).initCapacity(allocator, 16);
When to use it
✅ Most applications
✅ CLIs, servers, parsers
✅ When unsure → start here
This is your "default heap".
5. ArenaAllocator – Allocate Fast, Free Once
This one is huge for parsers and compilers.
What it is- Allocates memory in chunks - Never frees individual allocations - Frees everything at once
Setup
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const arena_alloc = arena.allocator();
Example
const node = try arena_alloc.create(Node);
const name = try arena_alloc.dupe(u8, "hello");
No free. Ever.
When to use it
✅ ASTs
✅ Token trees
✅ Temporary data per request
✅ Markdown parser
Mental model
"I'll throw everything away together."
This maps perfectly to: - parse → build tree → generate output → discard
6. FixedBufferAllocator – No Heap, No Excuses
What it is- Allocates from a pre-allocated buffer - No OS calls - Hard memory limit
Setup
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();
Example
const slice = try allocator.alloc(u8, 100);
If you exceed 1024 bytes → error.
When to use it
✅ Embedded
✅ WASM
✅ Tests
✅ Deterministic tools
This is incredible for catching accidental allocations.
7. Stack vs Heap (Zig-style)
var buf: [256]u8 = undefined; // stack
Fast
Fixed size
Lifetime = scope
Use stack when:- Size is known - Lifetime is local - You don't need dynamic resizing
Use heap when:- Size is dynamic - Data escapes the scope
8. Common Allocator Patterns (Very Important)
Pattern 1: Pass allocator down
fn tokenize(allocator: std.mem.Allocator, input: []const u8) ![]Token
✔️ Preferred
✔️ Testable
✔️ Composable
Pattern 2: One arena per operation
fn parseFile(allocator: std.mem.Allocator) !void {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
// everything uses `a`
}
This is idiomatic Zig.
Pattern 3: Containers don't own allocators
var list = std.ArrayList(u8).init(allocator);
defer list.deinit();
The caller owns the allocator, not the container.
9. Allocator Choice Cheat Sheet
| Situation | Use | |-----------|-----| | CLI tool | GPA | | Parser / AST | Arena | | Short-lived temp memory | Arena | | Embedded / WASM | FixedBuffer | | Tests | FixedBuffer | | Kernel / engine | Custom allocator |
10. Example: Markdown Tokenizer (Mini)
fn tokenize(
allocator: std.mem.Allocator,
input: []const u8,
) ![]Token {
var tokens = std.ArrayList(Token).init(allocator);
defer tokens.deinit();
// push tokens...
return tokens.toOwnedSlice();
}
If caller uses: - GPA → caller must free - Arena → caller frees everything at once
Zero code changes. Different memory behavior.
11. Zig Philosophy (Worth Internalizing)
Memory is not a side effect in Zig. It is a parameter.
Once you start thinking this way: - APIs become cleaner - Bugs become obvious - Performance becomes intentional
Part 2: Ownership Deep Dive
1. Zig's Core Memory Principle
Zig has no hidden memory allocation.
If memory is allocated: - an allocator is passed explicitly - ownership is explicit - freeing is intentional
Memory is not a side effect — it is an API parameter.
2. What an Allocator Is
An allocator in Zig is a value that represents a memory allocation strategy. It provides functions for allocating, resizing, and freeing memory.
Allocators do not imply ownership rules — they only define how memory is obtained and released.
3. Why ArenaAllocator Takes Another Allocator
An ArenaAllocator does not own memory. It manages lifetimes, not memory sources.
When initialized:
var arena = std.heap.ArenaAllocator.init(backing_allocator);
This means: - the arena requests large chunks from the backing allocator - it sub-allocates from those chunks - it frees everything at once during deinit
The arena never talks to the OS directly.
Why this design exists- Allocators are composable - Arena behavior can be reused in different environments - Memory sources can be swapped (heap, fixed buffer, WASM, tests)
This enables allocator stacking:
FixedBufferAllocator → ArenaAllocator → Application Data
or:
GeneralPurposeAllocator → ArenaAllocator → AST
4. How an Arena Works (Conceptually)
Arena keeps a list of memory chunks
Each allocation bumps a pointer
No per-allocation free
arena.deinit() frees all chunks via the backing allocator
This removes: - fragmentation - double-free bugs - per-node deallocation logic
5. Freeing Memory: The Golden Rule
The code that allocates memory is responsible for freeing it — unless ownership is explicitly transferred.
Zig does not infer ownership. Zig does not auto-free. Ownership is an API contract.
6. Double Free and Safety
Double-free is a serious bug.
Behavior depends on allocator:
| Allocator | Double Free | |-----------|-------------| | GeneralPurposeAllocator (debug) | Panic | | GeneralPurposeAllocator (release) | Undefined behavior | | page_allocator | Undefined behavior | | ArenaAllocator | Impossible | | FixedBufferAllocator | Error or panic |
Arena allocators eliminate double-free by design.
7. Ownership Rules by Scenario
7.1 Function allocates and returns memory
Caller frees
fn readData(allocator: std.mem.Allocator) ![]u8 {
return try allocator.alloc(u8, 1024);
}
Caller:
const data = try readData(allocator);
defer allocator.free(data);
Returning allocated memory transfers ownership.
7.2 Function allocates temporary memory
Function frees
fn process(allocator: std.mem.Allocator) !void {
const tmp = try allocator.alloc(u8, 256);
defer allocator.free(tmp);
}
Caller never touches this memory.
7.3 Function accepts allocator but does not allocate
Nothing to free
Accepting an allocator does not imply allocation happened.
8. Containers and Ownership
ArrayList
var list = std.ArrayList(u8).init(allocator);
defer list.deinit();
init allocates
deinit frees
toOwnedSlice() transfers ownership
const slice = try list.toOwnedSlice();
defer allocator.free(slice);
Ownership transfer is explicit.
9. ArenaAllocator and Ownership
With an arena:
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const buf = try arena.allocator().alloc(u8, 128);
No free
No ownership tracking per allocation
Lifetime = arena scope
Arena ownership rule:
Everything allocated from an arena lives until arena.deinit()
10. Common Ownership Patterns
Pattern 1: Caller owns returned memory
fn tokenize(allocator: std.mem.Allocator) ![]Token
Caller frees.
Pattern 2: Caller provides buffer
fn writeInto(buf: []u8) usize
No allocation, no freeing.
Pattern 3: Struct owns memory
const Parser = struct {
allocator: std.mem.Allocator,
tokens: []Token,
pub fn deinit(self: *Parser) void {
self.allocator.free(self.tokens);
}
};
Ownership is tied to the type.
11. Zig Does Not Enforce Ownership
Zig intentionally avoids: - garbage collection - reference counting - borrow checkers
Instead it provides: - explicit allocation - debug allocators - deterministic behavior
Correctness comes from API design and conventions.
12. Best Practices to Avoid Bugs
Use defer immediately after allocation
Use arenas for tree-shaped data (ASTs, DOMs)
Make ownership obvious in function names
Attach deinit() to owning structs
Use fixed buffers in tests to catch accidental allocations
13. Idiomatic Parser Memory Strategy
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
const ast = try parseMarkdown(arena.allocator(), input);
const html = try renderHtml(arena.allocator(), ast);
One arena per operation
No individual frees
Clean lifetimes
Zero leaks
14. Core Principle to Remember
Allocators define where memory comes from. Ownership defines who frees it. Arenas define when memory dies.
Part 3: Why Arena Takes a Backing Allocator
This is an excellent question — it means you're thinking at the systems-design level now, not just "how do I use Zig".
Short answer:
An ArenaAllocator doesn't own memory. It borrows memory from another allocator.
Let's unpack that carefully, because this explains half of Zig's allocator philosophy.
1. The Key Insight (TL;DR)
var arena = std.heap.ArenaAllocator.init(allocator);
This means:
"When the arena needs more memory, it will ask this allocator for a big chunk."
So the arena: - does not talk to the OS - does not manage pages itself - does not know where memory really comes from
It only knows how to: - carve memory out of chunks - hand out pointers - free everything at once
2. Mental Model: Arena = Manager, Not Owner
Think of this stack:
Your code
↓
ArenaAllocator ← fast, simple, dumb
↓
GeneralPurposeAllocator (or page allocator)
↓
Operating System
The arena is just a middleman.
It's like saying:
"Hey, allocator, give me a 64KB slab. I'll handle the rest."
3. What Problem Is This Solving?
Why not let Arena talk to the OS directly?
Because Zig wants: - composability - testability - control
If ArenaAllocator hardcoded OS calls: - You couldn't use it in WASM - You couldn't limit memory - You couldn't swap allocators - You couldn't test allocations deterministically
Instead, Zig says:
Every allocator is built on top of another allocator.
This creates a layered allocator system.
4. How It Works Internally (Conceptual)
Let's simplify the internals:
const Chunk = struct {
next: ?*Chunk,
memory: []u8,
used: usize,
};
When you do:
const ptr = arena_alloc.alloc(u8, 32);
Step-by-step:
1. Arena checks current chunk:
- enough space? → carve slice
2. If not enough space:
- calls backing allocator: backing.alloc(u8, new_chunk_size)3. Stores chunk in linked list
4. Returns pointer
When you call:
arena.deinit();
Arena: - walks the chunk list - calls backing.free(chunk.memory) - done
No per-allocation bookkeeping. No fragmentation. No individual frees.
5. Why This Design Is Powerful
Allocators become policies
Arena is not memory — it's a policy:
"Allocate fast. Free everything together."
The backing allocator defines: - where memory comes from - how expensive allocation is - whether it's thread-safe - whether it's bounded
You can stack allocators
Yes, this is real and common:
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
Or:
var fba = std.heap.FixedBufferAllocator.init(&buffer);
var arena = std.heap.ArenaAllocator.init(fba.allocator());
Now you have: - arena behavior - bounded memory - zero OS calls
This is incredibly useful.
6. Concrete Example: Why This Matters
Example A: Production build
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
Dynamic memory
Debug checks
Leak detection
Example B: Test mode
var buffer: [64 * 1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
var arena = std.heap.ArenaAllocator.init(fba.allocator());
Same code. Same arena usage. Now: - hard memory cap - deterministic failure - no heap
This is exactly how Zig encourages testing.
7. Why Arena Can't Be Standalone
An arena: - doesn't know how to get memory - doesn't know OS APIs - doesn't know platform constraints
If it did, you'd get: - duplicated logic - platform-specific code - untestable allocators - less flexibility
Zig's design avoids this by making allocators composable.
8. Zig's Allocator Philosophy (Core Rule)
Allocators are values. Memory strategies are pluggable. Ownership flows downward.
That's why: - arena takes allocator - containers take allocator - functions take allocator
Nothing "reaches out" for memory implicitly.
9. Visual Summary
[ OS / System ]
↑
[ page_allocator ]
↑
[ GeneralPurposeAllocator ]
↑
[ ArenaAllocator ]
↑
[ Your AST / tokens / strings ]
You can replace any layer.
10. Tie-in to Your Work (Very Relevant)
For your Markdown → HTML parser:
fn parseMarkdown(
allocator: std.mem.Allocator,
input: []const u8,
) !HtmlAst
Caller decides: - arena per file - arena per request - fixed buffer for tests - GPA for CLI
Your parser stays pure and reusable.
Part 4: Memory Ownership Q&A
This is the question that separates "I can write Zig" from "I understand Zig." You're asking exactly the right thing.
I'll be very explicit and a little repetitive on purpose — memory rules should feel boringly clear.
1. The Golden Rule (Memorize This)
The code that allocates memory is responsible for freeing it — unless ownership is explicitly transferred.
Zig does not guess. Zig does not auto-free. Zig does not enforce ownership magically.
Ownership is a human contract, expressed in API shape.
2. Double Free: Is It a Problem?
Short answer
✅ Yes, double-free is a serious bug
✅ Zig can detect it (depending on allocator)
❌ Zig does not prevent it at compile time
Example (bug)
const buf = try allocator.alloc(u8, 128);
allocator.free(buf);
allocator.free(buf); // ❌ undefined behavior
What happens?- With GeneralPurposeAllocator in debug → panic - With page_allocator → undefined behavior - With arena → doesn't exist (you don't free individually)
So: - Zig lets you shoot your foot - But gives you excellent debugging ammo
3. Who Frees Memory? Function vs Caller
This is the core confusion point, so let's split cases clearly.
Case 1: Function allocates and RETURNS memory
👉 Caller must free
Example
fn readFile(
allocator: std.mem.Allocator,
) ![]u8 {
const buf = try allocator.alloc(u8, 1024);
return buf;
}
Usage:
const data = try readFile(allocator);
defer allocator.free(data);
Why?
Because the function: - can't know how long caller needs the memory - cannot free it safely
📌 Returning memory transfers ownership to the caller
Case 2: Function allocates TEMPORARY memory
👉 Function frees it
Example
fn process(allocator: std.mem.Allocator) !void {
const tmp = try allocator.alloc(u8, 256);
defer allocator.free(tmp);
// use tmp
}
Caller never sees tmp, so caller never frees it.
Case 3: Function takes allocator but does NOT allocate
👉 Nothing to free
Just because a function accepts an allocator doesn't mean it uses it.
fn print(input: []const u8, allocator: std.mem.Allocator) void {
_ = allocator;
std.debug.print("{s}\n", .{input});
}
Allocator presence ≠ allocation happened.
4. Containers Make Ownership Explicit
Example: ArrayList
var list = std.ArrayList(u8).init(allocator);
defer list.deinit();
Rules:- init → allocates internal buffer - deinit → frees it - toOwnedSlice() → transfers ownership
Important example
const slice = try list.toOwnedSlice();
// list is now empty
// YOU must free slice
defer allocator.free(slice);
Zig APIs are very intentional about this.
5. ArenaAllocator Changes the Rules (But Not Ownership)
With an arena:
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const buf = try a.alloc(u8, 128);
Who frees buf?
❌ You don't
❌ The function doesn't
✅ arena.deinit() frees everything
Why this is safe- Individual frees are impossible - Double-free cannot happen - Lifetime is scope-based
Arena turns memory ownership into:
"All allocations live as long as the arena."
6. Double-Free and Use-After-Free Matrix
| Allocator | Double free | Use-after-free | |-----------|-------------|----------------| | GPA (debug) | Panic | Panic | | GPA (release) | UB | UB | | page_allocator | UB | UB | | arena | Impossible | Impossible (if scoped correctly) | | fixed buffer | Panic or error | UB |
This is why arenas are beloved in parsers.
7. Common Zig Ownership Patterns (VERY IMPORTANT)
Pattern 1: Caller owns returned memory
fn tokenize(allocator: std.mem.Allocator) ![]Token
✔ Caller frees
Pattern 2: Function fills caller-provided buffer
fn readInto(buf: []u8) usize
✔ No allocation
✔ No freeing
Pattern 3: Struct owns memory → struct has deinit
const Parser = struct {
tokens: []Token,
allocator: std.mem.Allocator,
pub fn deinit(self: *Parser) void {
self.allocator.free(self.tokens);
}
};
Ownership is attached to the type.
8. Zig Does NOT Enforce Ownership (On Purpose)
Zig chooses: - simplicity - explicitness - debuggability
Instead of: - borrow checker - GC - reference counting
This means: - You must design APIs carefully - But the behavior is always obvious
9. How to Avoid Double Free in Practice
1. Use defer immediately
const buf = try allocator.alloc(u8, 128);
defer allocator.free(buf);
2. Use arenas for tree-shaped data
ASTs, DOMs, IRs → arena
3. Name functions honestly
toOwnedSlice()
clone()
allocXYZ()
These signal ownership transfer.
10. One Rule to Tattoo on Your Brain
If a function returns allocated memory, the caller frees it. If a function only borrows memory, it never frees it.
Everything else is just a variation.
11. Tie-in to Your Parser Project
Your Markdown pipeline should look like:
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
const ast = try parseMarkdown(arena.allocator(), input);
const html = try renderHtml(arena.allocator(), ast);
No frees. No leaks. No double-free bugs. Clean lifetimes.
Part 5: Complete Code Examples
1. Allocator Setup Patterns
GeneralPurposeAllocator (default heap + leak detection)
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const leaked = gpa.deinit();
if (leaked) std.debug.print("Memory leaked!\n", .{});
}
const allocator = gpa.allocator();
const buf = try allocator.alloc(u8, 32);
defer allocator.free(buf);
@memset(buf, 'A');
std.debug.print("buf[0]={c}\n", .{buf[0]});
}
page_allocator (OS-backed, low-level)
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
const buf = try allocator.alloc(u8, 4096);
defer allocator.free(buf);
std.debug.print("allocated {d} bytes\n", .{buf.len});
}
FixedBufferAllocator (bounded, deterministic, great for tests/WASM)
const std = @import("std");
pub fn main() !void {
var backing: [256]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&backing);
const allocator = fba.allocator();
const a = try allocator.alloc(u8, 128);
const b = try allocator.alloc(u8, 64);
std.debug.print("a={d}, b={d}\n", .{a.len, b.len});
// This would fail (out of memory) if you try to exceed 256 bytes total.
}
ArenaAllocator (fast bulk allocation; free once)
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
const a = arena.allocator();
const s1 = try a.dupe(u8, "hello");
const s2 = try a.dupe(u8, "world");
std.debug.print("{s} {s}\n", .{ s1, s2 });
// No free(s1), free(s2). arena.deinit() frees everything.
}
2. Why Arena Takes a Backing Allocator (Demonstration)
You can back an arena with different strategies:
Arena backed by GPA (production-ish)
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
Arena backed by FixedBufferAllocator (bounded arena)
var backing: [64 * 1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&backing);
var arena = std.heap.ArenaAllocator.init(fba.allocator());
defer arena.deinit();
// All arena allocations must fit in 64KiB, or you'll get an error.
This is the point: arena manages lifetimes, the backing allocator provides where memory comes from.
3. Ownership and Freeing: Concrete API Examples
3.1 Function returns allocated memory → caller frees
const std = @import("std");
fn makeBuffer(allocator: std.mem.Allocator, n: usize) ![]u8 {
const buf = try allocator.alloc(u8, n);
@memset(buf, 0);
return buf; // Ownership transferred to caller
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const buf = try makeBuffer(allocator, 128);
defer allocator.free(buf);
std.debug.print("buf len={d}\n", .{buf.len});
}
3.2 Function uses temporary allocation → function frees internally
const std = @import("std");
fn hashSomething(allocator: std.mem.Allocator, input: []const u8) !u64 {
const tmp = try allocator.alloc(u8, input.len);
defer allocator.free(tmp);
@memcpy(tmp, input);
// pretend we do some work
var h: u64 = 1469598103934665603;
for (tmp) |c| h = (h ^ c) * 1099511628211;
return h;
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const h = try hashSomething(allocator, "abc");
std.debug.print("hash={d}\n", .{h});
}
3.3 Borrowed memory in, no allocation → no freeing
const std = @import("std");
fn countLines(input: []const u8) usize {
var n: usize = 0;
for (input) |c| if (c == '\n') n += 1;
return n;
}
pub fn main() !void {
const text = "a\nb\nc\n";
std.debug.print("lines={d}\n", .{countLines(text)});
}
4. Container Ownership Examples
4.1 ArrayList owns its internal buffer until deinit()
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.appendSlice("hello");
try list.append(' ');
try list.appendSlice("zig");
std.debug.print("{s}\n", .{list.items});
}
4.2 toOwnedSlice() transfers ownership to caller
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(); // safe: list will be empty after toOwnedSlice
try list.appendSlice("owned!");
const owned = try list.toOwnedSlice(); // ownership transfer
defer allocator.free(owned);
std.debug.print("{s}\n", .{owned});
std.debug.print("list len after={d}\n", .{list.items.len});
}
5. A Small "Parser-ish" Example with Arena (AST-style)
This shows the classic: allocate a bunch of nodes, free once.
const std = @import("std");
const Node = struct {
text: []const u8,
next: ?*Node = null,
};
fn buildList(arena_alloc: std.mem.Allocator) !*Node {
const a = try arena_alloc.create(Node);
a.* = .{ .text = try arena_alloc.dupe(u8, "first") };
const b = try arena_alloc.create(Node);
b.* = .{ .text = try arena_alloc.dupe(u8, "second") };
a.next = b;
return a;
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
const head = try buildList(arena.allocator());
var it: ?*Node = head;
while (it) |n| : (it = n.next) {
std.debug.print("{s}\n", .{n.text});
}
// No frees. arena.deinit() cleans all nodes + strings.
}
6. Double Free Demonstration (Do NOT Do This)
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const buf = try allocator.alloc(u8, 16);
allocator.free(buf);
allocator.free(buf); // ❌ undefined behavior; debug GPA will usually panic
}
7. The Three Practical Rules (with code intent)
If you return allocated memory → caller frees (
fn foo(alloc) ![]T)If you allocate only for temporary work → free inside the function (
defer alloc.free(tmp))If you allocate a whole graph of objects → use an arena (
arena.deinit()frees everything)