Odin · context & allocators

The implicit allocator, four levels deep

Every Odin proc receives a hidden struct named context, threaded in for you by the compiler. One of its fields, context.allocator, is the default pool that new, make, append, and delete reach for — with no allocator argument, no parameter passed. Press the buttons and watch a real allocation counter on context.allocator tick.

Allocation counts are measured from a compiled Odin program (a tracking allocator wrapping context.allocator).
context
the hidden struct every proc gets
allocator→ tracked pool
temp_allocator→ scratch arena
logger→ default
random_generator→ default
the pool, watching every request
bytes held: 0
allocations: 0

Sizes (size_of)

context       = 112
mem.Allocator = 16

An Allocator is two pointers — a procedure and its data. context.allocator and context.temp_allocator are two different Allocators (their procedure pointers differ): two separate pools, one struct.

What you're seeing

Level 1 was what it is. Level 2 is why it's special: assign context.allocator = X for a scope and every allocation below it inherits X — down through procs you call, including ones you didn't write — without threading a single parameter. The override flows down the call tree like a value down a stack.

Here is a call tree under an override. context.allocator is set to arena A at the top. Each node that allocates with new/make and no argument lands in A — even make_buffer, which takes no allocator parameter at all. The one node that passes an explicit allocator argument escapes to arena B. Toggle that one call:

arena A — implicit calls2
arena B — explicit-arg calls1

The mechanism: when you write context.allocator = X inside a block, you mutate a local copy of the context struct. Every proc you call from inside the block is handed that copy, so it sees X. The instant the block's } closes, the local copy dies and the original context.allocator is back — no defer, no save/restore. You measured this: an allocation before the block and one after both land in the outer pool; the two inside land in the inner one.

The counterfactual: if a proc allocates with an explicit argument — new(int, my_alloc) — that argument wins and the override is ignored (that's arena B above). So the rule is precise: the context allocator is the default for calls that don't name one. Name one, and you've opted out of the cascade for that call.

Why this is the whole point

The payoff isn't your own code — it's code you can't change. A formatting routine, a parser, a logging call buried three libraries deep all allocate through context.allocator because that's what new/make read. Wrap a call to any of them in a scope that overrides the allocator, and their allocations now land in your pool — a per-frame arena, a leak tracker, a fixed buffer — without forking them. That redirection-of-someone-else's-allocation is what the context system exists to do.

Level 2 sold you on the cascade. Level 3 is the bill, and it comes in two parts: the temp allocator never cleans itself, and the very implicitness that makes the override magic can hide where memory comes from in your own APIs.

The bill, part one: context.temp_allocator is scratch space for short-lived allocations — but it does not auto-free. It grows until you wipe it with free_all(context.temp_allocator). That wipe is cheap: it resets the arena's cursor to zero in one step rather than walking and freeing each block — so cheap that the next allocation reuses the very same address:

a := make([]u8, 32, context.temp_allocator)
addr1 := raw_data(a)
free_all(context.temp_allocator)   // O(1) cursor reset
b := make([]u8, 32, context.temp_allocator)
addr2 := raw_data(b)
// same address reused after free_all: true

Run it once at program exit and nothing breaks — the OS reclaims everything. Run it in a loop that never wipes and the temp arena climbs without bound: the canonical leak. The discipline is one line per cycle — free_all(context.temp_allocator) at the end of every frame, every request, no exceptions.

The bill, part two: because allocation is implicit, a proc can read context.allocator silently and its signature gives no hint that it allocates at all. Both procs below produce identical output under an override — but only one tells the truth in its type:

hides the allocation

bad_double :: proc(in: []u8) -> []u8 {
  return make([]u8, len(in)*2)
  // hidden context.allocator read
}
signature says nothing — a reader can't tell it allocates, or from where. They must read the body and the call site's context.

advertises it

good_double :: proc(in: []u8,
    allocator := context.allocator) -> []u8 {
  return make([]u8, len(in)*2, allocator)
}
the parameter is in the type. Callers who don't care omit it and inherit the context; callers who care pass their own. Allocation is visible.

The fix: when you write a proc that allocates, take allocator := context.allocator as a parameter. The default keeps the ergonomics — most callers write nothing and inherit the cascade — and the parameter restores the honesty: the allocation shows up in the signature. The context override is the escape hatch for code you can't change; your own APIs should still say where memory comes from.

the temp allocator is not magic cleanup A common first read is "temp means it frees itself." It doesn't. context.temp_allocator is just a second Allocator sitting on the context — a (procedure, data) pair like any other. Nothing alive between two free_all calls is automatically released; you choose the wipe point, and everything allocated since the last wipe dies together at the next one. Its whole value is that the wipe is a single cursor reset, not per-allocation bookkeeping.

The last level is the property that emerges from context being compiler-threaded with a fixed layout: it lets you intercept across boundaries — and it turns "this proc has no context" from a silent landmine into a compile error.

The context struct's layout is fixed and you can't add fields to it. That constraint is the feature: because both sides of a call agree on exactly where allocator sits in the struct, your override survives a call into separately-built code. Interception works because the layout can't drift.

That threading isn't free of rules, though. The default calling convention passes context; a proc marked proc "contextless" does not get one. People sometimes reach for "contextless" thinking they're saving the cost of a context they don't appear to use. But the moment such a proc allocates — directly or through anything it calls — there's no context.allocator to read, and the build stops cold:

bad :: proc "contextless" () {
    p := new(int)   // no context in this scope
    p^ = 1
}

Error: 'context' has not been defined within this scope, but is required for this procedure call
    Suggestion: 'context = runtime.default_context()'

The emergent payoff: the absence of a context is caught at compile time, not as a crash or a garbage allocation at runtime. And the compiler names the cure. A proc "contextless" (or a proc "c" callback handed to a foreign library, which also arrives with no context) makes one explicit when it genuinely needs to:

my_callback :: proc "c" (data: rawptr) {
    context = runtime.default_context()  // now allocation, logging, fmt all work
    p := new(int)
    p^ = 9
}

So the context system is implicit where it helps — every ordinary proc gets one for free, and overrides cascade — and explicit exactly where implicitness would be a trap: at a boundary where no context exists, you must say so, and the compiler won't let you forget.

That's the arc: L1 context.allocator is the default new/make read implicitly → L2 override it for a scope and every callee inherits it without a parameter; an explicit arg opts out → L3 the bill is discipline: wipe the temp allocator yourself, and don't hide allocation behind an implicit read in your own APIs → L4 the fixed layout lets you intercept across boundaries, and a missing context is a compile error, not a silent bug.

probes reproduce with odin run · sizes, allocation counts, the temp-reuse fact & the L4 error are real compiler output (claims/lessons/08-context-and-allocators)