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.
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:
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.
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:
bad_double :: proc(in: []u8) -> []u8 { return make([]u8, len(in)*2) // hidden context.allocator read }
good_double :: proc(in: []u8, allocator := context.allocator) -> []u8 { return make([]u8, len(in)*2, allocator) }
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 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.