← History

Odin: data-oriented by design

How Odin's implicit context allocator, built-in slices and maps, errors-as-values, and a games-and-data-first philosophy add up to one of the most coherent memory models in systems programming.

Odin

Most systems languages are organized around a question of control: how do I get the machine to do exactly what I want, no more and no less? Odin asks a slightly different question first - how is my data laid out, and how does it flow through the program? - and lets the answer to that question drive everything else. Created by Bill Hall (Ginger Bill) in 2016 out of frustration with C++ while doing scientific and graphics work, Odin is unapologetically a data-oriented language with deep roots in the handmade and game-development communities. That orientation is not marketing; it shows up directly in the memory model, which is the thing C-lingua cares about.

This article is about how four design decisions - the implicit context (and the allocator it carries), first-class slices / dynamic arrays / maps, the deliberate absence of exceptions, and the data-oriented philosophy itself - fit together into a coherent whole. Odin has no garbage collector, no RAII, and no hidden destructors. Memory is manual, like C. What makes it feel modern is not that the language frees memory for you (it does not) but that it gives you ergonomic, swappable control over where every byte comes from.

The implicit context, and the allocator it carries

Start with the feature everything else hangs off. Every procedure written in the Odin calling convention receives a hidden parameter called context - a struct that travels down the call tree carrying, among other things, an allocator, a temp_allocator, a logger, an assertion_failure_proc, and a random-number state. You almost never name it, but it is always there.

The reason this matters for memory is that Odin's allocation built-ins - new, make, free, delete, append, and string-building helpers like fmt.aprintf - default to context.allocator. So you can write allocating code with no allocator argument anywhere in sight:

package main

import "core:fmt"

main :: proc() {
    p := new(int)     // heap-allocated via context.allocator; p is ^int
    p^ = 42           // ^ is the postfix dereference
    fmt.println(p^)   // 42
    free(p)           // returned to the SAME allocator
}

There is no #include, no global malloc reached for blindly. new(int) is exactly equivalent to new(int, context.allocator) - the second form is just spelling out the default. That equivalence is the whole trick: the allocator is data, sitting in a field you can read, log, or replace.

Why "implicit but inspectable" is the interesting middle

Two other approaches bracket Odin. C hides the allocator completely behind a global malloc - you cannot see it, cannot redirect it, cannot batch it:

char *make_greeting(const char *name) {
    size_t n = strlen(name) + 10;
    char *s = malloc(n);             /* the global heap. always. */
    snprintf(s, n, "Hello, %s!", name);
    return s;                        /* and now WHO frees it? convention only. */
}

Zig goes the opposite way: there is no implicit allocator at all, so a function that allocates must take a std.mem.Allocator parameter, visible at every call site:

const std = @import("std");

// The allocator is named explicitly, every time, in the signature.
fn makeGreeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
    return std.fmt.allocPrint(allocator, "Hello, {s}!", .{name});
}

Odin sits deliberately in the middle: the allocator is implicit (you do not thread it through every signature) but inspectable and swappable (it is a documented field, not a hidden global). You name it once for a region of the program rather than at every call:

package main

import "core:fmt"
import "core:mem"

make_greeting :: proc(name: string) -> string {
    return fmt.aprintf("Hello, %s!", name)   // uses context.allocator
}

main :: proc() {
    g1 := make_greeting("world")
    defer delete(g1)
    fmt.println(g1)

    // Redirect EVERY allocation in this block by swapping one field.
    // No call sites change - not even inside make_greeting.
    backing := make([]byte, 1 << 16)
    defer delete(backing)
    arena: mem.Arena
    mem.arena_init(&arena, backing)

    {
        context.allocator = mem.arena_allocator(&arena)
        g2 := make_greeting("arena")   // now bump-allocated from `arena`
        fmt.println(g2)
        // no delete(g2): the whole arena is reclaimed at once below
    }
    mem.arena_free_all(&arena)         // free everything in one operation
}

Because context propagates down the call tree, setting context.allocator at the top of a subsystem reroutes every new, make, append, and fmt.aprintf underneath it - including calls inside library code you did not write - through your allocator. That is the payoff: per-subsystem allocation policy decided in one place, with no signature churn.

defer, not destructors

Odin has no RAII. Cleanup is scheduled explicitly with defer, which runs at scope exit in reverse order of registration. The idiom is to put the free/delete on the line right after the allocation, so no early return or error path can skip it:

process :: proc() {
    buf := make([]u8, 1024)
    defer delete(buf)         // runs on EVERY exit from this scope

    p := new(Node)
    defer free(p)             // runs BEFORE delete(buf): reverse order

    if buf[0] == 0 {
        return                // both defers still fire
    }
    // ... use buf and p ...
}

This is the same explicit-statement cleanup Zig and Hare use, and the honest trade against C++'s automatic destructors: you give up "it happens for you" to keep the promise that nothing runs implicitly.

The temp allocator: an arena promoted to a convention

The context carries a second allocator, context.temp_allocator - a per-thread scratch arena for short-lived junk (a formatted string, a throwaway list inside one frame or one request). You allocate into it freely and reclaim everything at once with free_all, never matching individual deletes:

import "core:fmt"

build_message :: proc(name: string, n: int) -> string {
    // Allocate the formatted string from temp memory, not the heap.
    return fmt.aprintf("Hello %s, you have %d messages",
                       name, n, allocator = context.temp_allocator)
}

main :: proc() {
    defer free_all(context.temp_allocator)   // clear the scratch arena at frame end
    msg := build_message("Aria", 3)          // lives in temp memory
    fmt.println(msg)
    // no individual delete(msg): free_all reclaims it.
}

The mental shift here is from per-object lifetime to per-phase lifetime. You stop asking "when does this object die?" and start asking "when does this phase end?" - usually a much easier question. Parse a request into temp memory, reset when the response is sent; build a frame in temp memory, reset at vsync. The one rule: anything in temp memory must be done with before the next free_all.

Slices, dynamic arrays, and maps as language types

The second pillar is that Odin's core data structures are built into the language, and each one cooperates directly with the context allocator. This is where "data-oriented" stops being a slogan and becomes a memory model.

A fixed array [N]T carries its length in the type and lives wherever you put it (stack, struct, global). A slice []T is a view - a pointer plus a length - that owns nothing and is bounds-checked:

arr := [5]int{10, 20, 30, 40, 50}   // length is part of the type
s   := arr[1:4]                      // slice: a view of {20, 30, 40}
fmt.println(len(s), s[0])            // 3 20   (len is O(1); access is bounds-checked)

Because a slice carries its length, you never pass a separate count argument the way C forces you to, and out-of-bounds access is caught rather than silently corrupting memory. Contrast C, where the length and the pointer are two unrelated values held together only by hope:

/* C: pointer and length are separate; nothing ties them together. */
void sum(const int *xs, size_t n);   /* caller MUST pass the right n */
int  a[5] = {10, 20, 30, 40, 50};
sum(a, 5);                            /* off-by-one here = buffer overrun */

A dynamic array [dynamic]T is a growable buffer (like C++'s std::vector). It allocates from context.allocator, grows on append, and is released with delete:

nums: [dynamic]int       // empty; backed by context.allocator
defer delete(nums)
append(&nums, 1)
append(&nums, 2, 3, 4)   // append takes one element or many
fmt.println(nums, len(nums), cap(nums))   // [1, 2, 3, 4] 4 ...

Maps are likewise a language-level type with the same allocator story and the same delete pairing:

ages: map[string]int
defer delete(ages)
ages["Aria"] = 30
if v, ok := ages["Aria"]; ok {   // comma-ok form reports whether the key existed
    fmt.println(v)
}

Note the deliberate two-word vocabulary that keeps cleanup unambiguous: new pairs with free (single values), and make pairs with delete (slices, dynamic arrays, maps, cloned strings). Both routes go through the same in-scope allocator, so swapping context.allocator redirects append's growth reallocations exactly as it redirects everything else.

Hare reaches a similar place with a smaller surface - slices are built in and carry their length, but dynamic arrays and maps are less central, and alloc/free target a single global heap rather than a swappable interface:

// Hare: slices carry length too; alloc/free hit the global heap.
let xs: []int = alloc([1, 2, 3, 4]);
defer free(xs);

The point of Odin's richer built-ins is not convenience for its own sake. It is that the common containers a data-oriented program lives in all funnel through one allocator field, so the entire program's memory behavior is steerable from a single knob.

No exceptions: errors are ordinary values

The third pillar reinforces the first two. Odin has no exceptions, no try/catch, and no stack unwinding for ordinary errors. This is not an oversight; it is load-bearing for the memory model. Exceptions introduce hidden control flow - an error thrown three frames down can blow past your code on its way out - and that is exactly the kind of invisible behavior that makes manual cleanup treacherous. Without a GC to mop up after an unwind, "what got freed and what leaked?" becomes unanswerable.

So Odin returns errors as plain values, typically via multiple return values, with the error usually an enum or a tagged union:

Error :: enum { None, Div_By_Zero }

safe_div :: proc(a, b: int) -> (int, Error) {
    if b == 0 {
        return 0, .Div_By_Zero   // .Div_By_Zero is shorthand for Error.Div_By_Zero
    }
    return a / b, .None
}

result, err := safe_div(10, 0)
if err != .None {
    fmt.println("failed:", err)
}

To keep that from becoming a wall of if err != nil boilerplate, Odin provides or_return (propagate a non-zero trailing error up the stack, like Zig's try or Rust's ?) and or_else (supply a fallback value):

load :: proc(path: string) -> (Config, Error) {
    data := read_file(path) or_return    // on error, return it up; else yield data
    cfg  := parse(data)     or_return
    return cfg, .None
}

port := parse_int(s) or_else 8080        // fall back to 8080 if parsing failed

The crucial property for memory: because every error path is visible in the source and exits through a normal return, your defer free(...)/defer delete(...) statements fire on the error path exactly as they fire on the success path. No invisible unwinding means no leaked allocation that an exception silently skipped over. The C++ contrast is instructive - RAII does make exceptions memory-safe via stack unwinding (destructors run as the stack peels back):

// C++: an exception here unwinds the stack; ~vector frees buf automatically.
void process() {
    std::vector<int> buf(1024);
    might_throw();           // if it throws, buf is still cleaned up by RAII
}

Both are coherent answers. C++ says "errors can unwind, and RAII guarantees cleanup during the unwind." Odin says "errors don't unwind at all, so cleanup stays where you wrote it." Odin's choice keeps the cost model legible: control flow goes only where the source says it goes.

The data-oriented philosophy: #soa and SoA layout

The fourth pillar is the why behind the other three. Data-oriented design starts from the hardware reality that modern CPUs are starved for memory bandwidth: a cache miss can cost hundreds of cycles, and the way to avoid misses is to lay data out so the bytes you need next are the bytes already in cache. Object-oriented "array of structs" (AoS) fights this - iterating one field across many objects drags in every other field too, polluting the cache.

The data-oriented answer is structure of arrays (SoA): store all the xs contiguously, then all the ys, so a loop over one field streams perfectly through cache. Odin builds this in with the #soa directive, which transposes the layout automatically - you still write entities[i].x, but in memory the fields are split:

Entity :: struct { x, y, vx, vy: f32 }

// Stored as four contiguous arrays (all x's, then all y's, ...),
// but indexed as if it were an array of structs.
entities: #soa[dynamic]Entity
defer delete_soa(entities)
append_soa(&entities, Entity{0, 0, 1, 1})

// Updating just position+velocity streams x, y, vx, vy through cache
// without touching unrelated fields.
for i in 0..<len(entities) {
    entities[i].x += entities[i].vx
    entities[i].y += entities[i].vy
}

Doing this by hand in C means maintaining several parallel arrays and indexing them in lockstep - correct, but error-prone and ugly:

/* C: SoA by hand. Four arrays kept in sync; one wrong index corrupts data. */
typedef struct {
    float *x, *y, *vx, *vy;
    size_t n;
} Entities;

void step(Entities *e) {
    for (size_t i = 0; i < e->n; i++) {
        e->x[i] += e->vx[i];
        e->y[i] += e->vy[i];
    }
}

Odin's #soa gives you the AoS programming model with the SoA memory layout - the data-oriented win without the bookkeeping. This is why the language found its home in game tooling and high-performance work: arenas reset per frame, #soa for the hot loops, slices so lengths travel with data, and one allocator knob to tune it all.

Tooling for the discipline: tracking allocators

Manual memory means a class of bugs (leaks, double-frees, use-after-free) that a GC would have eaten for you. Because the allocator is data in Odin, the language can hand you a debug allocator that catches them. mem.Tracking_Allocator wraps any other allocator and records every allocation, so at shutdown you can report exactly what leaked and where it was allocated:

import "core:mem"
import "core:fmt"

main :: proc() {
    track: mem.Tracking_Allocator
    mem.tracking_allocator_init(&track, context.allocator)
    context.allocator = mem.tracking_allocator(&track)   // wrap the whole program
    defer {
        for _, leak in track.allocation_map {
            fmt.printf("leaked %v bytes @ %v\n", leak.size, leak.location)
        }
        mem.tracking_allocator_destroy(&track)
    }

    // ... run the program; any unfreed allocation is reported above ...
}

This is the Odin echo of Zig's leak-detecting GeneralPurposeAllocator: because allocation went through an interface you control, "did I leak?" becomes a test you can run rather than a hope you hold. Run your suite under a tracking allocator and every unfreed byte is a failure with a source location.

Where Odin sits among the seven

It helps to place Odin on the spectrum this site walks. At one end, C hides the allocator behind a global malloc and ties pointer to length only by convention. HolyC - Terry A. Davis's C dialect, the native language and JIT shell of TempleOS - is firmly classic in spirit, with MAlloc/Free against a heap, but with one prescient twist: every task has its own heap, and when a task dies its heap is reclaimed automatically. That is, in effect, an arena at task granularity, and MAlloc even takes an optional heap argument - the seed of the explicit-allocator idea, arrived at from an entirely different motivation on a system with no memory protection at all:

/* HolyC: MAlloc returns uninitialized memory (CAlloc is the zeroing
   variant); the optional second arg names WHICH heap to draw from. */
U8 *p = MAlloc(256);          /* this task's heap */
*p = 42;
Print("%d\n", *p);
Free(p);                       /* Free(NULL) is a safe no-op */

Forth, the oldest here, is a bump allocator at its core - the dictionary grows by advancing HERE via ALLOT, and MARKER lets you rewind a whole region - an arena predating the term by decades:

\ Forth: the dictionary data space is itself a bump arena.
CREATE BUF  1024 ALLOT     \ reserve 1024 bytes by advancing HERE
MARKER -WORK               \ remember this point...
\ ... define and allocate things ...
-WORK                      \ ...and reclaim everything since, in one word

Zig makes the allocator a fully explicit parameter; Hare keeps a tiny runtime with a global-heap alloc/free and clean slices, leaving arenas to be built and passed by hand; C++ automates ownership with RAII and retrofits swappable allocators with C++17's std::pmr. Odin's distinct contribution is the implicit-but-inspectable middle: an allocator that rides along automatically through a documented context, paired with built-in data structures and an honest, exception-free control flow - so the whole program's memory behavior is both convenient by default and steerable from one place.

The takeaway

Odin's memory model is not a grab-bag of features; it is one idea seen from four sides. The context makes the allocator implicit but visible. The built-in slices, dynamic arrays, and maps funnel the program's data through that one allocator. The absence of exceptions keeps control flow - and therefore cleanup - exactly where you wrote it. And the data-oriented philosophy explains why all of this is shaped the way it is: it exists to let you decide, per phase and per subsystem, where your data lives and when it dies. No garbage collector, no RAII, no hidden costs - just the allocator as a value you hold, and the freedom to swap it.