Data and Memory Management in Odin
Odin is a language that gives you control over memory without the usual pitfalls of manual management. This means that you must handle allocation and deallocation explicitly, which might seem daunting if you are used to languages with garbage collection. However, Odin makes it manageable through its implicit context system.
The implicit context stores two allocators: context.allocator and context.temp_allocator. Both can be reassigned to any kind of allocator, but each serves a distinct purpose.
- The
context.allocatoris used for data that needs to persist beyond a temporary operation within a subsystem. - The
context.temp_allocatoris used for temporary data that only needs to exist during a single operation, and can be bulk-freed at a natural boundary usingfree_all(context.temp_allocator).
For example, in a web server, you might use context.temp_allocator for data that only needs to exist during the handling of a single request, and context.allocator for data that needs to persist across multiple requests or throughout the lifetime of the server.
Odin takes care of creating the allocator values and passing them through your call stack implicitly. However, you can create your own custom allocator and assign them to context.allocator or context.temp_allocator as needed. This allows you to have different allocators for different subsystems or scopes within your program.
Most Odin code does not involve anything more complicated than deciding which of these two places a piece of data belongs in. That knowledge is enough to start working with heap/dynamic memory allocation in Odin.
In this article, you will understand:
- fixed arrays, dynamic arrays, and slices,
- structs and strings,
- how to allocate and clean up memory using
make,delete, anddefer, - and how the context system manages your allocators implicitly.
Understanding The Default Allocators
Here is the mental model that makes Odinβs memory management manageable.
Think of your program as an office with two places to put things.
The first is a filing cabinet β structured, long-term storage for data that needs to survive beyond a single task. You are responsible for clearing it out when you are done. This is context.allocator. Pair every allocation with a defer delete() placed immediately below it, so that cleanup is always visible next to creation.
The second is a desktop bin β a scratch surface for temporary work. You toss things there during a task without tracking them individually. At the end of a request cycle or frame, the entire bin is emptied at once. You do not need to remember what you put there. This is context.temp_allocator.
Most Odin code does not go beyond deciding which of these two a piece of data belongs in. That is the whole mental model.
Memory in Practice
We are going to start with a simple example that does stack and heap allocation.
Create a file named memory.odin and paste in the following code:
package main
import "core:fmt"
User :: struct {
id: int,
name: string,
}
main :: proc() {
// 1. Fixed array β stored directly in CPU local memory
fixed_scores: [3]int = {90, 85, 95}
// 2. Slice β a view into a memory region
score_view: []int = fixed_scores[0:2]
// 3. Dynamic array β resizable, heap-backed collection
users := make([dynamic]User, context.allocator)
defer delete(users)
append(&users, User{id = 1, name = "Alice"})
append(&users, User{2, "Bob"}) // struct literals can omit field names if order is correct
// 4. Temporary scratch buffer
scratch_buffer := make([]u8, 512, context.temp_allocator)
fmt.println("Fixed Scores:", fixed_scores)
fmt.println("Slice view:", score_view)
fmt.println("Dynamic users:", users)
fmt.printf("Scratch buffer size: %v bytes\n", len(scratch_buffer))
}
Run it with:
odin run memory.odin -file
The -file tells Odin to compile only the specified file. You used this because your directory now contains multiple files with their own main :: proc() blocks, and you want to run just this one.
Running this code will print:
Fixed Scores: [90, 85, 95]
Score View: [90, 85]
Dynamic users: [User{id = 1, name = "Alice"}, User{id = 2, name = "Bob"}]
Scratch buffer size: 512 bytes
Given that the code runs and produces the expected output, the next sections will go into detail about what each part of the code does, how memory is managed, and how the context system works to make it all manageable.
Allocation and Local Lifetimes
The most important pattern in the example is this:
users := make([dynamic]User, context.allocator)
defer delete(users)
make() and delete() are the closest Odin has to constructors and destructors in class-based languages. The difference is that you are responsible for calling delete() yourself. make() creates heap-allocated collections β dynamic arrays, slices, and hash maps β using a specific allocator.
Releasing memory has to happen at the right time. In our example, that means at the end of the main :: proc() scope. Although it could just as easily be at the end of a request/response cycle. This is where defer comes in.
A
deferstatement schedules execution of a statement for when the current scope exits.
The defer + delete() statement schedules the cleanup to run automatically when the current block exits.
The combination of make() and defer delete() keeps allocation and cleanup side by side in the code, making the intended lifetime of the data visible at a glance.
The Context System
Odin automatically passes an implicit parameter named context through every procedure call β except when the procedure is marked as βcontextlessβ. You do not declare it, and you do not pass it manually through your call stack. It is simply there.
The purpose of the implicit context system is to allow you to intercept third-party code and modify its behaviour. One such case is modifying how a library does allocation or logging. As you have seen, we used it to specify the allocators used in the make() procedure.
Its values are defined in the Context struct, which is currently defined as:
Context :: struct {
allocator: Allocator,
temp_allocator: Allocator,
// ...and a few other fields for logging, random generation, and internal use
}
It is safe to override the default values β for example, you can swap in a custom allocator for the allocator or temp_allocator within a specific scope without affecting the rest of your program.
We will not go deeper on the context system, because the defaults are enough for anyone coming from a garbage-collected language.
Arrays and Slices
The example used three kinds of collection which weβre going to explore next. Starting with fixed-size arrays.
Fixed Arrays
A fixed-size array is an array whose size is known at compile-time. They are stored contiguously on the stack and do not grow or shrink. The fixed_scores variable in the example is a fixed-size array, declared as:
fixed_scores: [3]int = {90, 85, 95}
Odinβs fixed size array supports array programming, matrix types, and swizzle operation. For example, you can sum two arrays without explicit loop and add statements:
package main
import "core:fmt"
Vector4 :: [4]f32
main :: proc() {
a := Vector4{1, 2, 3, 4}
b := Vector4{10, 20, 30, 40}
// Without array programming β manual loops
sum_loop: Vector4
for i in 0..<4 {
sum_loop[i] = a[i] + b[i]
}
fmt.println("loop: a + b =", sum_loop)
// With array programming
sum := a + b
fmt.println("array: a + b =", sum)
}
Slices
The score_view variable in the earlier example is a Slice.
score_view: []int = fixed_scores[0:2]
A slice looks similar to an array, and the confusion compounds if you come from a language that uses similar syntax to represent an array. However, a slice does not store data, and its length is not known at compile time. It is rather a reference to a region of existing memory: a pointer to the underlying data and a length.
We can therefore describe score_view as a slice with elements of type int.
The value of a slice is formed by specifying two indices β a low and high bound β separated by a colon. The bounds include the lower element but exclude the higher element, as in data[offset:offset+length]. Therefore, fixed_scores[0:2] gives us {90, 85}, out of {90, 85, 95}.
Dynamic Arrays
A dynamic array is a resizable array allocated on the heap. It uses either the current implicit context allocator or that set explicitly. Your earlier example specified it explicitly, just to make it obvious that allocation happens behind-the-scene:
users := make([dynamic]User, context.allocator)
As you append() elements, the array expands its backing memory automatically when needed. But the growth behaviour is managed for you. This gives you the convenience of a resizable collection while keeping the cost of allocation visible and traceable.
You can remove items from a dynamic array using any of these built-in procedures:
- pop: removes the last element of the array
- unordered_remove: removes an element at a specific index. Unordered means it is O(1), since it swaps the last element into the removed position.
- ordered_remove: removes an element at a specific index. Ordered means it will move all elements after the index downwards with a copy, ensuring elements remain in the same order.
You can read more about various operations that can be done on dynamic arrays and slices in the documentation.
Structs and Strings
You used two other types β struct and string β which I will briefly cover before moving to the next article.
Structs
A struct is a data structure that groups multiple values/fields together, under a single name. For example:
User :: struct {
id: int,
name: string,
}
Coming from a class-based language, you would assume it is equivalent to a class.
Itβs not.
Structs are flat data layouts where fields are stored directly in memory, in a predictable order. There are no hidden runtime objects, no vtables, and no inheritance.
Strings
Odin strings are immutable, utf-8 encoded bytes. Like slices, they are internally a pointer and a length β no null terminator required to find the end, unlike in C.
When you need procedures to perform various operations on string, look at the core:strings library.
Mini Recap
The major lesson in this article was the implicit context system which helps with memory management, and how to work with arrays and slices. Keep this in mind as you continue to the next articles and lessons:
- Fixed arrays have a compile-time size and live in stack memory.
- Slices are views into existing memory region β a pointer and a length. They can also be created using
make. - Dynamic arrays are resizable. You create them with
makeand delete them withdelete. deferschedules execution at the end of the current block, not the end of the procedure.context.allocatoris for data persistent object lifetimes.context.temp_allocatoris for short-lived work.- Odin strings are immutable, length-aware, and utf-8 encoded.
The Next Step
With data lifetimes under control, you no longer need to worry about memory management. But managing data is only half the work. You still need to express logic clearly.
In the next part, you will see how Odin handles control flow: expressive switches, clean loops, and error handling without the repetitive boilerplate.