Allocators & the Context System
There is no new syntax to learn here. Everything you already write — make, new, append, delete — already goes through this machinery. This lesson just makes the machinery visible, so you can control it when it matters.
Where Memory Comes From
Start with the decision that shapes everything else.
There Is No Garbage Collector
Odin has no runtime that watches your allocations and reclaims them for you. Memory is acquired explicitly and released explicitly. That is a real trade, and both halves are worth knowing:
- What you gain: no pauses you did not ask for, no runtime sitting between you and the machine, and a cost model that is visible in the source. A loop that allocates says so; a loop that does not, does not.
- What you pay: memory you allocate is your responsibility. Forget a
deleteand the program quietly uses more memory than it should.
Odin's answer to that second half is not a garbage collector but a set of habits — and the most important habit is one you already met: defer delete(...) written directly under the allocation that it matches.
Who Actually Allocates
Here is the detail that surprises people arriving from C. make and new do not each call the operating system. They ask something for memory, and that something is chosen by your program:
// A dynamic array: memory comes from the CURRENT context's allocator.
queue := make([dynamic]int, 0, 8)
defer delete(queue)
// A single value: new allocates one zeroed int and gives you its address.
node := new(int) // node is a ^int
defer free(node)
node^ = 42
fmt.println(node^) // 42
Two different pairs of procedures, and mixing them up is a genuine bug: make goes with delete, and new goes with free. You will see them side by side in the last section of this page.
So who is "the current context"? That is the next section, and it is the reason this lesson exists.
The Context
Most languages answer "where do allocations come from" with a global: one system allocator, one logger, one random-number source, fixed for the life of the program. Odin's alternative is to make that bundle an ordinary value and thread it through your code automatically.
A Bag of Implicit Services
That bundle is the context. There is one variable named context in scope, the compiler maintains it for you, and — this is the part that matters — it is a plain struct that you can read, copy, and replace.
Its job is to carry the things that a built-in procedure needs but cannot sensibly take as an argument. Nobody wants to write make(allocator, type, length, capacity) in every line, so the allocator travels alongside instead.
What Lives in a Context
Five fields, and the list is short enough to memorise. Each one supplies something the language needs at a moment's notice:
| Field | What it is for |
|---|---|
allocator | Where make, new, and most allocation comes from |
temp_allocator | Scratch memory for short-lived values inside a scope |
logger | Where your program's logging output goes |
assertion_failure_proc | What happens when an assertion fails |
random_generator | The source of random numbers |
Look at what is not in that list. There is no hidden heap that a library can reach into behind your back, no implicit global logger, no global random state. Everything a built-in needs is in one struct you can inspect — and that is the whole design.
Reading and Setting the Allocator
Because the context is an ordinary value, you can look at it and change the parts you care about. Begin by reading the allocator that is in force right now:
// The allocator currently in use is just a value you can hold on to.
current := context.allocator
fmt.println(current) // whatever is installed, shown as its parts
An allocator is less mysterious than its name suggests. It is a procedure that knows how to satisfy an allocation, plus a data pointer that carries whatever bookkeeping the strategy needs. Two parts — which is exactly why an allocator can be any strategy you like: the default that talks to the system, a bump allocator that hands out slices of one big block, a pool that recycles fixed-size objects, or an arena that frees everything at once.
Whole-context replacement works too, and it has one specific use worth knowing. A procedure with the C calling convention may be entered straight from the operating system or a C library, with no context set up for it. The first line of such a procedure installs the standard one:
import "base:runtime"
// Rebuild the standard context — the usual opening line of a `proc "c"` entry point.
context = runtime.default_context()
make and new consult the context; the allocator in it decides where the bytes actually come from.Passing an Allocator Explicitly
The context is a convenience, not a cage. When a particular collection needs a particular allocator, say so — the built-ins accept one as an extra argument.
make with an Allocator
// The allocator becomes an ordinary argument; the context is not consulted.
buffer := make([]u8, 4096, context.allocator)
defer delete(buffer)
fmt.println(len(buffer)) // 4096
Read that as: "make me a buffer, and take the memory from this allocator rather than from whatever happens to be installed." The same extra argument works for dynamic arrays, and it is how a function that receives an allocator from its caller can guarantee where its data lives.
When to Be Explicit
| Situation | Why an explicit allocator helps |
|---|---|
| Lifetimes differ | Data that must outlive a temporary scope cannot come from that scope's scratch allocator |
| Strategies differ | A long-lived collection may deserve the general allocator while a per-frame buffer does not |
| You are testing | A test can pass an allocator that refuses, and check how the code behaves when memory runs out |
| You are writing a library | Accepting an allocator from the caller means your code never assumes how much memory it may use |
One obligation comes with the choice: an allocator must stay alive as long as the data allocated from it. A dynamic array remembers where its memory came from, but it cannot keep your arena from being destroyed underneath it.
What an Allocator Actually Is
Time to demystify the word. An allocator is not a class hierarchy or a plugin system; it is two fields.
A Procedure and Some Data
Here is the default context being built, quoted from Odin's own runtime. It is the clearest possible definition:
// From the runtime, while it initialises the standard context:
c.allocator.procedure = default_allocator_proc
c.allocator.data = nil
An allocator is a procedure that services requests — allocate, resize, free, free everything — plus a data pointer that carries whatever that strategy needs to remember. The context's allocator starts as the default one, and stays that way until you replace it.
That is the entire interface, and it explains a phrase you will read in Odin discussions: an allocator is just a procedure with some data. Writing your own is therefore a reasonable thing to do, because the rest of the language will treat it as if it were the system's.
Three Strategies You Will Meet
You do not have to invent a strategy to benefit from one. Three patterns cover most needs, and Odin's core:mem provides all three:
| Strategy | The idea | What it buys |
|---|---|---|
| Arena / region | One big block, handed out piece by piece | Freeing everything is a single operation |
| Pool | Fixed-size slots, recycled as they are returned | No fragmentation for many equal objects; reuse is immediate |
| Stack / temp | Last in, first out, within a scope | Scratch memory that costs almost nothing to reclaim |
Notice that each one is an answer to a shape of problem rather than a general improvement. That is the reason the choice is left to you: no single strategy wins at everything, and a language that hides the choice cannot help you make it well.
Swapping the Allocator for a Scope
Now the payoff. Because the allocator travels in the context, redirecting allocation for a block of work takes three lines of ordinary code.
Save, Replace, Restore
// 1. remember what is in force
previous := context.allocator
// 2. install the allocator this scope should use
context.allocator = scratch_allocator
// 3. put the old one back — on every exit path, not just the happy one
defer context.allocator = previous
// ... everything here that allocates now uses scratch_allocator ...
The third line is the one people forget, and it is the one that matters. The context belongs to the caller's world, not yours: leaving your scratch allocator installed means the next procedure — possibly one you have never read — allocates its own data out of your scratch space. That is exactly the kind of cross-module surprise Odin's design exists to prevent.
temp_allocator — Scratch Space
One of those five context fields is already an allocator meant for exactly this job. temp_allocator is a scratch allocator whose contents you are expected to abandon when a scope ends.
Think of the shape of work it suits: parse a document, build a handful of temporary slices while doing so, then discard the lot at the end of the function. Freeing item by item would be pointless — the whole region is going away. Because it is a stack-style allocator, discarding is not a loop of frees; it is one pointer moving back, and the built-in free_all is how you say "everything from this allocator, now":
process_frame :: proc() {
// Temporary strings, temporary buffers, temporary slices — all of them
// come out of the scratch allocator...
name := strings.clone_to_cstring(text, context.temp_allocator)
// ...and one line releases every one of them at once.
defer free_all(context.temp_allocator)
// ... work with name ...
}
Look at where that defer sits: inside a procedure, so the scratch space is released when the procedure ends. In a loop it would release the scratch space at the end of each iteration, which is exactly what a per-frame or per-request scratch budget wants. That single line is the idiom you will see most often in Odin code that handles text and buffers.
new, free, make, delete
Two pairs of procedures, and confusing them is a real bug rather than a matter of style.
Two Pairs You Must Not Mix Up
| To create | Use | To release | What you get back |
|---|---|---|---|
| A slice, dynamic array, or map | make | delete | The collection itself |
| A single value on the heap | new | free | A pointer to it — ^T |
Both pairs take memory from the context's allocator unless you pass one explicitly, and both pairs must be matched: delete on something that came from new, or free on a dynamic array, is simply wrong — each pair expects the bookkeeping the other one did not write.
Notice also how their purposes differ. A dynamic array is a container that grows; new is for a single long-lived value that needs an address of its own — a node in a tree, an object shared by several owners, something too large for the stack.
defer Is the Idiom
With no garbage collector, the discipline that replaces it is a habit rather than a feature: write the release on the line under the acquisition.
process :: proc() {
scratch := make([dynamic]u8, 0, 4096, context.allocator)
defer delete(scratch) // written once, runs on every exit path
append(&scratch, 1, 2, 3)
// ... real work, including any early returns added later ...
} // delete runs here — whichever route we took to reach it
Why this habit rather than a tidy cleanup at the end of the function? Because code changes. A return added six months from now would skip a cleanup written at the bottom, and the leak would be invisible. A defer attached to the allocation cannot be bypassed.
Where This Goes Next
The last lesson of Phase 4 puts the two previous ones together. You now know where memory is and who provides it; next we look at how to arrange it — because on modern hardware the shape of your data decides how fast the program runs.
The Page in One Breath
- Odin has no garbage collector: memory is acquired and released explicitly, and
defer delete(...)is the habit that replaces the collector. makeandnewtake memory from the context, a plain struct holding five services: allocator, temp_allocator, logger, assertion_failure_proc, random_generator.- An allocator is a procedure plus a data pointer — that is the whole type, which is why writing your own is a reasonable thing to do.
- Pass an allocator explicitly when a collection's lifetime or strategy must differ from the scope's:
make(..., context.allocator). - Redirect allocation for a scope with save, replace, restore — and never skip the restore.
temp_allocatoris scratch space, discarded wholesale when the scope ends.make/deleteandnew/freeare two pairs; matching them is part of writing correct code.
Continue with Data-Oriented Design (SOA/AOS) →