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.

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

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

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

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

This is incredible for catching accidental allocations.


7. Stack vs Heap (Zig-style)

var buf: [256]u8 = undefined; // stack

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

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)

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();
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);

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

  1. Use defer immediately after allocation

  2. Use arenas for tree-shaped data (ASTs, DOMs)

  3. Make ownership obvious in function names

  4. Attach deinit() to owning structs

  5. 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);

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());

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

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?

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

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)

  1. If you return allocated memory → caller frees (fn foo(alloc) ![]T)

  2. If you allocate only for temporary work → free inside the function (defer alloc.free(tmp))

  3. If you allocate a whole graph of objects → use an arena (arena.deinit() frees everything)