Allocators & the Context System

This is the lesson that answers the question Odin has been asking you since Phase 1: where does the memory come from? The answer is an allocator, chosen by you, carried in a small structure called the context. Understanding these two words explains most of what makes Odin feel different from a language with a garbage collector.

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 delete and 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:

FieldWhat it is for
allocatorWhere make, new, and most allocation comes from
temp_allocatorScratch memory for short-lived values inside a scope
loggerWhere your program's logging output goes
assertion_failure_procWhat happens when an assertion fails
random_generatorThe 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.

Why bother with all this? Because a global heap makes two things impossible: using a different allocation strategy for one part of a program, and testing allocating code without touching a real heap. Threading the choice through a context keeps both doors open, and costs you nothing on the days when you do not care.

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()
The context structure with its five fields, showing make and new drawing memory from context.allocator, which points at an allocator made of a procedure and some data, which in turn obtains memory from the system
The path from a line of Odin to real memory. 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

SituationWhy an explicit allocator helps
Lifetimes differData that must outlive a temporary scope cannot come from that scope's scratch allocator
Strategies differA long-lived collection may deserve the general allocator while a per-frame buffer does not
You are testingA test can pass an allocator that refuses, and check how the code behaves when memory runs out
You are writing a libraryAccepting 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:

StrategyThe ideaWhat it buys
Arena / regionOne big block, handed out piece by pieceFreeing everything is a single operation
PoolFixed-size slots, recycled as they are returnedNo fragmentation for many equal objects; reuse is immediate
Stack / tempLast in, first out, within a scopeScratch 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.

Save, replace, restore. The pattern is worth memorising as a trio, because it works for the other context fields too: swap the logger to capture messages, swap the allocator to change strategy — and always restore when the scope ends.

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 createUseTo releaseWhat you get back
A slice, dynamic array, or mapmakedeleteThe collection itself
A single value on the heapnewfreeA 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.

Thinking in Odin: the context system and the two allocator pairs are the same idea seen twice — memory is a resource with an owner, and the owner should be visible in the source. You are not expected to account for every byte by hand; you are expected to know, at every allocation, where the memory came from and when it goes back.

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.
  • make and new take 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_allocator is scratch space, discarded wholesale when the scope ends.
  • make/delete and new/free are two pairs; matching them is part of writing correct code.
Well done. A good exercise with nothing to run: take three allocations from your own programs — or from the earlier lessons — and write down, for each, which allocator provided the memory and which line releases it. If either answer is "I am not sure", that is precisely the situation this lesson exists to prevent.

Continue with Data-Oriented Design (SOA/AOS) →