Learn Odin
Data-oriented programming with the implicit context allocator, slices, and no exceptions.
What Odin Is: Data-Oriented, No Hidden Costs
A C alternative built for data-oriented programming, with simplicity, no exceptions, and a built-in context.
What Odin Is: Data-Oriented, No Hidden Costs
Odin is a systems programming language created by Ginger Bill (Bill Hall), first released publicly in 2016. It is designed as a modern alternative to C: a small, readable, fast language for people who want full control of the machine without C++'s complexity. Its stated motto is the joy of programming, and its design leans hard into data-oriented programming - thinking about how data is laid out in memory and processed in bulk, rather than wrapping everything in objects.
Odin is AOT compiled (ahead of time, via an LLVM backend), produces native binaries, and has no garbage collector. Like C, you manage memory yourself - but Odin gives you ergonomic tools (an implicit allocator, defer, slices with lengths) that make manual management far less error-prone than raw C.
Hello, world
A complete Odin program. The package is main, and the entry point is proc named main:
package main
import "core:fmt"
main :: proc() {
fmt.println("Hello, Odin!")
}
Two things stand out immediately. Declarations use the name :: value form (:: for compile-time constants, including procedures and types). And there is no #include and no header files - Odin uses a real package/import system, pulling in core:fmt (the formatting package from the standard core library).
Design principles you will feel everywhere
- Simplicity and orthogonality. A handful of consistent rules instead of many special cases. There is one looping keyword (
for), one way to declare things (:=/::), and minimal syntax noise (no semicolons required, no parentheses around conditions). - No hidden control flow, no hidden allocations, no hidden costs. What you write is what runs. This is the same promise Zig makes, and the opposite of C++'s implicit copies and destructors.
- No exceptions. Errors are values you return and handle explicitly. There is no
try/catch, no stack unwinding. We will use multiple return values and explicit error enums instead. - Built-in data-oriented features. Slices, dynamic arrays, and maps are language-level types, not library bolt-ons; and
#soalets you flip a struct-of-arrays layout for cache-friendly bulk processing.
The implicit context: a one-line preview
Odin's single most distinctive feature is the implicit context - a hidden struct passed to every Odin procedure that carries, among other things, the current allocator and logger. You almost never name it, but it is why new, make, and free work without you threading an allocator argument through every call. We will return to this repeatedly; it is the heart of Odin's memory model.
ptr := new(int) // allocates using context.allocator -- no allocator argument!
ptr^ = 42 // ^ dereferences a pointer
fmt.println(ptr^) // 42
free(ptr) // returns it to the same allocator
Reference
- Odin language overview: https://odin-lang.org/docs/overview/
- Odin homepage: https://odin-lang.org/
Next: the type system, declarations, and Odin's compile-time constant syntax.
Types, Declarations, and ::
Fixed-width types, the :: vs := distinction, structs, enums, and distinct types.
Types, Declarations, and ::
Odin has a small, explicit type system with C-style fixed-width integers and a clean, uniform declaration syntax. Once you internalize :: versus :=, the rest follows.
The two colons: constants vs variables
Odin uses a single, consistent declaration grammar built around the colon:
name : Type = value- full form: a variable with an explicit type.name := value- a variable with the type inferred from the value.name :: value- a compile-time constant. Procedures, types, and named constants all use::.
x : int = 10 // explicit type
y := 20 // inferred as int
PI :: 3.14159 // compile-time constant (note :: )
Add :: proc(a, b: int) -> int { return a + b } // a proc is just a constant
Vec2 :: struct { x, y: f32 } // a type is just a constant
That is the whole rule. A procedure is a constant whose value is a procedure; a struct type is a constant whose value is a type. There are no separate func, typedef, or const keywords - one form covers all of it.
Fixed-width numeric types
Like Zig and HolyC (and unlike C's portable-but-vague int/long), Odin's sized types state their width exactly:
| Type | Meaning |
|---|---|
i8 i16 i32 i64 i128 |
signed integers |
u8 u16 u32 u64 u128 |
unsigned integers |
f16 f32 f64 |
floating point |
int / uint |
pointer-sized signed / unsigned |
bool |
boolean (b8 b16 b32 b64 also exist) |
rune |
a Unicode code point (an i32) |
string |
a length-prefixed slice of bytes (UTF-8) |
There is no implicit narrowing: assigning an i64 into an i32 is a compile error unless you cast explicitly. Casts use a leading-type form:
big : i64 = 300
small := i32(big) // explicit conversion
b := cast(u8)small // 'cast(T)x' is the equivalent long form
Strings are not C strings
An Odin string is not a NUL-terminated char*. It is a slice: a pointer plus a length. Getting the length is O(1) (len(s)), and strings can contain NUL bytes. When you need to talk to a C API you convert to a cstring explicitly.
s := "héllo"
fmt.println(len(s)) // 6 -- BYTES, not runes (é is 2 bytes in UTF-8)
c := strings.clone_to_cstring(s) // explicit, and it allocates
Structs, enums, and unions
Color :: enum { Red, Green, Blue } // enum
Player :: struct { // struct
name: string,
health: int,
pos: Vec2,
}
// A tagged union: one of several types, with a known active variant.
Shape :: union { Circle, Rectangle }
Struct literals name their fields, which keeps call sites readable and order-independent:
p := Player{ name = "Aria", health = 100, pos = Vec2{0, 0} }
distinct: nominal types from old ones
distinct makes a brand-new type that shares a representation but is not interchangeable - great for units and IDs you do not want to mix up:
Meters :: distinct f64
Feet :: distinct f64
d : Meters = 5.0
// f : Feet = d // compile error: Meters is not Feet
f : Feet = Feet(d) // must convert deliberately
Reference
- Odin overview (basic types, declarations): https://odin-lang.org/docs/overview/#basic-types
Next: control flow, multiple return values, and how Odin handles errors without exceptions.
Control Flow, Procedures, and Errors as Values
One for loop, multiple return values, and explicit error handling with or_return - no exceptions.
Control Flow, Procedures, and Errors as Values
Odin keeps control flow minimal: there is exactly one loop keyword and no exceptions anywhere in the language. Errors are ordinary return values that you must handle.
One keyword to loop: for
for covers every loop shape - C-style, while-style, infinite, and range:
// C-style
for i := 0; i < 5; i += 1 {
fmt.println(i)
}
// while-style (just the condition)
n := 10
for n > 0 {
n -= 1
}
// infinite
for {
break
}
// range over a slice (index + value), or over a number
xs := []int{10, 20, 30}
for v, i in xs {
fmt.printf("xs[%d] = %d\n", i, v)
}
for i in 0..<5 { // 0,1,2,3,4 (..< is half-open; ..= is inclusive)
fmt.println(i)
}
Conditions need no parentheses, and bodies always need braces. if can carry an init statement, just like for:
if x := compute(); x > 0 {
fmt.println("positive", x)
}
switch does not fall through by default (no break needed), and supports ranges:
switch grade {
case 90..=100: fmt.println("A")
case 80..<90: fmt.println("B")
case: fmt.println("lower") // default
}
Procedures and multiple return values
Procedures can return multiple values - the mechanism Odin uses instead of out-parameters and instead of exceptions:
divmod :: proc(a, b: int) -> (q: int, r: int) {
return a / b, a % b
}
q, r := divmod(17, 5) // q = 3, r = 2
Returns can be named (the q, r above), which doubles as documentation and lets you use a bare return.
Errors are values, not exceptions
There is no try/catch and no panics for ordinary errors. The idiom is to return a value and an error, where the error is usually an enum or a tagged union. The caller must look at it.
Error :: enum { None, DivByZero }
safe_div :: proc(a, b: int) -> (int, Error) {
if b == 0 {
return 0, .DivByZero // '.DivByZero' is shorthand for Error.DivByZero
}
return a / b, .None
}
result, err := safe_div(10, 0)
if err != .None {
fmt.println("failed:", err)
} else {
fmt.println(result)
}
or_return: propagate errors without ceremony
Writing if err != nil { return ... } after every call gets noisy, so Odin provides or_return. When a call returns a trailing error that is non-zero, or_return returns it from the current procedure immediately; otherwise it yields the remaining value(s). This is Odin's answer to Zig's try and Rust's ?:
load :: proc(path: string) -> (Config, Error) {
data := read_file(path) or_return // on error, return it up the stack
cfg := parse(data) or_return // same here
return cfg, .None
}
There is also or_else, which supplies a fallback value when the error is set:
port := parse_int(s) or_else 8080 // use 8080 if parsing failed
Because errors are plain values and or_return/or_else are explicit, every error path is visible in the source - no invisible unwinding, consistent with Odin's "no hidden control flow" principle.
Reference
- Odin overview (control flow, procedures): https://odin-lang.org/docs/overview/#control-flow-statements
- Odin overview (or_return / or_else): https://odin-lang.org/docs/overview/#or_return-operator
Next: the memory model - the implicit context allocator, new/make/free, and defer.
Memory: The Implicit Context Allocator and defer
How new/make/free route through context.allocator, paired with defer for leak-free manual cleanup.
Memory: The Implicit Context Allocator and defer
This is the lesson that defines Odin's place among systems languages. Odin has no garbage collector - memory is manual, like C, Hare, and Forth. But Odin's distinctive twist is that allocations route through an implicit context allocator, and cleanup is paired with defer so you rarely leak.
Allocating: new, make, free, delete
Odin gives you four core memory built-ins:
new(T)- allocate oneTon the heap, return a^T(pointer to T).make(T, n)- allocate a slice, dynamic array, or map withnelements/capacity.free(ptr)- release whatnewreturned.delete(x)- release whatmakereturned (a slice, dynamic array, map, or string).
import "core:fmt"
main :: proc() {
p := new(int) // one int on the heap -> p is ^int
p^ = 42
fmt.println(p^)
free(p) // pair new with free
xs := make([]int, 3) // a slice of 3 ints (zeroed)
xs[0] = 10
fmt.println(xs)
delete(xs) // pair make with delete
}
Notice what is not there: no allocator argument. So where does the memory come from?
The implicit context
Every Odin procedure receives a hidden parameter called context - a struct that carries the current allocator, a temp_allocator, a logger, and more. The memory built-ins use context.allocator by default. That is why new, make, free, and delete "just work" without you passing an allocator through every layer of the call tree.
// These two lines are equivalent. The allocator is implicit by default.
p := new(int)
p := new(int, context.allocator)
Compare the contrast with Zig, where there is no implicit allocator - you must thread one explicitly through every function that allocates:
// Zig: the allocator is an explicit argument, always visible, never hidden.
const buf = try allocator.alloc(u8, 1024);
defer allocator.free(buf);
Odin makes the opposite ergonomic choice: convenient defaults via context, with the ability to override when you want control. Each design is "no hidden allocations" in spirit - Zig by making the allocator a parameter, Odin by making it a single, swappable, inspectable field on a documented struct.
defer: cleanup next to acquisition
defer schedules a statement to run when the enclosing scope exits, in reverse order of registration. You write the free/delete immediately after the allocation, and it runs no matter which path leaves the scope - the same idea as Zig and Hare's defer, and the explicit-statement cousin of C++'s RAII.
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 run
}
// ... use buf and p ...
} // here: free(p) first, then delete(buf)
Because the cleanup sits right next to the acquisition, early returns and error paths cannot accidentally skip it. This is the everyday Odin pattern: make/new on one line, defer delete/defer free on the next.
Swapping the allocator
context.allocator is just a field, so you can replace it for a block of code, and every new/make/free inside picks up the new strategy automatically - even calls deep inside library procedures you did not write. This is the payoff of an implicit context.
import "core:mem"
main :: proc() {
arena_buf := make([]u8, 1 * mem.Megabyte)
defer delete(arena_buf)
arena: mem.Arena
mem.arena_init(&arena, arena_buf)
// Swap the allocator just for this scope.
context.allocator = mem.arena_allocator(&arena)
a := new(int) // comes from the arena, not the general heap
b := make([]int, 50) // also from the arena
// No per-object free needed: one reset reclaims it all.
free_all(context.allocator) // frees a and b at once
}
This whole-region reclaim mirrors the arena/per-task idea found in other languages - but in Odin it is opt-in per scope, without rewriting any allocation call.
Reference
- Odin overview (allocators / context): https://odin-lang.org/docs/overview/#implicit-context-system
- Odin
core:mempackage: https://pkg.odin-lang.org/core/mem/
Next: slices, dynamic arrays, the temp allocator, and a data-oriented finish with #soa.
Slices, Dynamic Arrays, and the Temp Allocator
Bounds-checked slices, growable [dynamic] arrays, and context.temp_allocator cleared with free_all.
Slices, Dynamic Arrays, and the Temp Allocator
Odin's collection types are part of the language, not library add-ons, and they cooperate directly with the context allocator from the last lesson. Understanding slices, dynamic arrays, and the temp allocator completes the practical memory picture.
Fixed arrays vs slices
A fixed array [N]T has its length baked into the type and lives wherever you put it (stack, struct, etc.). A slice []T is a view: a pointer plus a length, with no ownership implied. Slicing an array or another slice is cheap and bounds-checked.
arr := [5]int{10, 20, 30, 40, 50} // fixed array, length is part of the type
s := arr[1:4] // slice: a view of {20, 30, 40}
fmt.println(len(s)) // 3
fmt.println(s[0]) // 20 -- bounds-checked at runtime in debug
Because a slice carries its length, there is no separate "pass the length too" parameter the way C requires - and out-of-bounds access is caught rather than silently corrupting memory like C or HolyC.
Dynamic arrays: [dynamic]T
A [dynamic]T is a growable array (like C++'s std::vector or a Rust Vec). It allocates from context.allocator, grows automatically with append, and must be released with delete:
nums: [dynamic]int // starts empty, uses context.allocator
defer delete(nums) // pair with delete
append(&nums, 1)
append(&nums, 2, 3, 4) // append takes one or many
fmt.println(nums) // [1, 2, 3, 4]
fmt.println(len(nums), cap(nums))
Maps work the same way and are also language-level:
ages: map[string]int
defer delete(ages)
ages["Aria"] = 30
if v, ok := ages["Aria"]; ok { // the comma-ok form tells you if the key existed
fmt.println(v)
}
The temp allocator: scratch memory you do not free piece by piece
The context also carries a temp_allocator - a per-thread arena meant for short-lived scratch allocations (formatting a string, building a temporary list inside one frame or one request). You allocate into it freely and then reclaim everything at once with free_all, instead of matching each allocation with a delete.
import "core:fmt"
build_message :: proc(name: string, n: int) -> string {
// Allocate the formatted string from the temp allocator, not the heap.
return fmt.aprintf("Hello %s, you have %d messages",
name, n, allocator = context.temp_allocator)
}
main :: proc() {
// Per "frame": clear the scratch arena when the frame ends.
defer free_all(context.temp_allocator)
msg := build_message("Aria", 3) // lives in temp memory
fmt.println(msg)
// No individual delete(msg): free_all reclaims it.
}
This is the same coarse, free-it-all-at-once strategy that HolyC gets implicitly from per-task heaps and that you build by hand in C with an arena - but in Odin it is a standard part of every context. The discipline: anything you put in temp memory must be done with before the next free_all.
A data-oriented finish: #soa
Odin's data-oriented streak shows in #soa, which transposes a struct-of-arrays automatically. Logically you still index entities[i].x, but in memory all the xs are contiguous, then all the ys - cache-friendly for bulk processing, with no manual restructuring:
Entity :: struct { x, y, vx, vy: f32 }
// Struct-of-arrays layout: x's together, y's together, ...
entities: #soa[dynamic]Entity
defer delete_soa(entities)
append_soa(&entities, Entity{0, 0, 1, 1})
// Indexed like an array of structs, stored like arrays of fields.
entities[0].x += entities[0].vx
Putting it together
You now have Odin end to end: the data-oriented philosophy with no hidden costs; the ::/:= declaration system and fixed-width types; one for loop and errors-as-values with or_return; and the memory model that defines it - manual, GC-free allocation routed through an implicit context allocator, paired with defer for leak-free cleanup, with the temp_allocator and swappable arenas for whole-region reclaim. Convenient defaults, full control when you want it, and nothing happening behind your back.
Reference
- Odin overview (slices, dynamic arrays, maps): https://odin-lang.org/docs/overview/#slices
- Odin overview (temp allocator / context): https://odin-lang.org/docs/overview/#implicit-context-system
- Odin
#soadata types: https://odin-lang.org/docs/overview/#soa-data-types