← Back to DevBytes

Memory Management in Odin: A Deep Dive

Memory Management in Odin: A Deep Dive

Odin is a systems programming language that places manual memory management squarely in the hands of the developer — but it does so with an extraordinary degree of control, transparency, and flexibility. Unlike languages that rely on garbage collection or mandatory reference counting, Odin gives you raw power over every allocation, while providing a rich set of tools to make that power manageable, auditable, and remarkably pleasant to wield.

This tutorial takes you from the foundations of Odin's memory model all the way through to advanced patterns like tracking allocators, arena allocation, and temporary memory strategies. By the end, you will understand not just the mechanics, but the philosophy that makes Odin's approach so effective for systems programming, game development, and performance-critical applications.

What Memory Management Means in Odin

At its core, Odin treats memory as a resource that you explicitly request and explicitly release. There is no hidden garbage collector running behind the scenes, no automatic reference counting injected by the compiler, and no mandatory ownership semantics imposed by the type system. Instead, Odin gives you three fundamental pillars for working with memory:

Every piece of dynamically allocated memory in Odin is associated with an allocator — an explicit object that governs how memory is obtained and released. This design means you can have multiple allocators active simultaneously, each with different strategies, lifetimes, and debugging capabilities.

package memory_demo

import "core:fmt"

main :: proc() {
    // Every allocation requires specifying an allocator
    data, err := alloc(42)
    // Without an allocator, this would not compile
}

Why Memory Management Matters in Odin

The importance of understanding Odin's memory model cannot be overstated. Here is why it matters deeply for real-world development:

The Context System: Odin's Secret Weapon

Before diving deeper into allocators, you must understand Odin's context mechanism. Every Odin procedure receives an implicit context parameter that carries, among other things, the current allocator. This is what allows functions deep in your call stack to allocate memory without you having to thread an allocator parameter through every single function signature manually.

The context includes these critical fields:

Context :: struct {
    allocator:              Allocator,
    temp_allocator:         Allocator,
    panic_allocator:        Allocator,
    assertion_allocator:    Allocator,
    // ... logging, user data, and more
}

When you call context.allocator, you are accessing the current allocator for general-purpose allocations. The context.temp_allocator is specifically designed for short-lived scratch memory that will be freed en masse at the end of a frame or scope. This separation of concerns is one of Odin's most brilliant design choices.

Allocator Types in Depth

Odin ships with a comprehensive set of allocator implementations in the core:mem package. Each serves a distinct purpose and can be mixed, matched, and layered.

The Default Allocator

The default allocator wraps the operating system's heap allocation functions (typically malloc/free or their platform equivalents). It is general-purpose but offers no tracking or optimization. Use it when you need persistent, indefinite-lifetime memory and are not concerned about fragmentation.

import "core:mem"

main :: proc() {
    // Use the default heap allocator
    slice, err := mem.alloc(1024, mem.default_allocator)
    defer mem.free(slice, mem.default_allocator)
    
    // Now slice is valid until the defer runs
    for i in 0..<1024 {
        slice[i] = u8(i)
    }
}

The Tracking Allocator

The tracking allocator wraps any other allocator and records every allocation and deallocation along with the call site. It is your most powerful debugging tool. When your program exits, it can dump a complete report of outstanding allocations, including the exact file and line where each was made.

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

main :: proc() {
    // Create a tracking allocator that wraps the default allocator
    track: mem.Tracking_Allocator
    mem.tracking_allocator_init(&track, mem.default_allocator)
    defer mem.tracking_allocator_destroy(&track)
    
    tracking_allocator := mem.tracking_allocator(&track)
    
    // Allocate using the tracking allocator
    data, err := mem.alloc(512, tracking_allocator)
    defer mem.free(data, tracking_allocator)
    
    // Deliberately leak some memory to see the report
    leaked, _ := mem.alloc(256, tracking_allocator)
    _ = leaked  // oops, never freed
    
    fmt.println("Check the tracking report on exit!")
}

When this program terminates, the tracking allocator will print something like:

[Tracking Allocator] === Outstanding Allocations ===
[Tracking Allocator] 256 bytes at 0x7f... allocated at main (line 16)
[Tracking Allocator] Total outstanding: 256 bytes in 1 allocation

The Arena Allocator

The arena allocator is perhaps the most useful allocator for high-performance code. It allocates from a large contiguous block and never frees individual allocations. Instead, the entire arena is reset at once. This eliminates fragmentation, makes allocation trivially fast (just bumping a pointer), and is perfect for per-frame game data, batch processing, or request-scoped memory in servers.

import "core:mem"

main :: proc() {
    // Create an arena with 4 megabytes of backing memory
    arena_data := make([]byte, 4 * 1024 * 1024)
    arena: mem.Arena
    mem.arena_init(&arena, arena_data)
    defer delete(arena_data)  // free the backing memory
    
    arena_allocator := mem.arena_allocator(&arena)
    
    // Allocate thousands of objects with zero fragmentation
    for i in 0..<10000 {
        obj, err := mem.alloc(64, arena_allocator)
        // Process obj...
        _ = obj
    }
    
    // Reset the entire arena — all 10,000 allocations are "freed" instantly
    mem.arena_reset(&arena)
    
    // Now reuse the arena for another batch
    for i in 0..<5000 {
        obj2, _ := mem.alloc(128, arena_allocator)
        _ = obj2
    }
}

The arena allocator's _Free procedure does nothing — individual freeing is intentionally unsupported. This is by design. You reset the whole arena when you are done with the batch of allocations. The discipline this imposes actually leads to cleaner, more predictable code.

The Pool Allocator

A pool allocator manages fixed-size blocks, making it ideal for allocating many objects of the same type — particle systems, entity-component frameworks, or any homogeneous data structure. It provides O(1) allocation and deallocation with no fragmentation.

import "core:mem"

Particle :: struct {
    position: [3]f32,
    velocity: [3]f32,
    lifetime: f32,
}

main :: proc() {
    pool: mem.Pool
    // Initialize pool for Particle-sized objects with 4096 slots
    mem.pool_init(&pool, size_of(Particle), 4096)
    defer mem.pool_destroy(&pool)
    
    pool_allocator := mem.pool_allocator(&pool)
    
    // Allocate a particle from the pool
    p_ptr, err := mem.alloc(size_of(Particle), pool_allocator)
    particle := transmute(^Particle)p_ptr
    particle.position = {1.0, 2.0, 3.0}
    particle.lifetime = 5.0
    
    // Free it back to the pool for reuse
    mem.free(p_ptr, pool_allocator)
}

The Stack Allocator

The stack allocator works like an arena but supports freeing in LIFO (last-in-first-out) order. It is perfect for temporary allocations within a well-defined scope where you know the allocation pattern follows a stack discipline — recursive descent parsers, tree traversals, or layered UI rendering.

import "core:mem"

main :: proc() {
    stack_data := make([]byte, 2 * 1024 * 1024)
    stack: mem.Stack
    mem.stack_init(&stack, stack_data)
    defer delete(stack_data)
    
    stack_alloc := mem.stack_allocator(&stack)
    
    // Push allocations onto the stack
    header, _ := mem.alloc(256, stack_alloc)
    body, _ := mem.alloc(4096, stack_alloc)
    footer, _ := mem.alloc(64, stack_alloc)
    
    // Free in reverse order — must be LIFO!
    mem.free(footer, stack_alloc)
    mem.free(body, stack_alloc)
    mem.free(header, stack_alloc)
}

The Temp Allocator: Automatic Scratch Memory

Odin's context.temp_allocator deserves special attention. It is typically backed by an arena and is designed for memory that should not survive beyond the current frame or logical scope. The brilliant part is that you can use it with a defer to automatically clean up an entire batch of temporary allocations.

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

process_batch :: proc(items: []int) -> []int {
    // Save and restore the temp allocator's state
    arena_snapshot := mem.begin_temp()
    defer mem.end_temp(arena_snapshot)
    
    // Everything allocated with context.temp_allocator here
    // will be freed by the defer above
    result := make([]int, len(items))
    // This make() uses context.temp_allocator
    
    for item, i in items {
        result[i] = item * 2
    }
    
    // WARNING: Do NOT return result to the caller!
    // It will be invalid after end_temp() runs
    return result  // DANGER — use only within this scope
}

main :: proc() {
    data := []int{1, 2, 3, 4, 5}
    // This will cause problems because process_batch
    // returns temp-allocated memory
}

The key rule with temp allocators is: never return temp-allocated memory to a caller that lives beyond the temp scope. Temp memory is for intermediate computations, scratch buffers, and data that is consumed and discarded within the same logical frame.

Custom Allocators: When You Need Something Unique

Odin's allocator system is extensible. An allocator is simply a struct containing procedure pointers for allocation, freeing, resizing, and querying. You can implement custom allocators for specialized needs like memory-mapped files, GPU-shared memory, or custom heap strategies.

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

// A minimal custom allocator that just wraps the OS heap
custom_allocator_proc :: proc(
    allocator_data: rawptr,
    mode: mem.Allocator_Mode,
    size, alignment: int,
    old_memory: rawptr,
    old_size: int,
    location: mem.Source_Code_Location,
) -> ([]byte, mem.Allocator_Error) {
    context.allocator = mem.default_allocator
    
    switch mode {
    case .Alloc:
        ptr := mem.alloc_bytes(size, alignment)
        return ptr, nil
    case .Free:
        mem.free_bytes(old_memory)
        return nil, nil
    case .Resize:
        new_ptr := mem.resize_bytes(old_memory, size)
        return new_ptr, nil
    case .Query:
        // Return empty slice for query mode
        return nil, nil
    }
    return nil, .Invalid_Mode
}

main :: proc() {
    my_allocator: mem.Allocator
    my_allocator.procedure = custom_allocator_proc
    my_allocator.data = nil
    
    data, err := mem.alloc(1024, my_allocator)
    defer mem.free(data, my_allocator)
    
    fmt.printf("Allocated %d bytes with custom allocator\n", len(data))
}

Allocator Composition Patterns

One of Odin's most powerful features is the ability to compose allocators. You can wrap a tracking allocator around an arena, or put a pool allocator inside a larger arena-backed system. This composability lets you build exactly the memory strategy your application needs.

import "core:mem"

main :: proc() {
    // Create a large backing arena
    arena_memory := make([]byte, 8 * 1024 * 1024)
    arena: mem.Arena
    mem.arena_init(&arena, arena_memory)
    defer delete(arena_memory)
    
    // Wrap it with a tracking allocator for debugging
    track: mem.Tracking_Allocator
    mem.tracking_allocator_init(&track, mem.arena_allocator(&arena))
    defer mem.tracking_allocator_destroy(&track)
    
    // Now you have fast arena allocation WITH leak detection!
    tracked_arena := mem.tracking_allocator(&track)
    
    // Allocate freely — leaks will be reported on exit
    game_state, _ := mem.alloc(4096, tracked_arena)
    player_data, _ := mem.alloc(1024, tracked_arena)
    
    // Process everything...
    _ = game_state
    _ = player_data
    
    // Reset the arena at end of frame
    mem.arena_reset(&arena)
}

Working with Dynamic Data Structures

Odin's built-in dynamic types — slices, dynamic arrays, maps, and strings — all require allocators when you perform operations that may grow or allocate memory. The language makes this explicit, which prevents accidental hidden allocations.

import "core:fmt"

main :: proc() {
    // Dynamic array — explicitly pass allocator
    numbers := make([dynamic]int, 0, 16)
    // make() uses context.allocator by default
    
    append(&numbers, 42)    // may reallocate using context.allocator
    append(&numbers, 99, 100, 101)
    
    fmt.println(numbers)
    
    // Map with explicit allocator control
    scores := make(map[string]int)
    scores["player_one"] = 1500
    scores["player_two"] = 2300
    // Map operations use context.allocator for internal nodes
    
    // Don't forget to clean up!
    delete(numbers)
    delete(scores)
}

Best Practices for Odin Memory Management

Common Pitfalls and How to Avoid Them

Even experienced developers can stumble with manual memory management. Here are the most common Odin-specific traps:

// PITFALL 1: Using a freed arena's allocations
arena: mem.Arena
arena_data := make([]byte, 1024)
mem.arena_init(&arena, arena_data)
alloc := mem.arena_allocator(&arena)

ptr, _ := mem.alloc(256, alloc)
mem.arena_reset(&arena)
// ptr is now dangling — accessing it is undefined behavior

// PITFALL 2: Returning temp-allocated slices
bad_function :: proc() -> []int {
    snapshot := mem.begin_temp()
    defer mem.end_temp(snapshot)
    
    result := make([]int, 100)
    return result  // Will be invalid after defer runs!
}

// PITFALL 3: Stack allocator out-of-order free
stack: mem.Stack
stack_data := make([]byte, 4096)
mem.stack_init(&stack, stack_data)
stack_alloc := mem.stack_allocator(&stack)

a, _ := mem.alloc(100, stack_alloc)
b, _ := mem.alloc(200, stack_alloc)
mem.free(a, stack_alloc)  // ERROR: freeing a before b
// Stack allocator requires strict LIFO order

Advanced Pattern: Scoped Allocator Overrides

You can temporarily override the context allocator for a block of code, which is extremely useful for directing allocations from third-party or library code into your own memory systems.

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

render_frame :: proc() {
    // Save the original allocators
    original_allocator := context.allocator
    original_temp := context.temp_allocator
    
    // Create frame-specific arena
    frame_arena: mem.Arena
    frame_memory := make([]byte, 16 * 1024 * 1024)
    mem.arena_init(&frame_arena, frame_memory)
    defer {
        delete(frame_memory)
        // Restore original allocators
        context.allocator = original_allocator
        context.temp_allocator = original_temp
    }
    
    // Override context for this frame
    context.allocator = mem.arena_allocator(&frame_arena)
    context.temp_allocator = context.allocator
    
    // Now ALL allocations in called functions use the arena
    draw_ui()
    process_physics()
    update_animations()
    
    // Reset arena for next frame
    mem.arena_reset(&frame_arena)
}

Memory Alignment and Specialized Allocations

Odin's allocator interface supports alignment requirements, which is crucial for SIMD operations, GPU data, and cache-line optimization. The allocator procedures accept an alignment parameter, and the built-in allocators handle alignment correctly.

import "core:mem"
import "core:simd"

main :: proc() {
    // Allocate 64-byte aligned memory for SIMD vectors
    simd_data, err := mem.alloc(
        4096,
        mem.default_allocator,
        alignment = 64,  // align to 64-byte boundary
    )
    defer mem.free(simd_data, mem.default_allocator)
    
    // Use with SIMD types that require alignment
    vectors := transmute([^]simd.f32x4)simd_data
    vectors[0] = simd.f32x4{1.0, 2.0, 3.0, 4.0}
}

Memory Safety Through Explicit Design

Odin's philosophy is that memory safety comes not from a garbage collector or a borrow checker, but from clarity, explicitness, and good tooling. The language provides several mechanisms that work together to catch errors:

Putting It All Together: A Complete Example

Here is a realistic example that combines several allocator strategies in a game-style update loop, demonstrating how these patterns work together in practice:

package game_demo

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

Game_State :: struct {
    entities:      [dynamic]Entity,
    particle_pool: mem.Pool,
    frame_arena:   mem.Arena,
    track:         mem.Tracking_Allocator,
}

Entity :: struct {
    id:       u64,
    position: [2]f32,
    health:   f32,
}

game_init :: proc(state: ^Game_State) -> bool {
    // Permanent memory tracked for leak detection
    mem.tracking_allocator_init(&state.track, mem.default_allocator)
    
    tracked := mem.tracking_allocator(&state.track)
    
    // Entities dynamic array uses tracked allocator
    state.entities = make([dynamic]Entity, 0, 256, tracked)
    
    // Particle pool for fixed-size particle objects
    mem.pool_init(&state.particle_pool, size_of(Particle), 1024)
    
    return true
}

game_update :: proc(state: ^Game_State) {
    // Save original context allocators
    orig_alloc := context.allocator
    orig_temp := context.temp_allocator
    
    // Set up frame arena
    arena_mem := make([]byte, 4 * 1024 * 1024)
    mem.arena_init(&state.frame_arena, arena_mem)
    defer delete(arena_mem)
    
    frame_alloc := mem.arena_allocator(&state.frame_arena)
    context.allocator = frame_alloc
    context.temp_allocator = frame_alloc
    
    defer {
        context.allocator = orig_alloc
        context.temp_allocator = orig_temp
        mem.arena_reset(&state.frame_arena)
    }
    
    // Temporary scratch computation
    {
        snap := mem.begin_temp()
        defer mem.end_temp(snap)
        
        // Compute spatial hash using temp memory
        hash_map := make(map[u64][]Entity)
        // ... populate and query hash_map
        delete(hash_map)
    }
    
    // Process entities using pool for particles
    pool_alloc := mem.pool_allocator(&state.particle_pool)
    
    for &entity in state.entities {
        // Spawn particle effect from pool
        p, err := mem.alloc(size_of(Particle), pool_alloc)
        if err == nil {
            particle := transmute(^Particle)p
            particle.position = entity.position
            // ... simulate particle
            mem.free(p, pool_alloc)  // return to pool
        }
    }
}

game_shutdown :: proc(state: ^Game_State) {
    mem.pool_destroy(&state.particle_pool)
    delete(state.entities)
    mem.tracking_allocator_destroy(&state.track)
    // Tracking report will show any remaining leaks
}

main :: proc() {
    state: Game_State
    if !game_init(&state) {
        return
    }
    defer game_shutdown(&state)
    
    for frame in 0..<1000 {
        game_update(&state)
    }
    
    fmt.println("Clean shutdown — check for leak reports above.")
}

This example demonstrates the layered memory strategy that Odin encourages: a tracked allocator for persistent game state, a pool allocator for high-frequency particle allocations, an arena for frame-scoped temporary data, and the temp allocator for scratch computations within a single function. Each allocator solves a specific problem, and they coexist without conflict.

Conclusion

Memory management in Odin is not a burden to be feared — it is a toolkit to be mastered. The language strips away the mystery of where and how memory is allocated, replacing it with explicit, composable allocators that give you total control. The tracking allocator transforms memory debugging from a painful post-mortem exercise into a clear, actionable report. The arena allocator eliminates the concept of individual object lifetimes in favor of batch reset semantics that align naturally with how most real-world programs actually work. The temp allocator gives you scratch memory that is automatically cleaned up, combining the convenience of garbage collection with the performance of manual memory management.

By internalizing the patterns in this tutorial — knowing which allocator to reach for, keeping temp memory scoped, composing allocators for debugging, and always pairing allocations with cleanup — you will write Odin code that is fast, predictable, and remarkably free of memory bugs. The discipline Odin requires is not arbitrary ceremony; it is the foundation of the performance and reliability that systems programming demands.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles