Systems programming has a problem that no one wants to admit: the tooling got too clever. C++ grew operator overloading, exceptions, template metaprogramming, and virtual dispatch until reading a line of code required three layers of indirection to understand what actually happens at the machine level. Rust added safety guarantees that genuinely matter, but brought lifetimes, the borrow checker, and a complexity budget that many programmers find exhausting for projects that just need fast, simple, predictable code. Go went the other way — safe and simple but garbage-collected and not suitable for low-level systems work.
Odin is a different answer. Created by Bill Hall (known as gingerBill) starting around 2016, Odin is a compiled, statically-typed, manually-managed systems language that asks a straightforward question: what if we built a better C, instead of building a safer C++? It doesn’t try to prevent every class of bug at compile time. It doesn’t introduce a borrow checker. It introduces a handful of genuinely novel ideas — the context system, first-class SOA types, built-in allocator abstractions — and then gets out of your way.
This post is for programmers who already know at least one of C, C++, Go, or Zig. It’s not a beginner’s language tutorial. It’s a technical examination of what Odin actually offers, what trade-offs it makes, and where it fits.
What Odin Is and Its Philosophy
Odin’s design philosophy is stated plainly in its documentation: “Odin is a general-purpose programming language with distinct typing, designed for high performance, modern systems, and data-oriented programming.” The phrase “data-oriented” is load-bearing. It’s not marketing — it shapes concrete language decisions.
Data-oriented design (DOD) is the observation that modern CPU performance is dominated by cache behavior, not raw instruction throughput. A loop that processes 10,000 game entities is fast if those entities’ relevant fields are stored contiguously in memory and slow if each entity is a heap-allocated object with pointer-chased fields scattered across RAM. Object-oriented programming tends to produce the latter: objects with methods, polymorphism through vtable pointers, encapsulation through private fields. The object model is great for organizing human thought but terrible for organizing data for CPU caches.
Odin’s response is not to ban abstractions but to make data layout explicit and to refuse to hide costs behind syntactic sugar. The language has no:
- Operator overloading —
a + b always means built-in addition, never a hidden function call
- Implicit conversions — you cannot accidentally pass an
i32 where an i64 is expected
- Exceptions — errors are values returned from functions, never thrown and caught through the call stack
- Hidden allocations — no string concatenation allocates behind your back, no function call silently heap-allocates
- Constructors or destructors — memory is managed explicitly through allocators and
defer
- Implicit
this or self — procedures are not methods on types (though you can group them by naming convention)
This is not Odin being lazy or incomplete. These are deliberate omissions. Bill Hall’s position, stated explicitly in talks and documentation, is that these features harm code because they make it impossible to read a line of code and know what it does without understanding the entire type hierarchy around it. In Odin, what you see is what executes.
Odin vs. C vs. Zig vs. Rust
The honest comparison:
C is the baseline. C is simple, fast, portable, and has 50 years of ecosystem. It also has no namespaces, no generics, preprocessor macros as the only abstraction mechanism, and memory safety that relies entirely on programmer discipline. Odin looks like a clean C with real packages, parametric polymorphism, built-in allocator abstractions, and a syntax that doesn’t require you to fight the preprocessor.
Zig shares many of Odin’s goals (explicit allocators, no hidden control flow, C interop) but takes a different approach. Zig uses comptime — compile-time execution — as a universal mechanism for generics, conditional compilation, and metaprogramming. Zig’s error handling uses explicit error union types with try. Zig’s allocators are passed as explicit parameters to every function that allocates. Odin’s allocator system is more ergonomic (implicit via the context) but less explicit. Which you prefer is largely a matter of taste: Zig is more uniform but more verbose; Odin is more ergonomic but requires understanding the context system.
Rust has a different goal. Rust wants to eliminate memory safety bugs at compile time. The borrow checker enforces ownership rules that prevent use-after-free, data races, and dangling pointers without a garbage collector. The cost is a steep learning curve and constraints that sometimes require restructuring code that would be trivially simple in C. If memory safety guarantees are your primary requirement, Rust is worth the complexity. If you’re willing to be responsible for your own memory (as in C) but want better language ergonomics, Odin is a lighter-weight option.
Current State and Who Uses It
As of 2026, Odin is pre-1.0. The language specification is not finalized. The compiler (odin, written in C and self-bootstrapping) is actively developed. Breaking changes happen, though they’ve become less frequent as the language matures. The monthly releases (dev-2024-XX) are stable enough for production use in practice, but there’s no stability guarantee comparable to Go 1.x or Rust’s edition system.
The most prominent real-world user is Orca, the audio plugin host and DAW environment created by Ableton collaborator and musician developer team. Orca’s core is written entirely in Odin, which demonstrates that Odin can handle a complex, performance-critical, production application. The game development community uses Odin for indie games and tools, particularly for projects that want the control of C with less friction. The language ships with vendor bindings for raylib, SDL2, OpenGL, Vulkan, and several other game-relevant libraries.
Core Language
Odin’s syntax is clearly Go-inspired: := for inferred declaration, package declarations, import statements, proc for procedure definitions. But the semantics differ in important ways, and there are features Go doesn’t have.
Basic Syntax
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
package main
import "core:fmt"
import "core:os"
main :: proc() {
// Type-inferred variable declaration
name := "Odin"
version: int = 2024
// Explicit type annotation
x: f32 = 3.14
// Multiple assignment
a, b := 1, 2
// Constants use :: (same as procedure and type declarations)
MAX_SIZE :: 1024
PI :: 3.14159265358979
fmt.println("Hello from", name, version)
fmt.printf("x = %.2f, a = %d, b = %d\n", x, a, b)
_ = MAX_SIZE // suppress unused warning
_ = PI
}
|
The :: syntax is a uniform declaration syntax for anything that is “compile-time known”: constants, procedures, types, and package aliases. Variables that can change at runtime use := (inferred) or : type = (explicit).
Built-In Types
Odin has explicit-width integer types that make data layout unambiguous:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
package main
import "core:fmt"
type_tour :: proc() {
// Integers — explicit width
a: i8 = -128
b: i16 = 32767
c: i32 = 2_147_483_647
d: i64 = 9_223_372_036_854_775_807
e: u8 = 255
f: u16 = 65535
g: u32 = 4_294_967_295
h: u64 = 18_446_744_073_709_551_615
// Architecture-width (like C's int/size_t)
i: int = 42 // platform-native signed int
j: uint = 42 // platform-native unsigned int
k: uintptr = 0 // pointer-sized unsigned int
// Floating point
p: f16 = 1.5
q: f32 = 3.14
r: f64 = 3.14159265358979
// Boolean
flag: bool = true
// Rune (Unicode code point, 32-bit)
ch: rune = 'A'
// String — immutable, length-prefixed, valid UTF-8
s: string = "Hello, 世界"
// Raw string (no escape processing)
raw := `no \n escapes here`
// Byte string (mutable slice of bytes)
bs: []u8 = transmute([]u8)s // reinterpret string as []u8
fmt.println(a, b, c, d, e, f, g, h, i, j, k, p, q, r, flag, ch, s, raw, len(bs))
}
|
Unlike C, Odin’s int is always either 32 or 64 bits depending on the target architecture, and its width is always knowable. There are no long long ambiguities.
Multiple Return Values and Named Returns
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
package main
import (
"core:fmt"
"core:strconv"
)
// Multiple return values
divide :: proc(a, b: f64) -> (f64, bool) {
if b == 0 {
return 0, false
}
return a / b, true
}
// Named return values — useful for documentation and defer
parse_int_range :: proc(s: string, lo, hi: int) -> (value: int, ok: bool) {
v, parse_ok := strconv.parse_int(s, 10)
if !parse_ok {
return // returns zero values for named returns
}
if v < lo || v > hi {
return
}
value = v
ok = true
return
}
// Named returns let defer reference the return value
safe_open :: proc(path: string) -> (data: []u8, err: string) {
// In real code you'd use os.read_entire_file_or_err
// This demonstrates the named return pattern
if len(path) == 0 {
err = "empty path"
return
}
// ... actual file reading ...
return
}
main :: proc() {
result, ok := divide(10, 3)
if ok {
fmt.printf("10 / 3 = %.4f\n", result)
}
val, ok2 := parse_int_range("42", 0, 100)
fmt.println("parsed:", val, "ok:", ok2)
_, ok3 := parse_int_range("200", 0, 100)
fmt.println("out of range:", ok3)
}
|
Defer
defer evaluates its expression when the enclosing procedure returns. Unlike Go, Odin’s defer can appear in any block and defers to the end of that block (not just the procedure). This makes resource management precise.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
package main
import (
"core:fmt"
"core:mem"
"core:os"
)
process_file :: proc(path: string) -> bool {
fd, err := os.open(path)
if err != os.ERROR_NONE {
fmt.eprintln("failed to open:", path)
return false
}
defer os.close(fd) // guaranteed close, even on early return
// Arena scoped to this function
arena: mem.Arena
mem.arena_init(&arena, make([]u8, 1024 * 1024)) // 1MB
defer mem.arena_destroy(&arena)
// Temp allocations in the arena
arena_alloc := mem.arena_allocator(&arena)
buf := make([]u8, 4096, arena_alloc)
n, read_err := os.read(fd, buf)
if read_err != os.ERROR_NONE {
return false
}
content := string(buf[:n])
fmt.println("read", n, "bytes:", content[:min(n, 40)])
return true
}
// Defer in inner blocks
block_defer_demo :: proc() {
fmt.println("start")
{
defer fmt.println("inner block end") // runs when inner block exits
fmt.println("inside inner block")
}
fmt.println("after inner block") // inner defer already ran here
defer fmt.println("procedure end")
fmt.println("end of procedure body")
}
main :: proc() {
block_defer_demo()
}
|
Output:
start
inside inner block
inner block end
after inner block
end of procedure body
procedure end
When — Compile-Time Conditionals
when is like #if in C but cleaner. It evaluates at compile time and does not produce dead code. The else branch is entirely eliminated from the binary.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
package main
import "core:fmt"
import "core:intrinsics"
ODIN_OS :: #config(ODIN_OS, "linux")
platform_init :: proc() {
when ODIN_OS == "windows" {
fmt.println("Windows platform init")
// windows-specific code
} else when ODIN_OS == "darwin" {
fmt.println("macOS platform init")
} else {
fmt.println("Linux platform init")
}
}
// when for architecture-dependent types
Pointer_Size :: when size_of(rawptr) == 8 { 64 } else { 32 }
// when based on compiler constants
debug_log :: proc(msg: string) {
when ODIN_DEBUG {
fmt.println("[DEBUG]", msg)
}
// in release builds, this entire call is eliminated
}
// when for conditional struct fields
Config :: struct {
width: int,
height: int,
when ODIN_DEBUG {
debug_name: string, // only exists in debug builds
}
}
main :: proc() {
platform_init()
debug_log("startup complete")
fmt.printf("pointer size: %d bits\n", Pointer_Size)
}
|
#partial switch
By default, Odin’s switch on a union or enum must be exhaustive (or have a default). #partial removes that requirement, letting you handle only the cases you care about:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
package main
import "core:fmt"
Error :: union {
Network_Error,
Parse_Error,
IO_Error,
}
Network_Error :: struct { code: int, message: string }
Parse_Error :: struct { line: int, column: int }
IO_Error :: struct { path: string }
handle_error :: proc(err: Error) {
// Must handle all variants or compiler error
switch e in err {
case Network_Error:
fmt.printf("network error %d: %s\n", e.code, e.message)
case Parse_Error:
fmt.printf("parse error at %d:%d\n", e.line, e.column)
case IO_Error:
fmt.printf("io error: %s\n", e.path)
}
}
// Only handle what we care about
log_network_errors :: proc(err: Error) {
#partial switch e in err {
case Network_Error:
fmt.printf("NETWORK: %s\n", e.message)
// IO_Error and Parse_Error silently ignored
}
}
// Switch on enum
Direction :: enum { North, South, East, West }
move :: proc(dir: Direction) {
switch dir {
case .North: fmt.println("moving north")
case .South: fmt.println("moving south")
case .East: fmt.println("moving east")
case .West: fmt.println("moving west")
}
}
// #partial enum switch
is_horizontal :: proc(dir: Direction) -> bool {
#partial switch dir {
case .East, .West: return true
}
return false
}
main :: proc() {
err: Error = Network_Error{code = 404, message = "not found"}
handle_error(err)
log_network_errors(err)
move(.North)
fmt.println(is_horizontal(.East), is_horizontal(.North))
}
|
or_return, or_else, or_break, or_continue
These are the most ergonomic part of Odin’s error handling. They eliminate the boilerplate of checking a boolean or nil after every operation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
package main
import (
"core:fmt"
"core:os"
"core:strconv"
"core:strings"
)
// or_return: if the second return value is false/nil/0, return it immediately
// Equivalent to Go's "if err != nil { return ..., err }"
read_config :: proc(path: string) -> (config: map[string]string, ok: bool) {
data := os.read_entire_file(path) or_return
// If os.read_entire_file returns (nil, false), we return (nil, false) immediately
config = make(map[string]string)
lines := strings.split(string(data), "\n")
for line in lines {
line = strings.trim_space(line)
if len(line) == 0 || line[0] == '#' do continue
idx := strings.index(line, "=")
if idx < 0 do continue
key := strings.trim_space(line[:idx])
value := strings.trim_space(line[idx+1:])
config[key] = value
}
ok = true
return
}
// or_else: provide a default value if the expression fails
parse_port :: proc(s: string) -> int {
port, _ := strconv.parse_int(s, 10)
// In practice: strconv.parse_int(s, 10) or_else 8080
_ = port
return strconv.parse_int(s, 10) or_else 8080
}
// or_break and or_continue for loop control
process_lines :: proc(lines: []string) {
for line, i in lines {
// If parse fails, continue to next line
val := strconv.parse_int(line, 10) or_continue
fmt.printf("line %d: %d\n", i, val)
}
}
// Chaining or_return for deep call stacks
Pipeline :: struct { data: []u8 }
load_pipeline :: proc(path: string) -> (p: Pipeline, ok: bool) {
raw := os.read_entire_file(path) or_return
// Multiple operations can chain
p.data = raw
ok = true
return
}
main :: proc() {
// or_else demo
fmt.println("port:", parse_port("9090"))
fmt.println("default port:", parse_port("not-a-number"))
// or_continue demo
lines := []string{"1", "bad", "3", "also-bad", "5"}
process_lines(lines)
}
|
The Context System
The context system is Odin’s most distinctive feature. It solves a problem that plagues large C codebases: how do you thread state (allocators, loggers, cancellation, user data) through deep call stacks without explicit parameters everywhere?
What the Context Is
Every Odin procedure has an implicit context parameter of type runtime.Context. You don’t declare it; it’s automatically threaded through all calls. The runtime.Context struct contains:
1
2
3
4
5
6
7
8
9
10
11
12
|
// From core:runtime (simplified)
Context :: struct {
allocator: Allocator,
temp_allocator: Allocator,
assertion_failure_proc: proc(prefix, message: string, loc: Source_Code_Location),
logger: Logger,
random_generator: Random_Generator,
user_index: int,
user_ptr: rawptr,
user_data: any,
// ... internal fields
}
|
Every call to new(T), make([]T, n), fmt.println, logging functions, and anything that needs an allocator or logger reaches into context implicitly. You don’t pass the allocator — it’s already there.
How It Works
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
package main
import (
"core:fmt"
"core:mem"
"core:log"
)
// This procedure allocates — but takes no allocator parameter
build_greeting :: proc(name: string) -> string {
// strings.concatenate uses context.allocator internally
// No allocator parameter needed
return fmt.aprintf("Hello, %s!", name)
// fmt.aprintf allocates a string using context.allocator
}
// You can read context anywhere
log_context_info :: proc() {
// context is always in scope
log.debugf("allocator: %v", context.allocator)
log.debugf("user_index: %d", context.user_index)
}
// Override context for a scope
use_arena_for_this_scope :: proc() {
arena: mem.Arena
backing := make([]u8, 4 * mem.Megabyte)
mem.arena_init(&arena, backing)
defer {
mem.arena_destroy(&arena)
delete(backing)
}
// Override context.allocator for this scope and everything called from here
context.allocator = mem.arena_allocator(&arena)
// Now all allocations in this scope use the arena
s := build_greeting("Odin") // uses arena
nums := make([]int, 100) // uses arena
m := make(map[string]int) // uses arena
fmt.println(s)
fmt.println("allocated", len(nums), "ints and a map")
_ = m
// When this scope exits, the arena is destroyed and ALL allocations
// are freed at once — no individual frees needed
}
// Context passed explicitly when needed
custom_work :: proc(count: int, ctx: runtime.Context) {
// You can pass context explicitly to run code with a specific context
// This is unusual — typically you just let the implicit threading handle it
context = ctx
for i in 0..<count {
_ = make([]u8, 64) // uses ctx.allocator
}
}
main :: proc() {
fmt.println(build_greeting("world"))
use_arena_for_this_scope()
}
|
Why This Is Elegant
Compare the three approaches to allocator dependency injection:
C approach: global malloc/free or pass allocator pointers everywhere manually.
1
2
3
4
5
6
7
8
9
|
// C: allocator passed explicitly everywhere or buried in global state
char* build_greeting(const char* name, Allocator* alloc) {
size_t len = strlen("Hello, ") + strlen(name) + 2;
char* result = alloc->malloc(alloc->ctx, len);
snprintf(result, len, "Hello, %s!", name);
return result;
}
// Every function in a deep call stack needs the allocator parameter
// Or you use malloc globally and lose allocation control
|
Zig approach: explicit allocator parameter on every allocating function.
1
2
3
4
5
6
|
// Zig: explicit allocator parameter, every allocating function requires it
fn buildGreeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
return std.fmt.allocPrint(allocator, "Hello, {s}!", .{name});
}
// Clean but verbose — every call site must pass the allocator
// Changing the allocator requires updating every call site
|
Odin approach: allocator in context, override locally.
1
2
3
4
5
6
7
8
9
|
// Odin: no allocator parameter, override context once at the call site boundary
build_greeting :: proc(name: string) -> string {
return fmt.aprintf("Hello, %s!", name)
// Uses whatever allocator is in context
}
// At the boundary where you want a different allocator:
context.allocator = my_arena_allocator
result := build_greeting("world") // uses arena, no parameter change needed
|
The Zig approach is more explicit and traceable (you can always see what allocator is used). The Odin approach is more ergonomic — switching allocators for an entire subsystem requires one line, not updating every function signature in the chain.
Context for Logging
The same mechanism applies to logging. Odin’s core:log reads context.logger:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
package main
import (
"core:fmt"
"core:log"
"core:os"
)
// Custom logger setup
setup_logging :: proc() -> log.Logger {
return log.create_file_logger(os.stderr, log.Level.Info)
}
deep_function :: proc() {
// No logger parameter — uses context.logger
log.info("deep_function called")
log.debugf("processing %d items", 42)
}
subsystem_with_custom_logger :: proc(logger: log.Logger) {
context.logger = logger
// All log calls in this scope and callees use this logger
deep_function()
log.warn("something noteworthy in subsystem")
}
main :: proc() {
logger := setup_logging()
defer log.destroy_logger(logger)
context.logger = logger
log.info("application starting")
deep_function()
// Override logger for specific subsystem
subsystem_logger := log.create_multi_logger(logger,
log.create_console_logger(log.Level.Warning))
defer log.destroy_logger(subsystem_logger)
subsystem_with_custom_logger(subsystem_logger)
}
|
User Data in Context
The context.user_ptr, context.user_index, and context.user_data fields let you thread arbitrary application state through the implicit context — useful for things like request IDs in a web server, or player index in a game:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
package main
import "core:fmt"
Request_Context :: struct {
request_id: u64,
user_id: u64,
trace_id: string,
}
handle_request :: proc(req_id: u64) {
rc := Request_Context{
request_id = req_id,
user_id = 12345,
trace_id = "abc-123",
}
context.user_ptr = &rc
process_auth()
process_business_logic()
write_response()
}
get_request_ctx :: proc() -> ^Request_Context {
return cast(^Request_Context)context.user_ptr
}
process_auth :: proc() {
rc := get_request_ctx()
fmt.printf("[req:%d] authenticating user %d\n", rc.request_id, rc.user_id)
}
process_business_logic :: proc() {
rc := get_request_ctx()
fmt.printf("[req:%d] processing with trace %s\n", rc.request_id, rc.trace_id)
}
write_response :: proc() {
rc := get_request_ctx()
fmt.printf("[req:%d] writing response\n", rc.request_id)
}
main :: proc() {
handle_request(1001)
handle_request(1002)
}
|
Explicit Allocators
Odin’s allocator system is one of its most practical features for performance-critical code. Manual memory management is not just possible — it’s designed to be ergonomic.
The Allocator Interface
All allocators implement the same interface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// From core:mem
Allocator_Mode :: enum u8 {
Alloc,
Alloc_Non_Zeroed,
Free,
Free_All,
Resize,
Resize_Non_Zeroed,
Query_Features,
Query_Info,
}
Allocator_Proc :: #type proc(
allocator_data: rawptr,
mode: Allocator_Mode,
size, alignment: int,
old_memory: rawptr,
old_size: int,
location: Source_Code_Location,
) -> (data: []u8, err: Allocator_Error)
Allocator :: struct {
procedure: Allocator_Proc,
data: rawptr,
}
|
This is a two-field struct (a function pointer and a data pointer). Any allocator — system heap, arena, pool, stack — satisfies this interface. Switching allocators is just swapping the two-field struct.
Built-In Allocators
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
|
package main
import (
"core:fmt"
"core:mem"
)
demonstrate_allocators :: proc() {
// 1. Heap allocator (default) — uses OS malloc/free
heap := mem.heap_allocator() // this is context.allocator by default
data := make([]int, 100, heap)
defer delete(data, heap)
data[0] = 42
// 2. Arena allocator — allocate many, free all at once
arena: mem.Arena
backing := make([]u8, 1 * mem.Megabyte) // 1MB backing buffer
defer delete(backing)
mem.arena_init(&arena, backing)
defer mem.arena_destroy(&arena)
arena_alloc := mem.arena_allocator(&arena)
strings_data := make([]string, 50, arena_alloc)
more_data := make([]f64, 200, arena_alloc)
_ = strings_data
_ = more_data
// Free ALL arena allocations at once:
mem.free_all(arena_alloc)
// Or let it go out of scope — destroy frees the backing buffer
// 3. Temp allocator — arena backed by a per-thread scratch buffer
// Typically freed at the end of a frame/request with free_all
temp_str := fmt.aprintf("formatted %d", 42, allocator = context.temp_allocator)
_ = temp_str
defer mem.free_all(context.temp_allocator) // common pattern: frame-end cleanup
// 4. Fixed buffer allocator — allocate into a stack-local buffer
buf: [4096]u8
fba := mem.fixed_buffer_allocator(buf[:])
frame_data := make([]u8, 128, mem.fixed_buffer_allocator_allocator(&fba))
_ = frame_data
// No free needed — data is on the stack
fmt.println("allocator demo complete")
}
// Stack allocator — LIFO, for nested scopes
demonstrate_stack_allocator :: proc() {
buf: [65536]u8 // 64KB on the stack
sa: mem.Stack
mem.stack_init(&sa, buf[:])
defer mem.stack_destroy(&sa)
alloc := mem.stack_allocator(&sa)
// Allocate in nested scopes
level1 := make([]int, 100, alloc)
mark := mem.stack_save(&sa) // save position
level2 := make([]f32, 200, alloc)
_ = level2
mem.stack_restore(&sa, mark) // free level2, keep level1
_ = level1
}
main :: proc() {
demonstrate_allocators()
demonstrate_stack_allocator()
}
|
new and make
new and make mirror Go syntax but accept an optional explicit allocator:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
package main
import (
"core:fmt"
"core:mem"
)
Node :: struct {
value: int,
next: ^Node,
}
allocator_explicit_demo :: proc() {
arena: mem.Arena
backing := make([]u8, 256 * mem.Kilobyte)
defer delete(backing)
mem.arena_init(&arena, backing)
defer mem.arena_destroy(&arena)
a := mem.arena_allocator(&arena)
// new(T) — allocates a single T, zero-initialized, returns ^T
node1 := new(Node, a) // uses explicit allocator
node2 := new(Node) // uses context.allocator
defer free(node2)
node1.value = 10
node1.next = new(Node, a)
node1.next.value = 20
// make([]T, len) — allocates a slice
nums := make([]int, 100, a) // explicit allocator
nums2 := make([]int, 100) // context.allocator
defer delete(nums2)
// make([]T, len, cap) — with explicit capacity
buf := make([]u8, 0, 1024, a)
// make(map[K]V) — allocates a hash map
scores := make(map[string]int, a)
scores["alice"] = 95
scores["bob"] = 87
_ = nums
_ = buf
fmt.println("node chain:", node1.value, node1.next.value)
fmt.println("scores:", scores)
// Arena free — frees node1, node2-equivalent, nums, buf, scores all at once
// (node2 is on the heap allocator, freed by defer above)
}
main :: proc() {
allocator_explicit_demo()
}
|
The defer free_all Pattern
The defer free_all(context.temp_allocator) pattern is idiomatic Odin for frame or request-scoped allocations. Instead of tracking individual allocations, you use the temp allocator for everything that lives only for the current frame/request:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
package main
import (
"core:fmt"
"core:mem"
"core:strings"
)
// Game-style frame loop
game_frame :: proc(frame: int) {
// All temp allocations this frame are freed at the end
defer mem.free_all(context.temp_allocator)
// Build temporary strings — no individual frees needed
frame_label := fmt.tprintf("Frame %d", frame) // tprintf = temp_printf
entity_names := make([]string, 10, context.temp_allocator)
for i in 0..<10 {
entity_names[i] = fmt.tprintf("entity_%d_%d", frame, i)
}
// Complex temporary computation
sb := strings.builder_make(context.temp_allocator)
strings.write_string(&sb, frame_label)
strings.write_string(&sb, ": ")
for name in entity_names {
strings.write_string(&sb, name)
strings.write_byte(&sb, ' ')
}
result := strings.to_string(sb)
if frame < 3 {
fmt.println(result[:min(60, len(result))])
}
// defer frees everything above at once
}
// HTTP request handler style
handle_http_request :: proc(path, body: string) -> string {
defer mem.free_all(context.temp_allocator)
// All parsing happens with temp allocations
parts := strings.split(path, "/", context.temp_allocator)
response := fmt.tprintf(`{"path": %q, "parts": %d, "bodyLen": %d}`,
path, len(parts), len(body))
// response is in temp allocator — caller must copy if it needs to persist
// This is intentional: the caller decides the lifetime
result := strings.clone(response) // copies to context.allocator (heap)
return result
// temp allocator freed here by defer
}
main :: proc() {
for frame in 0..<5 {
game_frame(frame)
}
resp := handle_http_request("/api/v1/users", `{"name":"alice"}`)
defer delete(resp)
fmt.println("response:", resp)
}
|
Tracking Allocator — Finding Leaks
Odin ships a tracking allocator that wraps another allocator and records every allocation and free. It’s invaluable for finding memory leaks:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
package main
import (
"core:fmt"
"core:mem"
)
leaky_function :: proc() -> []int {
result := make([]int, 10) // this should be freed by caller
result[0] = 42
leaked := make([]u8, 256) // this is definitely leaked
_ = leaked
return result
}
main :: proc() {
// Set up tracking allocator wrapping the heap allocator
track: mem.Tracking_Allocator
mem.tracking_allocator_init(&track, mem.heap_allocator())
defer mem.tracking_allocator_destroy(&track)
context.allocator = mem.tracking_allocator(&track)
// Run your code
result := leaky_function()
defer delete(result) // properly freed
// At shutdown, check for leaks
if len(track.allocation_map) > 0 {
fmt.eprintfln("=== MEMORY LEAKS ===")
for _, leak in track.allocation_map {
fmt.eprintfln(" %v bytes at %v", leak.size, leak.location)
}
}
if len(track.bad_free_array) > 0 {
fmt.eprintfln("=== BAD FREES ===")
for bad_free in track.bad_free_array {
fmt.eprintfln(" %v at %v", bad_free.memory, bad_free.location)
}
}
fmt.println("done")
}
|
Comparison: C malloc, Zig Allocators, Odin Allocators
The fundamental difference between the three approaches:
1
2
3
4
5
6
|
// C: global allocator, no control
void* process(size_t count) {
int* data = malloc(count * sizeof(int)); // always system malloc
// ... work ...
return data; // caller's problem
}
|
1
2
3
4
5
6
|
// Zig: explicit allocator parameter everywhere
fn process(allocator: std.mem.Allocator, count: usize) ![]i32 {
const data = try allocator.alloc(i32, count);
// explicit, but every allocating function needs the parameter
return data;
}
|
1
2
3
4
5
6
7
8
9
|
// Odin: implicit via context, override at boundary
process :: proc(count: int) -> []i32 {
data := make([]i32, count) // uses context.allocator
return data
}
// Usage — caller controls the allocator:
context.allocator = arena_alloc
result := process(1000) // uses arena, no signature change
|
Zig’s approach is strictly more traceable. Odin’s approach is more ergonomic for large codebases where you want to switch allocator strategies at subsystem boundaries without threading parameters through every layer.
Data-Oriented Features
SOA — Structure of Arrays
In traditional OOP-style code, you might have an array of structs:
[entity0: {x, y, z, health, team}, entity1: {x, y, z, health, team}, ...]
When you loop over entities to update positions, you load each struct into cache — but you only need x, y, z. You’re loading health and team too, wasting cache lines.
Structure of Arrays (SOA) flips this: separate arrays for each field.
positions_x: [entity0.x, entity1.x, entity2.x, ...]
positions_y: [entity0.y, entity1.y, entity2.y, ...]
positions_z: [entity0.z, entity1.z, entity2.z, ...]
health: [entity0.health, entity1.health, ...]
Now the position update loop accesses only positions_x, positions_y, positions_z — perfect cache utilization.
Odin has first-class SOA syntax:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
package main
import "core:fmt"
Entity :: struct {
pos: [3]f32,
vel: [3]f32,
health: f32,
team: u8,
}
soa_demo :: proc() {
// #soa marks this as a Structure of Arrays
// Instead of []Entity (array of structs),
// entities is stored as struct of arrays internally
entities: #soa[1000]Entity
// Access syntax is identical to AOS — the compiler handles the layout
entities[0].pos = {1, 2, 3}
entities[0].vel = {0.1, 0, 0}
entities[0].health = 100
entities[0].team = 1
entities[1].pos = {4, 5, 6}
entities[1].vel = {-0.1, 0, 0}
entities[1].health = 80
entities[1].team = 2
// This loop accesses entities[i].pos.x sequentially in memory:
// [pos.x[0], pos.x[1], pos.x[2], ...] — all contiguous, cache-friendly
for i in 0..<len(entities) {
entities[i].pos[0] += entities[i].vel[0]
entities[i].pos[1] += entities[i].vel[1]
entities[i].pos[2] += entities[i].vel[2]
}
fmt.println("entity 0 pos:", entities[0].pos)
fmt.println("entity 1 pos:", entities[1].pos)
}
// Dynamic SOA slice
dynamic_soa :: proc() {
// SOA with dynamic length
particles: #soa[dynamic]struct {
pos: [2]f32,
vel: [2]f32,
age: f32,
}
defer delete(particles)
// append works normally
append(&particles, {pos = {1, 2}, vel = {0.5, 0.1}, age = 0})
append(&particles, {pos = {3, 4}, vel = {-0.2, 0.3}, age = 0.1})
// Update loop — vel accesses are contiguous in memory
for i in 0..<len(particles) {
particles[i].pos += particles[i].vel
particles[i].age += 0.016 // 60fps delta
}
fmt.println("particle count:", len(particles))
fmt.println("particle 0:", particles[0].pos)
}
main :: proc() {
soa_demo()
dynamic_soa()
}
|
The key insight: the entities[i].pos syntax looks identical whether entities is an []Entity or an #soa[]Entity. You get SOA’s cache performance without rewriting your access patterns. This is unique to Odin — C requires manually restructuring the code, and most other languages don’t have the concept at all.
SIMD Vectors
Odin has built-in SIMD vector types that map directly to hardware vector operations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
package main
import (
"core:fmt"
"core:simd"
)
simd_demo :: proc() {
// #simd creates a SIMD vector type
// These map to SSE/AVX on x86, NEON on ARM
a: #simd[4]f32 = {1, 2, 3, 4}
b: #simd[4]f32 = {5, 6, 7, 8}
// Arithmetic operations are SIMD-accelerated
c := a + b // {6, 8, 10, 12}
d := a * b // {5, 12, 21, 32}
e := a / b // element-wise division
fmt.println("a + b =", c)
fmt.println("a * b =", d)
fmt.println("a / b =", e)
// Horizontal operations
sum := simd.reduce_add_ordered(a) // 1+2+3+4 = 10
fmt.println("sum:", sum)
// Comparison — returns a vector of bools (or mask)
mask := a < b // {true, true, true, true}
fmt.println("a < b:", mask)
// Shuffles
shuffled := simd.shuffle(a, b, 0, 2, 4, 6) // picks elements from a and b
fmt.println("shuffled:", shuffled)
}
// Practical: processing 4 floats at a time
normalize_vectors_simd :: proc(vecs: [][4]f32) {
for &v in vecs {
sv := transmute(#simd[4]f32)v
// dot product with itself
dot := simd.reduce_add_ordered(sv * sv)
inv_len := 1.0 / f32(dot) // would normally be sqrt
sv = sv * #simd[4]f32{inv_len, inv_len, inv_len, inv_len}
v = transmute([4]f32)sv
}
}
main :: proc() {
simd_demo()
}
|
Matrix Type
Odin has a built-in matrix type for linear algebra, common in graphics and simulation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
package main
import (
"core:fmt"
"core:math/linalg"
)
matrix_demo :: proc() {
// matrix[rows, cols]type
m: matrix[4, 4]f32 = {
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
}
// Matrix multiplication with *
rotation := linalg.matrix4_rotate_f32(1.5707963, {0, 1, 0}) // 90 deg around Y
translation := linalg.matrix4_translate_f32({1, 2, 3})
transform := translation * rotation // matrix multiply
// Matrix * vector
point: [4]f32 = {1, 0, 0, 1}
transformed := transform * point
fmt.println("transform matrix:")
fmt.println(transform)
fmt.println("transformed point:", transformed)
// 2D matrix
a: matrix[2, 2]f64 = {1, 2, 3, 4}
b: matrix[2, 2]f64 = {5, 6, 7, 8}
c := a * b
fmt.println("2x2 multiply:", c)
// Transpose
d := transpose(a)
fmt.println("transpose:", d)
// Determinant via linalg
det := linalg.matrix2_determinant(a)
fmt.println("determinant:", det)
}
main :: proc() {
matrix_demo()
}
|
bit_set and bit_field
Odin’s bit_set and bit_field give precise control over bit-level data layout — critical for protocols, hardware registers, and compact flags:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
package main
import "core:fmt"
// bit_set — a set type backed by a bitmask
// Much cleaner than C's manual flag bitmasks
Permission :: enum { Read, Write, Execute, SetUID, SetGID }
File_Permissions :: bit_set[Permission; u8] // backed by a u8
// bit_field — struct with explicit bit-width fields
// Maps directly to hardware register layouts
TCP_Flags :: bit_field u16 {
cwr: u8 | 1, // congestion window reduced
ece: u8 | 1, // ECN-Echo
urg: u8 | 1, // urgent pointer
ack: u8 | 1, // acknowledgment
psh: u8 | 1, // push function
rst: u8 | 1, // reset
syn: u8 | 1, // synchronize
fin: u8 | 1, // finish
_: u8 | 8, // data offset + reserved (padding)
}
// IPv4 header fields (simplified)
IPv4_Header_Byte0 :: bit_field u8 {
ihl: u8 | 4, // internet header length
version: u8 | 4, // IP version
}
flags_demo :: proc() {
// bit_set operations
perms: File_Permissions = {.Read, .Write}
fmt.println("initial perms:", perms)
fmt.println("can read:", .Read in perms)
fmt.println("can execute:", .Execute in perms)
perms += {.Execute}
perms -= {.Write}
fmt.println("updated perms:", perms)
// Set operations
a: File_Permissions = {.Read, .Execute}
b: File_Permissions = {.Write, .Execute}
fmt.println("union:", a | b)
fmt.println("intersection:", a & b)
fmt.println("difference:", a - b)
// bit_field usage
flags: TCP_Flags
flags.syn = 1
flags.ack = 1
// Direct byte manipulation for network code
raw := transmute(u16)flags
fmt.printf("TCP flags bytes: 0x%04x\n", raw)
// Parse incoming flags
incoming: u16 = 0x0012 // SYN+ACK in network order
parsed := transmute(TCP_Flags)incoming
fmt.println("SYN:", parsed.syn, "ACK:", parsed.ack, "FIN:", parsed.fin)
}
main :: proc() {
flags_demo()
}
|
Parametric Polymorphism
Odin’s generics system uses explicit type parameters with $. It’s more limited than C++ templates or Zig’s comptime but considerably less complex.
Basic Generic Procedures
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
|
package main
import "core:fmt"
// $T means T is a type parameter inferred from the argument
// This is specialised at compile time for each concrete type
swap :: proc(a, b: ^$T) {
a^, b^ = b^, a^
}
// Explicit type parameter
make_pair :: proc($A, $B: typeid, a: A, b: B) -> (A, B) {
return a, b
}
// Generic with constraint using where
// where clause restricts what types are valid
dot_product :: proc(a, b: [$N]$T) -> T where intrinsics.type_is_numeric(T) {
result: T
for i in 0..<N {
result += a[i] * b[i]
}
return result
}
// Generic slice operations
contains :: proc(slice: []$T, value: T) -> bool where intrinsics.type_is_comparable(T) {
for item in slice {
if item == value {
return true
}
}
return false
}
filter :: proc(slice: []$T, pred: proc(T) -> bool, allocator := context.allocator) -> []T {
result := make([dynamic]T, allocator)
for item in slice {
if pred(item) {
append(&result, item)
}
}
return result[:]
}
map_slice :: proc(slice: []$T, f: proc(T) -> $R, allocator := context.allocator) -> []R {
result := make([]R, len(slice), allocator)
for item, i in slice {
result[i] = f(item)
}
return result
}
main :: proc() {
// swap
a, b := 10, 20
swap(&a, &b)
fmt.println("swapped:", a, b)
x, y := "hello", "world"
swap(&x, &y)
fmt.println("swapped strings:", x, y)
// dot product
v1 := [3]f32{1, 2, 3}
v2 := [3]f32{4, 5, 6}
fmt.println("dot product:", dot_product(v1, v2)) // 32
vi1 := [4]int{1, 2, 3, 4}
vi2 := [4]int{5, 6, 7, 8}
fmt.println("int dot:", dot_product(vi1, vi2)) // 70
// contains
nums := []int{1, 2, 3, 4, 5}
fmt.println("contains 3:", contains(nums, 3))
fmt.println("contains 9:", contains(nums, 9))
words := []string{"alpha", "beta", "gamma"}
fmt.println("contains beta:", contains(words, "beta"))
// filter
evens := filter(nums, proc(n: int) -> bool { return n % 2 == 0 })
defer delete(evens)
fmt.println("evens:", evens)
// map
doubled := map_slice(nums, proc(n: int) -> int { return n * 2 })
defer delete(doubled)
fmt.println("doubled:", doubled)
}
|
Specialised Procedures with where
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
|
package main
import (
"core:fmt"
"core:intrinsics"
)
// Different behaviour for integer vs float types
abs :: proc(x: $T) -> T where intrinsics.type_is_numeric(T) {
when intrinsics.type_is_unsigned(T) {
return x
} else {
return x < 0 ? -x : x
}
}
// Generic min/max with ordered constraint
clamp_val :: proc(value, lo, hi: $T) -> T where intrinsics.type_is_ordered(T) {
if value < lo { return lo }
if value > hi { return hi }
return value
}
// Generic stack
Stack :: struct($T: typeid) {
data: [dynamic]T,
}
stack_push :: proc(s: ^Stack($T), value: T) {
append(&s.data, value)
}
stack_pop :: proc(s: ^Stack($T)) -> (value: T, ok: bool) {
if len(s.data) == 0 { return }
value = pop(&s.data)
ok = true
return
}
stack_peek :: proc(s: ^Stack($T)) -> (value: T, ok: bool) {
if len(s.data) == 0 { return }
return s.data[len(s.data)-1], true
}
stack_destroy :: proc(s: ^Stack($T)) {
delete(s.data)
}
main :: proc() {
fmt.println(abs(-5)) // int
fmt.println(abs(-3.14)) // float
fmt.println(abs(uint(5))) // unsigned
fmt.println(clamp_val(15, 0, 10)) // 10
fmt.println(clamp_val(-5, 0, 10)) // 0
fmt.println(clamp_val(5, 0, 10)) // 5
fmt.println(clamp_val("banana", "apple", "cherry")) // "banana"
s: Stack(int)
defer stack_destroy(&s)
stack_push(&s, 1)
stack_push(&s, 2)
stack_push(&s, 3)
if top, ok := stack_peek(&s); ok {
fmt.println("top:", top) // 3
}
for {
val, ok := stack_pop(&s)
if !ok { break }
fmt.println("popped:", val)
}
}
|
Comparison: C++ Templates vs Zig comptime vs Odin Polymorphism
1
2
3
4
5
6
7
|
// C++ templates: Turing-complete metaprogramming, error messages are notorious
template<typename T>
T clamp(T value, T lo, T hi) {
static_assert(std::is_arithmetic_v<T>, "T must be arithmetic");
return std::max(lo, std::min(value, hi));
}
// Error messages from misuse span 50+ lines of template instantiation traces
|
1
2
3
4
5
6
7
8
9
10
|
// Zig comptime: powerful but requires understanding compile-time evaluation
fn Stack(comptime T: type) type {
return struct {
data: std.ArrayListUnmanaged(T) = .{},
pub fn push(self: *@This(), alloc: std.mem.Allocator, val: T) !void {
try self.data.append(alloc, val);
}
// allocator everywhere, comptime type generation
};
}
|
1
2
3
4
|
// Odin: struct parametric types, explicit but clean
Stack :: struct($T: typeid) { data: [dynamic]T }
stack_push :: proc(s: ^Stack($T), value: T) { append(&s.data, value) }
// Straightforward, error messages are readable, no allocator parameter
|
Odin’s generics are less powerful than Zig’s comptime (you can’t generate arbitrary types or run arbitrary code at compile time) but the simplicity means correct code is easier to write and errors are understandable.
Error Handling
Odin does not have exceptions. Errors are values. The or_return, or_else, or_break, and or_continue keywords provide ergonomic shorthand for the common patterns.
Multiple Return Values for Errors
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
|
package main
import (
"core:fmt"
"core:net"
"core:os"
"core:strconv"
"core:strings"
)
// Go-style: (result, bool)
parse_positive_int :: proc(s: string) -> (int, bool) {
n, ok := strconv.parse_int(s, 10)
if !ok || n < 0 { return 0, false }
return n, true
}
// (result, error string)
load_config_file :: proc(path: string) -> (data: string, err: string) {
bytes, ok := os.read_entire_file(path)
if !ok {
err = fmt.tprintf("failed to read %q", path)
return
}
data = string(bytes)
return
}
// Using union for typed errors
Parse_Error :: struct {
line: int,
column: int,
message: string,
}
IO_Error :: struct {
path: string,
code: int,
}
Config_Error :: union {
Parse_Error,
IO_Error,
}
load_and_parse :: proc(path: string) -> (result: map[string]string, err: Config_Error) {
bytes, ok := os.read_entire_file(path)
if !ok {
err = IO_Error{path = path, code = -1}
return
}
result = make(map[string]string)
lines := strings.split(string(bytes), "\n")
for line, i in lines {
line = strings.trim_space(line)
if len(line) == 0 || line[0] == '#' { continue }
idx := strings.index(line, "=")
if idx < 0 {
err = Parse_Error{line = i+1, column = 0, message = "missing '='"}
return
}
result[strings.trim_space(line[:idx])] = strings.trim_space(line[idx+1:])
}
return
}
// Practical error handling
run :: proc() -> bool {
// or_return for chained operations
data, read_ok := os.read_entire_file("config.txt")
if !read_ok {
fmt.eprintln("could not read config")
return false
}
defer delete(data)
// or_else for defaults
port_str := "8080"
port := parse_positive_int(port_str) or_else 3000
fmt.println("port:", port)
// Typed error handling
config, config_err := load_and_parse("app.conf")
if config_err != nil {
switch e in config_err {
case Parse_Error:
fmt.eprintfln("parse error line %d: %s", e.line, e.message)
case IO_Error:
fmt.eprintfln("io error reading %s", e.path)
}
return false
}
defer delete(config)
fmt.println("config loaded:", len(config), "keys")
return true
}
main :: proc() {
run()
}
|
Maybe and Result Patterns
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
package main
import (
"core:fmt"
"core:strings"
)
// Maybe(T) is a union of T and nil — Odin's Optional type
find_first :: proc(s: string, ch: u8) -> Maybe(int) {
for i in 0..<len(s) {
if s[i] == ch { return i }
}
return nil
}
// Chain Maybe operations with or_else
process_path :: proc(path: string) -> string {
// Find last '/' or return 0
last_slash := find_first(path, '/') or_else 0
return path[last_slash+1:]
}
// Result-like pattern with a typed union
Result :: union($T: typeid, $E: typeid) #no_nil {
// Odin doesn't have a built-in Result type like Rust
// but you can build one with a union
}
// More idiomatic: pair of (value, err)
safe_divide :: proc(a, b: f64) -> (result: f64, err: string) {
if b == 0 {
err = "division by zero"
return
}
result = a / b
return
}
// Wrapping fallible operations with or_return
compute_pipeline :: proc(inputs: []string) -> (total: f64, ok: bool) {
for s in inputs {
// parse each string to float — if any fails, propagate
import "core:strconv"
val := strconv.parse_f64(s) or_return
total += val
}
ok = true
return
}
main :: proc() {
s := "hello/world/file.txt"
if idx, ok := find_first(s, '/'); ok {
fmt.println("first slash at:", idx)
} else {
fmt.println("no slash found")
}
fmt.println("basename:", process_path(s))
result, err := safe_divide(10, 3)
if err == "" {
fmt.printf("10/3 = %.4f\n", result)
}
_, err2 := safe_divide(10, 0)
fmt.println("div by zero error:", err2)
}
|
Foreign Function Interface
Odin’s C interop is clean and direct. Unlike Zig, there’s no need to translate C headers — Odin has a straightforward foreign block syntax.
Calling C Functions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
package main
import "core:fmt"
import "core:c"
// Linking with libc
foreign import libc "system:c"
foreign libc {
// Declare C functions
malloc :: proc(size: c.size_t) -> rawptr ---
free :: proc(ptr: rawptr) ---
memcpy :: proc(dst, src: rawptr, n: c.size_t) -> rawptr ---
memset :: proc(ptr: rawptr, value: c.int, n: c.size_t) -> rawptr ---
printf :: proc(fmt_str: cstring, #c_vararg args: ..any) -> c.int ---
strlen :: proc(s: cstring) -> c.size_t ---
getenv :: proc(name: cstring) -> cstring ---
}
// The `c` package provides C-compatible types
demo_libc :: proc() {
// Call C malloc directly
ptr := malloc(256)
defer free(ptr)
// memset to zero
memset(ptr, 0, 256)
// C printf
printf("Hello from C printf: %d\n", c.int(42))
// getenv
path := getenv("PATH")
if path != nil {
fmt.println("PATH length:", strlen(path))
}
}
main :: proc() {
demo_libc()
}
|
Linking a Custom C Library
Suppose you have a C library mylib.c:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// mylib.c — compile with: gcc -c mylib.c -o mylib.o
#include <stdio.h>
#include <string.h>
typedef struct {
float x, y, z;
} Vec3;
Vec3 vec3_add(Vec3 a, Vec3 b) {
return (Vec3){ a.x + b.x, a.y + b.y, a.z + b.z };
}
float vec3_dot(Vec3 a, Vec3 b) {
return a.x*b.x + a.y*b.y + a.z*b.z;
}
int string_count_char(const char* s, char ch) {
int count = 0;
while (*s) { if (*s++ == ch) count++; }
return count;
}
|
The Odin binding:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
package main
import "core:fmt"
import "core:c"
// Import the compiled object/library
foreign import mylib "mylib.o" // or "mylib.a", "mylib.so", "mylib.lib"
// Mirror the C struct exactly
Vec3 :: struct #packed {
x, y, z: f32,
}
foreign mylib {
@(link_name="vec3_add")
vec3_add :: proc(a, b: Vec3) -> Vec3 ---
@(link_name="vec3_dot")
vec3_dot :: proc(a, b: Vec3) -> f32 ---
@(link_name="string_count_char")
string_count_char :: proc(s: cstring, ch: c.char) -> c.int ---
}
main :: proc() {
a := Vec3{1, 2, 3}
b := Vec3{4, 5, 6}
sum := vec3_add(a, b)
fmt.println("sum:", sum)
dot := vec3_dot(a, b)
fmt.println("dot:", dot) // 1*4 + 2*5 + 3*6 = 32
s := cstring("hello world hello")
count := string_count_char(s, 'l')
fmt.println("'l' count:", count) // 5
}
|
Using Vendor Libraries (Raylib)
Odin ships with vendor bindings for popular libraries:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
package main
import rl "vendor:raylib"
// A complete raylib window in ~20 lines
main :: proc() {
rl.InitWindow(800, 600, "Odin + Raylib")
defer rl.CloseWindow()
rl.SetTargetFPS(60)
for !rl.WindowShouldClose() {
rl.BeginDrawing()
defer rl.EndDrawing()
rl.ClearBackground(rl.RAYWHITE)
rl.DrawText("Hello from Odin!", 200, 250, 40, rl.MAROON)
rl.DrawFPS(10, 10)
}
}
|
1
2
|
# Build with raylib vendor lib
odin build . -define:RAYLIB_SHARED=false
|
SDL2 Binding Example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
package main
import "core:fmt"
import sdl "vendor:sdl2"
main :: proc() {
if sdl.Init(sdl.INIT_VIDEO) != 0 {
fmt.eprintln("SDL init failed:", sdl.GetError())
return
}
defer sdl.Quit()
window := sdl.CreateWindow(
"Odin SDL2",
sdl.WINDOWPOS_CENTERED, sdl.WINDOWPOS_CENTERED,
800, 600,
sdl.WINDOW_SHOWN,
)
if window == nil {
fmt.eprintln("window creation failed:", sdl.GetError())
return
}
defer sdl.DestroyWindow(window)
renderer := sdl.CreateRenderer(window, -1, sdl.RENDERER_ACCELERATED)
if renderer == nil { return }
defer sdl.DestroyRenderer(renderer)
event: sdl.Event
running := true
for running {
for sdl.PollEvent(&event) {
if event.type == .QUIT { running = false }
}
sdl.SetRenderDrawColor(renderer, 30, 30, 46, 255)
sdl.RenderClear(renderer)
sdl.RenderPresent(renderer)
}
}
|
@(link_name) and @(link_prefix)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
package main
import "core:c"
// Rename to strip C prefix conventions
foreign import zlib "system:z"
@(link_prefix="z") // applied to all in this block
foreign zlib {
// C name: zlib_version() → Odin name: lib_version() (prefix stripped automatically)
// Or use explicit link_name:
@(link_name="zlibVersion")
get_version :: proc() -> cstring ---
@(link_name="compress2")
compress :: proc(
dest: ^u8,
dest_len: ^c.ulong,
source: ^u8,
source_len: c.ulong,
level: c.int,
) -> c.int ---
}
|
Packages and Build System
Package Structure
Odin uses directory-based packages (like Go). Every .odin file in a directory belongs to the same package.
my_project/
├── main.odin # package main
├── game/
│ ├── game.odin # package game
│ ├── entity.odin # package game (same package)
│ └── physics.odin # package game
├── renderer/
│ └── renderer.odin # package renderer
└── vendor/ # bundled third-party (by convention)
└── stb_image/
└── stb_image.odin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// game/entity.odin
package game
import "core:fmt"
Entity :: struct {
id: u64,
pos: [3]f32,
alive: bool,
}
entity_update :: proc(e: ^Entity, dt: f32) {
// ...
}
// Private to package (lowercase)
internal_counter: int
// Exported (uppercase — Odin uses capitalization for visibility)
MAX_ENTITIES :: 10_000
|
1
2
3
4
5
6
7
8
9
10
11
12
|
// main.odin
package main
import "core:fmt"
import "game" // relative import from same tree
main :: proc() {
e := game.Entity{id = 1, pos = {0, 0, 0}, alive = true}
game.entity_update(&e, 0.016)
fmt.println("entity:", e)
fmt.println("max:", game.MAX_ENTITIES)
}
|
Build Commands
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
# Run directly (compiles and executes)
odin run .
# Build a binary
odin build . -out:my_game
# Build with optimizations
odin build . -o:speed # speed optimization
odin build . -o:size # size optimization
odin build . -o:aggressive # aggressive (may change float behavior)
# Debug build with bounds checking and sanitizers
odin build . -debug
# Type-check only (fast — no code generation)
odin check .
# Run tests
odin test .
odin test ./... # (check docs — package path varies)
# Cross-compile to Windows from Linux
odin build . -target:windows_amd64 -out:game.exe
# Define compile-time constants
odin build . -define:DEVELOPER=true -define:MAX_PLAYERS=8
# Build a specific file
odin run main.odin -file
# Show generated LLVM IR (for debugging codegen)
odin build . -keep-temp-files
# Build a shared library
odin build . -build-mode:shared -out:libgame.so
# Build a static library
odin build . -build-mode:object
|
odin.json — Build Configuration
For complex builds, Odin supports a collection file (odin.json or via command line):
1
2
|
# Common flags via collection aliases
odin build . -collection:shared=/usr/local/lib/odin_shared
|
Testing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// math_test.odin
package math_utils
import "core:testing"
@(test)
test_clamp :: proc(t: ^testing.T) {
testing.expect_value(t, clamp(5, 0, 10), 5)
testing.expect_value(t, clamp(-1, 0, 10), 0)
testing.expect_value(t, clamp(15, 0, 10), 10)
}
@(test)
test_lerp :: proc(t: ^testing.T) {
result := lerp(0.0, 10.0, 0.5)
testing.expect(t, result == 5.0, "expected 5.0")
}
@(test)
test_normalize :: proc(t: ^testing.T) {
v := [3]f32{3, 4, 0}
n := normalize(v)
length := magnitude(n)
testing.expect(t, abs(length - 1.0) < 0.0001, "should be unit vector")
}
|
1
2
3
4
5
6
|
odin test .
# Output:
# [PASS] test_clamp
# [PASS] test_lerp
# [PASS] test_normalize
# 3 tests run, 3 passed, 0 failed
|
A Complete Real-World Example
Let’s build a small but complete entity-component system to demonstrate all of Odin’s features working together:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
|
package ecs
import (
"core:fmt"
"core:mem"
"core:slice"
)
MAX_ENTITIES :: 65536
Entity_ID :: distinct u32
// Component types
Transform :: struct {
pos: [3]f32,
rot: [4]f32, // quaternion
scale: f32,
}
Velocity :: struct {
linear: [3]f32,
angular: f32,
}
Health :: struct {
current: f32,
maximum: f32,
}
Tag :: enum u8 { Player, Enemy, Neutral, Projectile }
// Archetype: entities with the same component set
// Using SOA for cache-friendly iteration
World :: struct {
// Dense packed arrays using SOA
transforms: #soa[dynamic]Transform,
velocities: #soa[dynamic]Velocity,
healths: #soa[dynamic]Health,
tags: [dynamic]Tag,
entity_ids: [dynamic]Entity_ID,
// Sparse lookup: Entity_ID → dense index
id_to_index: map[Entity_ID]int,
next_id: Entity_ID,
// Allocator for this world
allocator: mem.Allocator,
}
world_create :: proc(allocator := context.allocator) -> World {
w: World
w.allocator = allocator
w.transforms = make(#soa[dynamic]Transform, allocator)
w.velocities = make(#soa[dynamic]Velocity, allocator)
w.healths = make(#soa[dynamic]Health, allocator)
w.tags = make([dynamic]Tag, allocator)
w.entity_ids = make([dynamic]Entity_ID, allocator)
w.id_to_index = make(map[Entity_ID]int, allocator)
w.next_id = 1
return w
}
world_destroy :: proc(w: ^World) {
delete(w.transforms)
delete(w.velocities)
delete(w.healths)
delete(w.tags)
delete(w.entity_ids)
delete(w.id_to_index)
}
entity_spawn :: proc(w: ^World, t: Transform, v: Velocity, h: Health, tag: Tag) -> Entity_ID {
id := w.next_id
w.next_id += 1
idx := len(w.transforms)
w.id_to_index[id] = idx
append(&w.transforms, t)
append(&w.velocities, v)
append(&w.healths, h)
append(&w.tags, tag)
append(&w.entity_ids, id)
return id
}
entity_destroy :: proc(w: ^World, id: Entity_ID) -> bool {
idx, exists := w.id_to_index[id]
if !exists { return false }
last := len(w.transforms) - 1
if idx != last {
// Swap with last to maintain dense packing
w.transforms[idx] = w.transforms[last]
w.velocities[idx] = w.velocities[last]
w.healths[idx] = w.healths[last]
w.tags[idx] = w.tags[last]
moved_id := w.entity_ids[last]
w.entity_ids[idx] = moved_id
w.id_to_index[moved_id] = idx
}
// Remove last
resize(&w.transforms, last)
resize(&w.velocities, last)
resize(&w.healths, last)
resize(&w.tags, last)
resize(&w.entity_ids, last)
delete_key(&w.id_to_index, id)
return true
}
// System: integrate physics
// SOA layout means this accesses memory contiguously
system_physics :: proc(w: ^World, dt: f32) {
for i in 0..<len(w.transforms) {
w.transforms[i].pos[0] += w.velocities[i].linear[0] * dt
w.transforms[i].pos[1] += w.velocities[i].linear[1] * dt
w.transforms[i].pos[2] += w.velocities[i].linear[2] * dt
}
}
// System: damage over time for enemies
system_dot :: proc(w: ^World, damage_per_sec: f32, dt: f32) {
for i in 0..<len(w.tags) {
if w.tags[i] == .Enemy {
w.healths[i].current -= damage_per_sec * dt
if w.healths[i].current < 0 {
w.healths[i].current = 0
}
}
}
}
// Query with a temp-allocator result slice
query_low_health :: proc(w: ^World, threshold: f32) -> []Entity_ID {
result := make([dynamic]Entity_ID, context.temp_allocator)
for i in 0..<len(w.healths) {
if w.healths[i].current / w.healths[i].maximum < threshold {
append(&result, w.entity_ids[i])
}
}
return result[:]
}
demo :: proc() {
// Dedicated arena for the world
arena: mem.Arena
backing := make([]u8, 16 * mem.Megabyte)
defer delete(backing)
mem.arena_init(&arena, backing)
defer mem.arena_destroy(&arena)
world_alloc := mem.arena_allocator(&arena)
w := world_create(world_alloc)
defer world_destroy(&w)
// Spawn entities
player := entity_spawn(&w,
Transform{pos = {0, 0, 0}, scale = 1},
Velocity{linear = {1, 0, 0}},
Health{current = 100, maximum = 100},
.Player,
)
for i in 0..<10 {
_ = entity_spawn(&w,
Transform{pos = {f32(i) * 5, 0, 0}, scale = 1},
Velocity{linear = {-0.5, 0, 0}},
Health{current = 50, maximum = 50},
.Enemy,
)
}
fmt.printf("spawned %d entities (player id: %d)\n", len(w.entity_ids), player)
// Simulate 60 frames
dt := f32(1.0 / 60.0)
for frame in 0..<60 {
defer mem.free_all(context.temp_allocator)
system_physics(&w, dt)
system_dot(&w, 5.0, dt) // 5 damage/sec to enemies
if frame % 20 == 19 {
low_health := query_low_health(&w, 0.5)
fmt.printf("frame %d: %d entities below 50%% health\n",
frame+1, len(low_health))
}
}
// Check player position
if idx, ok := w.id_to_index[player]; ok {
fmt.println("player final pos:", w.transforms[idx].pos)
}
}
|
Real-World Use and Ecosystem
Orca DAW
The most prominent Odin production project is Orca, an audio application platform developed by developer Martin Fouilleul. Orca is not the music environment by the same name — it’s an audio plugin hosting environment designed to let audio plugins run in a safe, sandboxed WebAssembly runtime while maintaining low-latency audio performance. The core runtime is written in Odin.
Orca demonstrates that Odin can handle:
- Hard real-time constraints (audio processing at 44100/48000 Hz requires microsecond-precise scheduling)
- Complex interop with C libraries (PortAudio, CoreAudio, WASAPI)
- Cross-platform deployment (macOS and Windows)
- A non-trivial codebase without a garbage collector
Game Development
The game development community has adopted Odin more broadly than any other domain. Reasons:
-
Raylib vendor bindings — Odin ships first-class raylib bindings. Building a game with a window, graphics, input, audio, and 2D/3D rendering takes fewer lines of Odin than the equivalent in C.
-
SOA types — game engines live and die by cache performance in entity update loops. #soa makes this ergonomic without manual struct splitting.
-
Allocators without overhead — games need frame allocators (free everything at frame end), pool allocators (fixed-size objects for entities), and stack allocators (temporary computation). Odin’s context system makes switching between them trivial.
-
No garbage collection pauses — 60fps games cannot tolerate GC pauses. Odin’s manual memory management avoids this entirely.
-
Simple FFI — most game engines link against C libraries (PhysX, Havok, OpenAL, FreeType). Odin’s foreign blocks handle this cleanly.
Notable community game projects include card games, roguelikes, and simulation games. The /r/odinlang community maintains a list of games shipped with Odin.
Vendor Library Collection
Odin’s vendor directory (in the compiler distribution) includes bindings for:
vendor/
├── raylib/ # 2D/3D game framework
├── sdl2/ # multimedia layer
├── sdl3/ # SDL3 (newer)
├── vulkan/ # Vulkan GPU API
├── opengl/ # OpenGL (via glad)
├── glfw/ # window/input management
├── imgui/ # immediate-mode GUI
├── miniaudio/ # audio
├── stb/ # stb_image, stb_truetype, stb_vorbis
├── fontstash/ # font rendering
├── microui/ # micro UI library
├── cgltf/ # glTF loader
├── commonmark/ # Markdown parser
├── lua/ # Lua scripting
├── wasm/ # WASM runtime hooks
└── zlib/ # compression
These are not a package manager — they’re curated, vendored bindings. You get them with the compiler distribution. This avoids dependency hell at the cost of less choice.
The core: and base: Libraries
Odin’s standard library is split into core: (full-featured) and base: (minimal, for environments without OS support):
core:fmt — formatted I/O (like Go's fmt)
core:os — file, process, environment
core:io — reader/writer interfaces
core:bufio — buffered I/O
core:net — networking (TCP, UDP, DNS)
core:http — basic HTTP client/server
core:mem — allocators, memory utilities
core:strings — string manipulation
core:strconv — string↔number conversion
core:math — basic math
core:math/linalg — linear algebra
core:math/rand — random number generation
core:sort — sorting
core:slice — slice utilities
core:bytes — byte slice utilities
core:unicode — Unicode support
core:encoding/json — JSON
core:encoding/csv — CSV
core:compress/zlib — compression
core:crypto — cryptographic primitives
core:log — structured logging
core:testing — test framework
core:thread — threads and synchronization
core:sync — mutex, semaphore, atomic
core:container — queue, priority_queue, avl_tree, rbtree
core:reflect — runtime reflection
core:time — time and duration
Honest Assessment
This section is direct about where Odin wins and where it doesn’t.
Where Odin Wins
Game development and simulations. Odin is genuinely excellent for games and physics simulations. The combination of #soa, vendor raylib/SDL2/Vulkan bindings, simple allocator control, and no GC makes it arguably the best language for indie game development where you want C-level performance without fighting the C preprocessor. The context system makes frame allocators almost invisible to write.
Data-heavy systems code. If your code processes large amounts of structured data — telemetry ingestion, game simulation, signal processing, computational geometry — Odin’s data-oriented features (SOA, SIMD, matrix types, explicit cache-friendly layout) are ahead of every language except possibly Zig.
“I want a better C.” If your honest requirement is “I want C but with real packages, generics, better error handling, and without the preprocessor,” Odin answers that question better than any other current option. It’s more pragmatic than Zig (which imposes the comptime paradigm everywhere), less complex than Rust, and more expressive than C.
Small teams on focused projects. Odin’s small surface area means a team can learn the whole language in a week. There’s no template metaprogramming, no complex lifetime system, no macro system to maintain. The language stays out of your way.
Embedding and plugin systems. The allocator context makes Odin well-suited for embedding as a scripting/logic layer where you can inject custom allocators for sandboxing (as Orca does).
Where to Choose Something Else
Memory safety is a hard requirement. If you need guaranteed memory safety (embedded medical devices, security-critical code, anything where use-after-free is categorically unacceptable), choose Rust. Odin is as unsafe as C — you can write dangling pointers, buffer overflows, and use-after-free without the compiler stopping you.
Large open-source ecosystem. Zig has more packages, more community libraries, and more tooling (Zig is already at 0.12+ with a more stable ABI). Rust has crates.io with tens of thousands of packages. Odin has vendor bindings and community code, but no package manager. If you need an HTTP/2 client, a database driver, or a production-grade TLS stack, you’ll be writing bindings to a C library rather than finding an Odin-native package.
Stability guarantees. Odin is pre-1.0. The compiler and standard library do change. For long-running projects or enterprise adoption where API stability is required, Rust (with edition-based stability) or Go (1.x compatibility guarantee) are safer choices.
Teams that need code review enforced by the type system. Rust’s ownership model prevents entire categories of bugs that code review can miss. If you’re building software where safety bugs are business-critical (browsers, OS kernels, cryptographic libraries), Rust’s guarantees are worth the complexity.
Cross-compilation needs beyond the mainstream. Odin compiles to LLVM IR and supports all major LLVM targets, but Windows has historically been the primary development platform. Linux and macOS support is solid, but obscure architectures or embedded targets may have rough edges. Zig has a more principled cross-compilation story and is more popular in the embedded space.
Complex generics. If your codebase requires type-level programming — type-safe heterogeneous containers, complex trait bounds, procedurally generating code from type information — Zig’s comptime or Rust’s generics + traits are more powerful. Odin’s generics are sufficient for 90% of use cases but hit a wall at the extremes.
The Pre-1.0 Reality
The pre-1.0 status deserves honest discussion. In practice, the Odin compiler has been stable enough for production use in the projects that use it (Orca has been shipping). But “stable enough in practice” is not the same as “guaranteed stable.” There is no written specification that pins down every language behavior. The monthly dev releases occasionally include breaking changes — usually small, but real.
For a greenfield side project or game: the pre-1.0 status is a non-issue.
For a multi-year production system at a company: evaluate whether your team can commit to tracking compiler updates. Zig has the same issue; neither language offers Go-style compatibility guarantees yet.
Comparative Summary
| Concern |
C |
Zig |
Odin |
Rust |
| Memory safety |
None |
None |
None |
Borrow checker |
| Learning curve |
Low-medium |
Medium |
Low |
High |
| C interop |
Native |
Excellent |
Excellent |
Good |
| Generics |
Macros |
comptime |
Limited |
Full |
| Allocator ergonomics |
Manual |
Explicit |
Context |
Allocator traits |
| SOA / data layout |
Manual |
Manual |
Built-in |
Manual |
| Compile speed |
Fast |
Fast |
Fast |
Slow |
| Ecosystem size |
Huge |
Growing |
Small |
Large |
| Stability |
Stable |
Pre-1.0 |
Pre-1.0 |
Stable |
| Package manager |
None |
Zig fetch |
None |
Cargo |
| Game dev libraries |
Available |
Available |
Vendor |
Available |
| Production track record |
Decades |
Growing |
Orca + games |
Mozilla, AWS |
Getting Started
Installation
1
2
3
4
5
6
7
8
9
10
11
12
|
# Linux — download from ziglang.org analog: odin-lang.org
# Or build from source:
git clone https://github.com/odin-lang/Odin.git
cd Odin
./build_odin.sh
# Add to PATH
export PATH="$PATH:$(pwd)"
# Verify
odin version
# odin version dev-2024-XX:XX (LLVM 17.0.X, ...)
|
First Project
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
mkdir hello && cd hello
cat > hello.odin << 'EOF'
package main
import "core:fmt"
main :: proc() {
fmt.println("Hello, Odin!")
}
EOF
odin run .
# Hello, Odin!
odin build . -out:hello
./hello
# Hello, Odin!
|
Where to Learn More
- Official site: odin-lang.org — language overview, documentation, download
- GitHub: github.com/odin-lang/Odin — compiler, standard library, vendor bindings
- Overview: odin-lang.org/docs/overview — the canonical language reference
- Discord: odin-lang.org/community — active Discord for questions
- Monthly blog posts: gingerBill’s website (gingerbill.org) — design decisions and philosophy
- Orca: orca-app.dev — the most polished Odin production project
- Example programs: github.com/odin-lang/examples — official examples repo
A Quick Reference
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
|
package main
// Procedure declaration
my_proc :: proc(x: int, y: f32) -> (result: string, ok: bool) { return }
// Struct
Point :: struct { x, y: f64 }
// Union
Shape :: union { Circle, Rectangle }
Circle :: struct { center: Point, radius: f64 }
Rectangle :: struct { min, max: Point }
// Enum
Direction :: enum { North, South, East, West }
// Bitset
Flags :: bit_set[enum{A, B, C}; u8]
// Generic procedure
identity :: proc(x: $T) -> T { return x }
// Generic struct
Pair :: struct($A, $B: typeid) { first: A, second: B }
// Array and slice
arr : [5]int = {1, 2, 3, 4, 5}
sl : []int = arr[1:4] // {2, 3, 4}
// Dynamic array
dyn : [dynamic]int
defer delete(dyn)
append(&dyn, 1, 2, 3)
// Map
m : map[string]int
defer delete(m)
m["key"] = 42
// Pointer
p := new(int)
defer free(p)
p^ = 100 // dereference with ^
// Type cast
f := f32(42)
i := int(f)
// Transmute (reinterpret bits)
bits := transmute(u32)f32(1.0)
// SOA
entities : #soa[100]struct { x, y: f32 }
entities[0].x = 1.0
// Defer in block
{
defer fmt.println("block end")
fmt.println("in block")
}
// or_return
value := some_fallible_proc() or_return
// or_else
val := some_fallible_proc() or_else 0
// Context override
context.allocator = my_arena
// All allocations in this scope use my_arena
// when (compile-time if)
when ODIN_OS == "windows" { /* windows code */ }
// #partial switch
#partial switch v in my_union {
case My_Type: // handle one variant
}
|
Conclusion
Odin is not trying to win a language war. It’s trying to solve a specific problem: writing high-performance systems code should not require a PhD in type theory, a compiler in your head to decode error messages, or accepting the chaos of C’s preprocessor.
The context system is genuinely novel and elegant. #soa is a feature that no other mainstream language offers built-in. The or_return / or_else patterns are ergonomically superior to Go’s if err != nil boilerplate without introducing exception-style control flow. The vendor library collection means you can build a game with raylib or SDL2 in minutes, not hours of binding setup.
The costs are real: pre-1.0 stability, a small ecosystem, no memory safety guarantees, and a package story that amounts to “copy C libraries and write bindings.” For systems programmers who understand and accept manual memory management — who have used C or Zig and want something more ergonomic without going all the way to Rust — Odin delivers.
If you’re building a game, a simulation, a data processing pipeline, or any performance-sensitive system where you want direct control over memory layout and allocation strategy, Odin is worth a week of serious exploration. The language is small enough that you can learn it thoroughly in that time, and surprising enough in its design choices that even experienced systems programmers will find something to think about.
Comments