PONY λ M2 Modula-2

C.CodeCompared.To/Odin

An interactive executable cheatsheet comparing C and Odin

C17 (GCC) Odin 2026-07a
Hello World & Building
Hello, World
Odin has no preprocessor, so there is no #include: import "core:fmt" binds a whole package to a name. main :: proc() is the entry point — the :: declares a compile-time constant, and a procedure is a constant whose value is code.
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
package main import "core:fmt" main :: proc() { fmt.println("Hello, World!") }
Odin's standard-library packages carry a core: prefix, and fmt.println appends the newline that printf makes you write every time. main returns nothing; the process exits 0 unless you call os.exit.
Compiling
Odin compiles a directory as a single package, so there is no per-file compile step and no linking. The -file flag is the exception that asks it to compile one file instead, which is what every example on this page uses.
/* One file: gcc -std=c17 -Wall -Wextra hello.c -o hello && ./hello Several files — compile each, then link: gcc -c a.c && gcc -c b.c && gcc a.o b.o -o app Optimized: gcc -O2 -std=c17 hello.c -o hello */ #include <stdio.h> int main(void) { printf("see the comment above\n"); return 0; }
// One file: // odin run hello.odin -file // A whole DIRECTORY is one package — no per-file compile, no linking step, // no makefile, no build system: // odin build . // Optimized: // odin build . -o:speed // Cross-compile with no extra toolchain: // odin build . -target:linux_arm64 package main import "core:fmt" main :: proc() { fmt.println("see the comment above") }
C compiles translation units separately and links object files, which is why makefiles exist. Because Odin sees the whole package at once, every .odin file beside this one is already in scope with no #include, no forward declarations, and no build system to configure.
No header files
Declaration order does not matter at file scope in Odin — main below calls helper, which is declared after it, with no prototype.
#include <stdio.h> /* A function must be declared before use — hence header files, include guards, and keeping .h and .c in sync by hand. */ int helper(int value); int main(void) { printf("%d\n", helper(20)); return 0; } int helper(int value) { return value + 22; }
package main import "core:fmt" main :: proc() { // helper is declared BELOW and needs no prototype fmt.println(helper(20)) } helper :: proc(value: int) -> int { return value + 22 }
That single rule removes header files, include guards, #pragma once, and the whole class of bugs where a header drifts out of sync with its implementation. There is exactly one declaration of anything, and the compiler sees the entire package at once.
Comments
Both languages have // and /* */. The difference is what happens when block comments are nested, which the two columns demonstrate.
#include <stdio.h> int main(void) { /* Block comments do NOT nest. /* This inner one ends the whole comment early */ // Line comment printf("done\n"); return 0; }
package main import "core:fmt" main :: proc() { /* Block comments DO nest: /* so this inner one is fine */ and commenting out a region is always safe. */ // Line comment fmt.println("done") }
Odin's block comments nest, so wrapping any region in /* … */ always does what you meant. In C the same edit terminates at the first inner */, leaving a stray */ and a confusing error somewhere below — which is why C programmers reach for #if 0 instead.
Declarations & the Preprocessor
int *a, b
Odin's FAQ calls this out by name as something C got wrong. In Odin the type is written once after the colon and applies to every name in the list, so a, b : ^int makes both pointers.
#include <stdio.h> int main(void) { int value = 1; /* a is a POINTER to int; b is a plain int. The * binds to the declarator, not the type. */ int *a = &value, b = 2; printf("%d %d\n", *a, b); return 0; }
package main import "core:fmt" main :: proc() { value := 1 // Both are ^int — the type applies to every name in the list a, b: ^int = &value, &value fmt.println(a^, b^) }
In C, int *a, b; declares a as a pointer and b as an int, because * binds to the declarator rather than the type. Putting the type on the right of a colon also removes C's spiral rule for reading declarations — there is no Odin equivalent of char *(*(*x[3])())[5].
No preprocessor
There is no preprocessor at all in Odin — no #define, no macros, no token pasting. A constant is a typed compile-time value declared with ::, and a function-like macro becomes an ordinary procedure.
#include <stdio.h> #define MAX_SIZE 100 #define SQUARE(x) ((x) * (x)) /* parens everywhere, or else */ int main(void) { int value = 3; /* The classic macro trap: SQUARE(value + 1) without the inner parens would expand to value + 1 * value + 1 */ printf("%d %d\n", MAX_SIZE, SQUARE(value + 1)); return 0; }
package main import "core:fmt" MAX_SIZE :: 100 // An ordinary procedure — arguments are evaluated once, and // the compiler type-checks it. No parentheses defense needed. square :: proc(value: int) -> int { return value * value } main :: proc() { value := 3 fmt.println(MAX_SIZE, square(value + 1)) }
That means arguments are evaluated exactly once (the C column shows the classic double-evaluation trap), the compiler type-checks the call, and a debugger can step into it. The things macros were genuinely needed for are covered by when, parametric polymorphism, and #force_inline.
Conditional compilation
when is a language construct, not a directive: the condition is a real typed expression over built-in constants such as ODIN_OS and ODIN_ARCH. Only the taken branch is compiled, but all of them must parse.
#include <stdio.h> int main(void) { #ifdef __APPLE__ printf("compiled for macOS\n"); #elif defined(__linux__) printf("compiled for Linux\n"); #else printf("compiled for something else\n"); #endif return 0; }
package main import "core:fmt" main :: proc() { // A real typed expression, not text substitution when ODIN_OS == .Darwin { fmt.println("compiled for macOS") } else when ODIN_OS == .Linux { fmt.println("compiled for Linux") } else { fmt.println("compiled for something else") } fmt.println("architecture:", ODIN_ARCH, "debug:", ODIN_DEBUG) }
#ifdef operates on text before the compiler runs, so a misspelled macro name silently takes the wrong branch and the untaken branch is never even parsed. In Odin a typo in the condition is a compile error, which is the whole point of moving this into the language.
typedef struct
A single :: declares the type and its name together, and a struct may reference ^Node before its own declaration finishes — so no forward declaration is needed. Field access through a pointer auto-dereferences, so there is no -> operator.
#include <stdio.h> /* The classic dance: a tag, a typedef, and often a forward declaration to allow self-reference. */ typedef struct Node Node; struct Node { int value; Node *next; }; int main(void) { Node second = {2, NULL}; Node first = {1, &second}; printf("%d %d\n", first.value, first.next->value); return 0; }
package main import "core:fmt" // One declaration. Self-reference works with no forward declaration. Node :: struct { value: int, next: ^Node, } main :: proc() { second := Node{value = 2, next = nil} first := Node{value = 1, next = &second} // Field access auto-dereferences: no -> operator exists fmt.println(first.value, first.next.value) }
The typedef struct Node Node; ritual disappears entirely. And because . works through any depth of pointer, first.next.value is what you write regardless of how many indirections are in the chain.
const vs compile-time constants
:: declares a genuine compile-time constant with no storage — note it being used below as an array length, which C's const int cannot do.
#include <stdio.h> /* const in C means "read-only view", not "compile-time value". This cannot size an array in standard C89, and const-correctness is a separate discipline layered on top of the type system. */ const int LIMIT = 100; static void show(const char *label, int value) { printf("%s=%d\n", label, value); } int main(void) { show("limit", LIMIT); return 0; }
package main import "core:fmt" // A genuine compile-time constant, usable as an array length LIMIT :: 100 show :: proc(label: string, value: int) { fmt.printfln("%s=%d", label, value) } main :: proc() { // LIMIT is usable where C would need a #define or an enum buffer: [LIMIT]u8 show("limit", LIMIT) fmt.println("buffer bytes:", len(buffer)) }
C's const is a read-only qualifier on a runtime object, which is why C programmers still reach for #define or an enum to size an array. Odin also has no const qualifier to propagate through signatures: procedure parameters are immutable by default, so const-correctness is the default rather than an annotation discipline.
Variables & Types
Uninitialized memory
Every Odin variable, struct, and array is zero-initialized — always, with no exceptions. The = --- form in the right column is how you opt out when you are about to overwrite the memory anyway.
#include <stdio.h> int main(void) { /* Reading an uninitialized local is undefined behavior. It may be 0, may be garbage, may differ per build. */ int declared_only = 0; /* you must remember this */ int values[4] = {0}; /* and this */ printf("%d %d\n", declared_only, values[3]); return 0; }
package main import "core:fmt" main :: proc() { // Every variable is zero-initialized. Always. No exceptions. declared_only: int values: [4]int // Opt OUT explicitly when you are about to overwrite it anyway scratch: [4]int = --- scratch[0] = 1 fmt.println(declared_only, values[3], scratch[0]) }
Uninitialized locals are one of C's most reliable sources of heisenbugs, since reading one is undefined behavior that may look fine in a debug build. Making zeroing the default and = --- the explicit escape hatch inverts which choice is the easy one.
Fixed-width types
Odin's built-in numeric names are the fixed-width names — i8 through i64, u8 through u64, plus i128/u128. Plain int and uint are pointer-sized.
#include <stdio.h> #include <stdint.h> #include <inttypes.h> int main(void) { /* int/long/short sizes are implementation-defined — <stdint.h> exists precisely to work around that. */ int32_t small = 2147483647; int64_t large = 9223372036854775807; uint32_t counter = 4294967295u; printf("%" PRId32 " %" PRId64 " %" PRIu32 "\n", small, large, counter); printf("sizeof(int)=%zu\n", sizeof(int)); return 0; }
package main import "core:fmt" main :: proc() { // The built-in names ARE the fixed-width names small: i32 = 2147483647 large: i64 = 9223372036854775807 counter: u32 = 4294967295 // And 128-bit integers exist without a library huge: i128 = 170141183460469231731687303715884105727 fmt.println(small, large, counter) fmt.println(huge) fmt.println("size_of(int) =", size_of(int)) }
There is no <stdint.h> because there is nothing to work around: the sizes are in the names rather than being implementation-defined. The PRId32 format-macro dance goes too, since %d and %v handle any integer width.
No implicit conversions
Odin performs no implicit numeric conversions — not signed to unsigned, not narrow to wide, not integer to float. Every mixed-type expression needs an explicit cast, which is why the right column writes int(unsigned_value).
#include <stdio.h> int main(void) { int signed_value = -1; unsigned int unsigned_value = 1; /* Usual arithmetic conversions: signed_value is converted to unsigned, so this comparison is FALSE. */ if (signed_value > unsigned_value) { printf("-1 > 1 -- the classic C surprise\n"); } /* Silent truncation, no diagnostic required */ char truncated = 300; printf("300 as char: %d\n", truncated); return 0; }
package main import "core:fmt" main :: proc() { signed_value: int = -1 unsigned_value: uint = 1 // Comparing them directly does not compile — no promotion rules if signed_value > int(unsigned_value) { fmt.println("never printed: -1 is not > 1") } else { fmt.println("compared correctly, because the cast is explicit") } // truncated: i8 = 300 // compile error: 300 does not fit truncated: i8 = 127 fmt.println("in range:", truncated) }
C's usual arithmetic conversions produce its most famous gotcha: the left column's signed_value > unsigned_value converts -1 to a huge unsigned value, so the comparison is false. Odin has no conversion rules to get wrong, and constants are range-checked at each use site, so i8 = 300 is rejected rather than silently truncated.
Distinct types
Odin offers both behaviors and makes you choose: Temperature :: f64 is an alias with C's typedef semantics, while distinct f64 creates a separate type that shares the representation and operators but will not convert implicitly.
#include <stdio.h> /* typedef creates an ALIAS, not a new type — the compiler will happily let you assign one to the other. */ typedef double Celsius; typedef double Fahrenheit; int main(void) { Celsius indoors = 21.5; Fahrenheit outdoors = indoors; /* compiles: both are double */ printf("%.1f\n", outdoors); return 0; }
package main import "core:fmt" // An alias, with C's typedef semantics Temperature :: f64 // A genuinely separate type Celsius :: distinct f64 Fahrenheit :: distinct f64 to_fahrenheit :: proc(degrees: Celsius) -> Fahrenheit { return Fahrenheit(f64(degrees) * 9 / 5 + 32) } main :: proc() { indoors: Celsius = 21.5 // outdoors: Fahrenheit = indoors // rejected at compile time outdoors := to_fahrenheit(indoors) fmt.printfln("%.1fC is %.1fF", f64(indoors), f64(outdoors)) }
C's typedef only ever creates an alias, so assigning a Celsius to a Fahrenheit compiles happily. distinct is how unit-mismatch and handle-mixup bugs — passing a file descriptor where a socket was expected, both being int — become compile errors rather than review comments.
Pointers
Pointer basics
Both of Odin's pointer spellings come from Pascal: the type is ^int rather than int *, and dereference is postfix pointer^ rather than prefix *pointer. Address-of is & as in C, and NULL is nil.
#include <stdio.h> #include <stddef.h> int main(void) { int value = 10; int *pointer = &value; *pointer = 42; /* prefix dereference */ printf("%d\n", value); printf("%d\n", pointer == NULL); return 0; }
package main import "core:fmt" main :: proc() { value := 10 pointer: ^int = &value pointer^ = 42 // POSTFIX dereference fmt.println(value) fmt.println(pointer == nil) }
Postfix dereference reads left to right in a chain — a.b^.c — and removes the parsing ambiguity between multiplication and dereference that makes C declarations hard to read.
Pointer arithmetic
Pointer arithmetic is not an operator in Odin. Offsetting a pointer requires an explicit mem.ptr_offset call, and the idiomatic alternative is a slice, which carries its own length.
#include <stdio.h> int main(void) { int numbers[4] = {10, 20, 30, 40}; int *cursor = numbers; /* Arithmetic is an operator, with nothing checking bounds */ cursor += 2; printf("%d\n", *cursor); /* Walking off the end is undefined behavior, silently */ printf("%d\n", *(numbers + 2)); return 0; }
package main import "core:fmt" import "core:mem" main :: proc() { numbers := [4]int{10, 20, 30, 40} // Pointer arithmetic is NOT an operator — it is an explicit call cursor := mem.ptr_offset(&numbers[0], 2) fmt.println(cursor^) // The idiomatic way is a slice, which carries its length view := numbers[2:] fmt.println(view[0], "remaining:", len(view)) }
Making raw pointer walking a named, greppable call rather than a + means it stops happening by accident. Nearly every C pointer-walking loop becomes a slice in Odin, which knows how many elements it has and is bounds-checked in debug builds.
Out parameters
Odin procedures return multiple values, and the results can be named — so the C habit of writing through pointer parameters is usually unnecessary.
#include <stdio.h> /* The only way to return two things: write through pointers */ static void divide(int numerator, int denominator, int *quotient, int *remainder) { *quotient = numerator / denominator; *remainder = numerator % denominator; } int main(void) { int quotient, remainder; divide(17, 5, &quotient, &remainder); printf("%d remainder %d\n", quotient, remainder); return 0; }
package main import "core:fmt" // Just return both divide :: proc(numerator, denominator: int) -> (quotient, remainder: int) { quotient = numerator / denominator remainder = numerator % denominator return } main :: proc() { quotient, remainder := divide(17, 5) fmt.println(quotient, "remainder", remainder) }
Removing out-parameters removes a family of bugs with them: a null out-pointer, an uninitialized one, or forgetting that a function writes through it. When an Odin procedure does take a pointer it is because it genuinely mutates something, and &value at the call site announces that.
Function pointers
A procedure type is written exactly like a signature with the body omitted, so Transform :: proc(value: int) -> int is the whole declaration. Passing one needs no address-of operator.
#include <stdio.h> /* The declaration syntax C is most mocked for */ typedef int (*Transform)(int); static int double_it(int value) { return value * 2; } static int apply_to(Transform operation, int value) { return operation(value); } int main(void) { printf("%d\n", apply_to(double_it, 21)); return 0; }
package main import "core:fmt" // A procedure type is written exactly like a signature, body omitted Transform :: proc(value: int) -> int double_it :: proc(value: int) -> int { return value * 2 } apply_to :: proc(operation: Transform, value: int) -> int { return operation(value) } main :: proc() { fmt.println(apply_to(double_it, 21)) // An anonymous procedure literal, assigned like any value triple := proc(value: int) -> int { return value * 3 } fmt.println(apply_to(triple, 21)) }
No int (*name)(int) spiral, and no typedef needed to make it legible. Procedure literals exist too, but they are not closures — they cannot capture locals, which keeps a procedure value a bare code pointer, exactly the machine-level thing a C function pointer is.
Arrays & Slices
Arrays decay to pointers
An Odin slice, written []T, is a pointer and a length traveling together as one value. A procedure taking []int therefore needs no separate count parameter, and numbers[1:3] produces a sub-range view.
#include <stdio.h> /* The parameter is NOT an array — it is int*, and sizeof inside the function gives the pointer size, not the array's. The length has to travel as a second parameter. */ static int total(const int *values, size_t count) { int sum = 0; for (size_t index = 0; index < count; index++) { sum += values[index]; } return sum; } int main(void) { int numbers[4] = {10, 20, 30, 40}; printf("%d\n", total(numbers, sizeof numbers / sizeof numbers[0])); return 0; }
package main import "core:fmt" // A slice carries its own length — one parameter, always correct total :: proc(values: []int) -> int { sum := 0 for value in values { sum += value } return sum } main :: proc() { numbers := [4]int{10, 20, 30, 40} fmt.println(total(numbers[:])) fmt.println(total(numbers[1:3])) // a sub-range works too }
A C array parameter is a lie: it decays to a pointer, sizeof inside the function measures the pointer rather than the array, and the length must be passed alongside and kept in sync by hand. This is the root of more C bugs than any other single rule, and slices remove it outright. Odin arrays themselves never decay — [4]int is a value that copies on assignment.
Bounds checking
Odin rejects a constant out-of-range index at compile time, and checks a runtime index in debug builds — aborting with the index, the length, and the source location. #no_bounds_check disables the check for a specific block.
#include <stdio.h> int main(void) { int numbers[4] = {10, 20, 30, 40}; /* Reading numbers[7] is undefined behavior. No diagnostic, no trap — it silently reads whatever is next in memory. */ printf("%d\n", numbers[3]); return 0; }
package main import "core:fmt" main :: proc() { numbers := [4]int{10, 20, 30, 40} // numbers[7] is a COMPILE error for a constant index. // A runtime index is bounds-checked in debug builds and // aborts with the file, line, index, and length. index := 3 fmt.println(numbers[index]) // Disable the check for a hot loop you have proven safe: #no_bounds_check { fmt.println(numbers[index]) } }
Release builds (-o:speed) drop the checks entirely, so you pay for safety exactly where you have not proven you can do without it. In C the same access is undefined behavior with no diagnostic and no trap — it silently reads whatever is adjacent in memory.
Growable arrays
[dynamic]T is a growable array built into the language: append is amortized, len and cap are distinct, and the realloc failure path is handled for you. It needs a delete because Odin has no garbage collector.
#include <stdio.h> #include <stdlib.h> int main(void) { size_t capacity = 2, count = 0; int *numbers = malloc(capacity * sizeof *numbers); if (!numbers) return 1; for (int value = 1; value <= 4; value++) { if (count == capacity) { capacity *= 2; int *grown = realloc(numbers, capacity * sizeof *numbers); if (!grown) { free(numbers); return 1; } numbers = grown; } numbers[count++] = value * 10; } printf("%zu: %d %d\n", count, numbers[0], numbers[3]); free(numbers); return 0; }
package main import "core:fmt" main :: proc() { numbers: [dynamic]int defer delete(numbers) for value in 1 ..= 4 { append(&numbers, value * 10) } fmt.println(len(numbers), numbers[0], numbers[3]) fmt.println("capacity:", cap(numbers)) }
Every C project eventually writes the left column, and every one writes it slightly differently. The dynamic array allocates from context.allocator, so an arena or pool applies to it automatically, and numbers[:] yields a slice for any []int procedure.
Hash maps
map[K]V is built into the language. The two-value read value, found := scores[key] is how you distinguish a missing key from one whose value is genuinely zero.
#include <stdio.h> #include <string.h> /* The standard library has no hash map. Every project either writes one, vendors uthash/stb_ds, or does a linear scan. */ int main(void) { const char *names[3] = {"alice", "bob", "carol"}; int scores[3] = {1, 2, 3}; for (int index = 0; index < 3; index++) { if (strcmp(names[index], "bob") == 0) { printf("bob=%d\n", scores[index]); } } return 0; }
package main import "core:fmt" main :: proc() { scores := make(map[string]int) defer delete(scores) scores["alice"] = 1 scores["bob"] = 2 scores["carol"] = 3 // The comma-ok read distinguishes absent from present-but-zero value, found := scores["bob"] fmt.println("bob =", value, "found:", found) fmt.println("entries:", len(scores)) }
C has no hash map in its standard library, which is why every non-trivial C codebase carries uthash, stb_ds, or a hand-rolled table. Odin's allocates from context.allocator. Watch the naming: delete_key(&scores, key) removes one entry, while delete(scores) frees the whole map.
Array arithmetic
Odin's fixed-size arrays are numeric vectors: +, -, *, and / apply element-wise. The .xyzw/.rgba swizzles and the matrix[R, C]T type come from shader languages.
#include <stdio.h> int main(void) { float left[3] = {1, 2, 3}; float right[3] = {10, 20, 30}; float sum[3]; /* No element-wise operators — write the loop and hope the optimizer vectorizes it. */ for (int index = 0; index < 3; index++) { sum[index] = left[index] + right[index]; } printf("%.0f %.0f %.0f\n", sum[0], sum[1], sum[2]); return 0; }
package main import "core:fmt" main :: proc() { left := [3]f32{1, 2, 3} right := [3]f32{10, 20, 30} // Element-wise, lowered to SIMD where the target supports it sum := left + right scaled := left * 2 fmt.println(sum, scaled) // Swizzles, borrowed from GLSL position := [3]f32{1, 2, 3} fmt.println(position.zyx, position.xy) // And a real matrix type transform := matrix[2, 2]f32{1, 2, 3, 4} fmt.println(transform * transform) }
The arithmetic lowers to SIMD instructions where the target supports them. GLSL is listed among Odin's influences in its FAQ, and for the graphics and simulation work the language targets this replaces a pile of hand-written vector macros or a C++ operator-overloading layer.
Strings
Strings are not NUL-terminated
An Odin string is a pointer and a length — the same structural upgrade slices give arrays. There is no NUL sentinel, so len is O(1) and slicing is a free view rather than a copy.
#include <stdio.h> #include <string.h> int main(void) { /* A string is a pointer to bytes with a sentinel at the end. Length is O(n), and a missing NUL runs off into memory. */ const char *greeting = "Hello, C!"; printf("%s\n", greeting); printf("length: %zu\n", strlen(greeting)); return 0; }
package main import "core:fmt" main :: proc() { // A string is a POINTER + LENGTH. No sentinel, len is O(1). greeting := "Hello, Odin!" fmt.println(greeting) fmt.println("length:", len(greeting)) // Slicing is free — it is a view, not a copy fmt.println(greeting[7:]) }
This also means a string may safely contain a zero byte, and there is no strlen that can walk off the end of an unterminated buffer. The trade is that you cannot hand one directly to a C function — which is what the next row is about.
The cstring type
Odin keeps NUL-terminated strings as a separate type, cstring, used only at the C boundary. Converting between it and string is explicit: string(c) is O(n), and clone_to_cstring allocates because adding the terminator requires a copy.
#include <stdio.h> #include <string.h> int main(void) { char buffer[32]; /* strcpy/strcat/sprintf are the classic overflow sites; the n-variants help but still need manual termination. */ snprintf(buffer, sizeof buffer, "%s, %s", "Hello", "world"); printf("%s (%zu)\n", buffer, strlen(buffer)); return 0; }
package main import "core:fmt" import "core:strings" main :: proc() { // cstring is a SEPARATE type for C interop — NUL-terminated from_c: cstring = "Hello, world" // Converting to an Odin string is explicit and O(n) as_odin := string(from_c) fmt.println(as_odin, len(as_odin)) // And back again, which allocates the NUL terminator back := strings.clone_to_cstring(as_odin) defer delete(back) fmt.println(back) }
Because the two are different types, you cannot accidentally pass a length-carrying Odin string to a C function expecting a sentinel. That distinction is doing the work that strcpy/strcat/sprintf overflow bugs come from the absence of.
Building strings
Odin gives you two shapes. strings.Builder grows as needed and is destroyed explicitly; fmt.bprintf formats into a caller-supplied buffer with no allocation, returning the slice actually written.
#include <stdio.h> int main(void) { char buffer[64]; int written = 0; for (int index = 1; index <= 5; index++) { int count = snprintf(buffer + written, sizeof buffer - (size_t)written, "%d ", index); if (count < 0 || (size_t)count >= sizeof buffer - (size_t)written) break; written += count; } printf("%s\n", buffer); return 0; }
package main import "core:fmt" import "core:strings" main :: proc() { // Growing buffer, when you do not know the size builder := strings.builder_make() defer strings.builder_destroy(&builder) for index in 1 ..= 5 { fmt.sbprintf(&builder, "%d ", index) } fmt.println(strings.to_string(builder)) // Or format into a fixed buffer with zero allocations backing: [64]byte fmt.println(fmt.bprintf(backing[:], "%d-%d", 1, 2)) }
The C column has to track the write offset, recompute the remaining space, and check snprintf's return for truncation on every call — three chances to get it wrong per iteration. Neither Odin form can overflow, and neither needs you to compute a remaining length by hand.
String utilities
strings.split returns a slice of views into the original string — only the outer slice allocates, so the pieces point into sentence and must not be freed individually.
#include <stdio.h> #include <string.h> int main(void) { const char *sentence = "alpha,beta,gamma"; /* strtok mutates its input and keeps global state. There is no split that returns a list. */ char copy[32]; strncpy(copy, sentence, sizeof copy - 1); copy[sizeof copy - 1] = '\0'; for (char *piece = strtok(copy, ","); piece; piece = strtok(NULL, ",")) { printf("[%s]", piece); } printf("\n"); return 0; }
package main import "core:fmt" import "core:strings" main :: proc() { sentence := "alpha,beta,gamma" // Allocates a slice of VIEWS into the original — no copying pieces, err := strings.split(sentence, ",") if err == nil { defer delete(pieces) for piece in pieces { fmt.printf("[%s]", piece) } fmt.println() } fmt.println(strings.contains(sentence, "beta")) fmt.println(strings.index(sentence, "gamma")) }
That is possible precisely because Odin strings carry a length and need no terminator. strtok, by contrast, mutates the string you pass it and keeps hidden static state, which makes it neither reentrant nor thread-safe — a famous piece of C API design.
Memory & Allocators
malloc and free
new(T) returns a typed ^T rather than a void *, and the memory is zeroed. free releases it, and defer lets you write that release on the line immediately after the allocation.
#include <stdio.h> #include <stdlib.h> int main(void) { /* malloc returns uninitialized memory; calloc zeroes it */ int *value = malloc(sizeof *value); if (!value) return 1; *value = 42; printf("%d\n", *value); free(value); return 0; }
package main import "core:fmt" main :: proc() { // new() zeroes the memory and returns a TYPED pointer value := new(int) defer free(value) value^ = 42 fmt.println(value^) }
Three quiet improvements over malloc: no cast, no sizeof to get wrong, and no uninitialized bytes. Placing the defer beside the allocation makes an unmatched allocation visible at a glance — and the rows below show why you often will not write free at all.
goto cleanup vs defer
defer runs a statement at scope exit, by whatever path the code leaves — so each cleanup is written next to the acquisition it belongs to, and deferred statements run in reverse order.
#include <stdio.h> #include <stdlib.h> int main(void) { int status = 1; int *first = NULL, *second = NULL; first = malloc(sizeof *first); if (!first) goto done; second = malloc(sizeof *second); if (!second) goto cleanup_first; *first = 1; *second = 2; printf("%d %d\n", *first, *second); status = 0; free(second); cleanup_first: free(first); done: return status; }
package main import "core:fmt" main :: proc() { // Each cleanup sits beside its allocation; both run at scope // exit in reverse order, whatever path the code takes out. first := new(int) defer free(first) second := new(int) defer free(second) first^ = 1 second^ = 2 fmt.println(first^, second^) }
The goto cleanup ladder is idiomatic C precisely because C has nothing better, and it scales badly: every new allocation means a new label and an audit of every earlier jump. With defer, adding a fourth allocation changes nothing about the first three. Note that a defer the compiler proves unreachable — after os.exit — is a compile error, not a silent leak.
The implicit context
Every Odin procedure receives an implicit context carrying an allocator, a temp allocator, and a logger. You never declare or pass it. Assigning context.allocator redirects allocation for the current scope and everything it calls — which is why collect below uses the arena despite taking no allocator parameter.
#include <stdio.h> #include <stdlib.h> /* To use a custom allocator, every function in the call chain must take it as a parameter — or you replace malloc globally with a linker trick. There is no scoped mechanism. */ static int *collect(void *(*allocate)(size_t), size_t count) { int *values = allocate(count * sizeof(int)); if (values) values[0] = 1; return values; } int main(void) { int *values = collect(malloc, 3); if (!values) return 1; printf("%d\n", values[0]); free(values); return 0; }
package main import "core:fmt" import "core:mem" // Takes no allocator parameter and knows nothing about arenas collect :: proc(count: int) -> []int { values := make([]int, count) values[0] = 1 return values } main :: proc() { backing: [1024]byte arena: mem.Arena mem.arena_init(&arena, backing[:]) // Redirects this scope AND everything it calls context.allocator = mem.arena_allocator(&arena) values := collect(3) fmt.println("from the arena:", values) fmt.println("no individual frees — the arena owns it all") }
This is the feature that most changes how you structure a program, and it has no C equivalent short of replacing malloc globally with a linker trick. Swapping in an arena, a pool, or a deliberately failing allocator for an entire subsystem is a one-line change, where the C column has to thread a function pointer through every frame in the call chain.
Arena allocation
An arena hands out bumped pointers from one fixed buffer and reclaims everything at once. Installing it as context.allocator means make, append, maps, and the whole standard library allocate from it too.
#include <stdio.h> #include <stddef.h> /* Arenas are a well-known C technique — but you write them, and nothing in the language or stdlib knows about them. */ static unsigned char backing[1024]; static size_t used = 0; static void *arena_alloc(size_t size) { if (used + size > sizeof backing) return NULL; void *pointer = backing + used; used += size; return pointer; } int main(void) { int *values = arena_alloc(3 * sizeof *values); if (!values) return 1; values[0] = 7; printf("%d, used %zu bytes\n", values[0], used); /* One reset frees everything: used = 0; */ return 0; }
package main import "core:fmt" import "core:mem" main :: proc() { backing: [1024]byte arena: mem.Arena mem.arena_init(&arena, backing[:]) context.allocator = mem.arena_allocator(&arena) // Everything below allocates from the arena, including // make(), append(), maps, and the whole standard library. values := make([]int, 3) values[0] = 7 names: [dynamic]string append(&names, "alpha", "beta") fmt.println(values[0], names) fmt.println("arena bytes used:", arena.offset) free_all(context.allocator) // reset the whole arena at once }
Arenas are not a new idea — good C codebases have used them for decades. What Odin changes is that the arena is plugged into the language, so library code you did not write still allocates from it. In C your hand-rolled arena is invisible to the standard library, and strdup and friends keep going to the heap regardless.
Finding leaks
Because an allocator is an ordinary value, one can wrap another. Tracking_Allocator records every allocation with the source location that made it, and its allocation_map holds whatever was never freed.
#include <stdio.h> #include <stdlib.h> /* Leak detection is an EXTERNAL tool: valgrind, ASan (-fsanitize=address), or a custom malloc shim. None of it is available from inside the language. */ int main(void) { int *leaked = malloc(sizeof *leaked); if (!leaked) return 1; *leaked = 42; printf("%d\n", *leaked); /* free deliberately omitted */ return 0; }
package main import "core:fmt" import "core:mem" main :: proc() { tracker: mem.Tracking_Allocator mem.tracking_allocator_init(&tracker, context.allocator) defer mem.tracking_allocator_destroy(&tracker) context.allocator = mem.tracking_allocator(&tracker) leaked := new(int) leaked^ = 42 fmt.println(leaked^) // free(leaked) deliberately omitted for _, entry in tracker.allocation_map { fmt.printfln("leaked %d bytes at %v", entry.size, entry.location) } }
Leak detection in C is an external tool — Valgrind, ASan, or a custom malloc shim — none of it reachable from inside the language. Odin's version needs no special build, works in a release binary, and can be scoped to one subsystem while the rest of the program runs untracked.
Structs
Structs & initialization
Odin struct literals name their fields with =, a partial literal zero-fills the rest, and an undeclared struct is zero-initialized. Structs also compare with == and print with %v.
#include <stdio.h> typedef struct { double x, y; } Point; int main(void) { Point origin = {.x = 3, .y = 4}; Point zero = {0}; /* Structs cannot be compared with == */ printf("(%.1f, %.1f) (%.1f, %.1f)\n", origin.x, origin.y, zero.x, zero.y); return 0; }
package main import "core:fmt" Point :: struct { x: f64, y: f64, } main :: proc() { origin := Point{x = 3, y = 4} zero: Point // zero-initialized automatically // Structs DO compare with == fmt.println(origin == Point{x = 3, y = 4}) // And print themselves, field names included fmt.printfln("%v %v", origin, zero) }
Both of those last two are things C cannot do: memcmp is wrong for comparison in the presence of padding, so C makes you write a field-by-field comparison, and printing needs a hand-written function per type.
Memory layout
#packed strips padding and #align(N) forces a minimum alignment — both part of the language rather than compiler extensions. size_of, align_of, and offset_of inspect the result.
#include <stdio.h> #include <stddef.h> struct Normal { unsigned char flag; unsigned int value; }; /* Packing is a COMPILER EXTENSION, not standard C */ #pragma pack(push, 1) struct Packed { unsigned char flag; unsigned int value; }; #pragma pack(pop) int main(void) { printf("normal: %zu\n", sizeof(struct Normal)); printf("packed: %zu\n", sizeof(struct Packed)); printf("offset: %zu\n", offsetof(struct Normal, value)); return 0; }
package main import "core:fmt" Normal :: struct { flag: u8, value: u32, } Packed :: struct #packed { flag: u8, value: u32, } Aligned :: struct #align(16) { flag: u8, value: u32, } main :: proc() { fmt.println("normal: ", size_of(Normal)) fmt.println("packed: ", size_of(Packed)) fmt.println("aligned:", size_of(Aligned), "align", align_of(Aligned)) fmt.println("offset: ", offset_of(Normal, value)) }
#pragma pack and __attribute__((packed)) differ between GCC, Clang, and MSVC, which is a portability headache for anyone parsing a binary format. Having the layout controls and the introspection in the language means you can assert the layout is what you think it is.
Composing structs
Applying using to a struct field promotes that field's members into the outer struct, so worker.name resolves through to worker.identity.name — while the full path keeps working.
#include <stdio.h> typedef struct { const char *name; } Named; typedef struct { Named identity; /* must be reached through the field name */ int salary; } Employee; int main(void) { Employee worker = {{"Ada"}, 100}; printf("%s %d\n", worker.identity.name, worker.salary); return 0; }
package main import "core:fmt" Named :: struct { name: string, } Employee :: struct { using identity: Named, // fields promoted into Employee salary: int, } main :: proc() { worker := Employee{identity = Named{name = "Ada"}, salary = 100} // Reachable directly, and the full path still works fmt.println(worker.name, worker.salary) fmt.println(worker.identity.name) // The embedded value is still a plain field you can pass along show :: proc(named: Named) { fmt.println("named:", named.name) } show(worker.identity) }
This is composition without the anonymous-struct-member extension that C compilers offer non-portably. The embedded field keeps a real name, so two embedded values of the same type do not collide, and it is still an ordinary field you can pass to a procedure expecting a Named.
Enums, Unions & Bit Sets
Untagged unions
An Odin union is tagged: it stores which variant is live and checks on access. The type-assertion form shape.(Circle) returns the value plus an ok flag rather than reinterpreting bytes.
#include <stdio.h> /* A C union has no tag. Keeping `kind` correct is entirely on you, and reading the wrong member is undefined behavior. */ typedef enum { CIRCLE, RECTANGLE } ShapeKind; typedef struct { ShapeKind kind; union { struct { double radius; } circle; struct { double width, height; } rectangle; } data; } Shape; int main(void) { Shape shape = {CIRCLE, {.circle = {2.0}}}; /* Nothing stops you reading shape.data.rectangle.width here */ printf("%.1f\n", shape.data.circle.radius); return 0; }
package main import "core:fmt" Circle :: struct { radius: f64 } Rectangle :: struct { width, height: f64 } // A TAGGED union — the tag is stored and checked for you Shape :: union { Circle, Rectangle, } main :: proc() { shape: Shape = Circle{radius = 2} // Reading the wrong variant returns ok = false, not garbage circle, is_circle := shape.(Circle) fmt.println("circle?", is_circle, "radius:", circle.radius) _, is_rectangle := shape.(Rectangle) fmt.println("rectangle?", is_rectangle) }
A C union shares storage with no record of which member is live, so the kind field is a convention the compiler never verifies and reading the wrong member is undefined behavior. An Odin union also has a nil state meaning "no variant set", which a C tagged struct can only fake with an extra enum member.
Dispatching on the variant
switch specific in shape binds a differently typed variable in each branch — inside case Circle the name specific is a Circle, so reaching for a Rectangle field would not compile.
#include <stdio.h> typedef enum { CIRCLE, RECTANGLE } ShapeKind; typedef struct { ShapeKind kind; double a, b; } Shape; static double area(Shape shape) { switch (shape.kind) { case CIRCLE: return 3.14159 * shape.a * shape.a; case RECTANGLE: return shape.a * shape.b; } return 0; /* adding a third kind silently reaches here */ } int main(void) { Shape circle = {CIRCLE, 2, 0}; printf("%.2f\n", area(circle)); return 0; }
package main import "core:fmt" Circle :: struct { radius: f64 } Rectangle :: struct { width, height: f64 } Shape :: union { Circle, Rectangle, } area :: proc(shape: Shape) -> f64 { // `specific` has a DIFFERENT type in each branch switch specific in shape { case Circle: return 3.14159 * specific.radius * specific.radius case Rectangle: return specific.width * specific.height } return 0 } main :: proc() { fmt.printfln("%.2f", area(Circle{radius = 2})) fmt.printfln("%.2f", area(Rectangle{width = 3, height = 4})) }
The C version reuses fields a and b for different meanings per kind, a common compromise the compiler cannot help with. Adding a third variant to an Odin union turns every non-exhaustive switch into a compile error, where the C switch quietly falls through to return 0.
Enums
Odin enum members are namespaced under their type — Color.Red, or just .Red where the type is already known. The type is closed, so len(Color), max(Color), and for color in Color operate on the type itself.
#include <stdio.h> /* C enum members leak into the enclosing scope, and the type is really just int — any value is assignable. */ typedef enum { RED, GREEN, BLUE } Color; int main(void) { Color chosen = GREEN; /* Printing the NAME needs a hand-written table */ const char *names[] = {"RED", "GREEN", "BLUE"}; printf("%s %d\n", names[chosen], chosen); Color bogus = (Color)99; /* perfectly legal */ printf("%d\n", bogus); return 0; }
package main import "core:fmt" // Members are namespaced under the type, so two enums may // both have a Red without colliding. Color :: enum { Red, Green, Blue, } main :: proc() { chosen := Color.Green // %v prints the NAME with no table to maintain fmt.println(chosen, int(chosen)) fmt.println("count:", len(Color), "max:", max(Color)) for color in Color { fmt.print(color, "") } fmt.println() }
Three fixes over C. Members do not leak into the enclosing scope, so two enums may both have a Red. %v prints the member name, removing the parallel string table that always drifts out of sync. And the type is genuinely closed, so Color(99) has no Odin equivalent.
Bit sets vs bit flags
bit_set[Flag; u8] is a real set type over an enum: the compiler assigns the bits, in tests membership, card counts, and the set operators replace hand-written masking. The ; u8 pins the backing width.
#include <stdio.h> /* Flags are hand-assigned powers of two, and the whole thing is really just an unsigned int. */ #define READ (1u << 0) #define WRITE (1u << 1) #define EXECUTE (1u << 2) int main(void) { unsigned int granted = READ | WRITE; printf("can read: %d\n", (granted & READ) != 0); printf("can exec: %d\n", (granted & EXECUTE) != 0); printf("raw: %u\n", granted); return 0; }
package main import "core:fmt" Flag :: enum { Read, Write, Execute, } // The compiler assigns the bits; ; u8 pins the backing type Permission :: bit_set[Flag; u8] main :: proc() { granted: Permission = {.Read, .Write} fmt.println("can read:", .Read in granted) fmt.println("can exec:", .Execute in granted) fmt.println("count:", card(granted)) fmt.println("as a value:", granted, "size:", size_of(Permission)) all: Permission = {.Read, .Write, .Execute} fmt.println("missing:", all &~ granted) }
The C idiom works, but the shifts are yours to assign correctly, any integer can be assigned in, and printing the flags means writing a decoder. Pinning the backing type matters when the layout has to match a file or wire format.
Bit fields
Odin's bit_field names its backing type up front — bit_field u32 here — and each member's width after a |. That fixes the layout at the declaration.
#include <stdio.h> /* C bit-fields have implementation-defined layout: bit order, straddling, and the allowed base types all vary by compiler. */ struct Header { unsigned int version : 4; unsigned int kind : 4; unsigned int length : 16; }; int main(void) { struct Header header = {3, 7, 512}; printf("%u %u %u (%zu bytes)\n", header.version, header.kind, header.length, sizeof header); return 0; }
package main import "core:fmt" // Backing type is explicit, so the layout is defined Header :: bit_field u32 { version: u8 | 4, kind: u8 | 4, length: u16 | 16, } main :: proc() { header := Header{version = 3, kind = 7, length = 512} fmt.println(header.version, header.kind, header.length) fmt.println("size:", size_of(Header)) fmt.printfln("%v", header) }
C bit-fields are notoriously under-specified: the standard leaves bit ordering, whether a field may straddle a storage unit, and the permitted base types to the implementation. A struct that parses a packet correctly under GCC may not under MSVC, which is why serious C code masks and shifts by hand instead.
Control Flow
Switch fallthrough
Odin inverts C's default: cases do not fall through, and fallthrough is written explicitly when you want it. Multiple values share a branch with a comma, ranges use ..=, and the default branch is a bare case:.
#include <stdio.h> int main(void) { int grade = 'B'; switch (grade) { case 'A': printf("excellent\n"); /* fall through -- forgetting break is the classic bug */ case 'B': case 'C': printf("passing\n"); break; default: printf("other\n"); } return 0; }
package main import "core:fmt" main :: proc() { grade := 'B' switch grade { case 'A': fmt.println("excellent") fallthrough // explicit, and therefore visible case 'B', 'C': fmt.println("passing") case 'D' ..= 'F': // ranges, which C lacks fmt.println("failing") case: fmt.println("other") } }
Inverting the default removes the missing-break bug entirely. Ranges have no C equivalent at all, and a case that declares a variable needs no braces around it.
Loops
One for keyword covers four shapes: a range (0 ..< 3 excludes the bound, ..= includes it), a C-style three-clause loop, a bare condition, and iteration over a collection. There is no ++ operator.
#include <stdio.h> int main(void) { for (int index = 0; index < 3; index++) { printf("%d ", index); } printf("\n"); int numbers[3] = {10, 20, 30}; /* No range-for: you index by hand and pass the length around */ for (size_t index = 0; index < 3; index++) { printf("%d ", numbers[index]); } printf("\n"); return 0; }
package main import "core:fmt" main :: proc() { for index in 0 ..< 3 { fmt.print(index, "") } fmt.println() numbers := [3]int{10, 20, 30} // Ranging over the collection — value FIRST, index second for value, index in numbers { fmt.printf("%d:%d ", index, value) } fmt.println() }
Watch the loop-variable order when ranging over a collection: for value, index in numbers puts the element first and the index second. That is the reverse of most languages and the single easiest thing to get backwards when starting out.
goto & labeled break
Odin labels the loop rather than a point in the function, so break search and continue search name which loop to act on.
#include <stdio.h> int main(void) { /* Escaping nested loops means a flag or a goto */ for (int row = 0; row < 3; row++) { for (int column = 0; column < 3; column++) { if (row * column > 2) { printf("stopped at %d,%d\n", row, column); goto finished; } } } finished: printf("done\n"); return 0; }
package main import "core:fmt" main :: proc() { // Label the LOOP, then name it in the break search: for row in 0 ..< 3 { for column in 0 ..< 3 { if row * column > 2 { fmt.printfln("stopped at %d,%d", row, column) break search } } } fmt.println("done") }
The jump target is therefore always a loop boundary, which keeps the control flow local and readable. Between this and defer, the two legitimate uses of goto in C — escaping nested loops and cleanup ladders — both have first-class replacements.
Scoped initializers
The if name := expression; condition form declares a temporary scoped to the branch that needs it, and the same form works on switch.
#include <stdio.h> static int compute(int value) { return value * 2; } int main(void) { /* The temporary outlives the branch it was needed for */ int doubled = compute(60); if (doubled > 100) { printf("large: %d\n", doubled); } /* doubled is still in scope here */ return 0; }
package main import "core:fmt" compute :: proc(value: int) -> int { return value * 2 } main :: proc() { // The temporary is scoped to the branch that needs it if doubled := compute(60); doubled > 100 { fmt.println("large:", doubled) } // doubled does not exist here // Same form on switch switch value := compute(5); value { case 10: fmt.println("ten") case: fmt.println("other:", value) } }
C99 allows a declaration in a for initializer but not in an if, so the temporary leaks into the enclosing scope where it can be misused later. A small thing, but it measurably reduces the number of live names in a function.
Functions & Procedures
Default & named arguments
Odin has default parameter values (greeting := "Hello") and call-by-name, so an argument can be set by name while the ones before it keep their defaults.
#include <stdio.h> /* C has neither. The workarounds are a variadic function, an options struct, or a family of _ex suffixed functions. */ static void greet_full(const char *name, const char *greeting, const char *punctuation) { printf("%s, %s%s\n", greeting, name, punctuation); } static void greet(const char *name) { greet_full(name, "Hello", "!"); } int main(void) { greet("Ada"); greet_full("Bob", "Hello", "?"); return 0; }
package main import "core:fmt" greet :: proc(name: string, greeting := "Hello", punctuation := "!") { fmt.printfln("%s, %s%s", greeting, name, punctuation) } main :: proc() { greet("Ada") // Name an argument to skip the ones before it greet("Bob", punctuation = "?") greet("Carol", greeting = "Good morning", punctuation = "?") }
This removes the C habit of shipping foo, foo_ex, and foo_ex2, or an options struct that has to be zero-initialized first. Named arguments may appear in any order, which makes a call with several boolean flags readable without a comment explaining which true is which.
Variadic parameters
Odin's ..int collects trailing arguments into a real typed slice — so the length travels with them and the types are checked. At the call site, ..numbers spreads an existing slice.
#include <stdio.h> #include <stdarg.h> /* va_list is untyped: the count and the types are a contract the compiler cannot check. Passing the wrong type is UB. */ static int sum_all(int count, ...) { va_list arguments; va_start(arguments, count); int total = 0; for (int index = 0; index < count; index++) { total += va_arg(arguments, int); } va_end(arguments); return total; } int main(void) { printf("%d\n", sum_all(4, 1, 2, 3, 4)); return 0; }
package main import "core:fmt" // Typed, and the count travels with the slice sum_all :: proc(values: ..int) -> int { total := 0 for value in values { total += value } return total } main :: proc() { fmt.println(sum_all(1, 2, 3, 4)) // Spread an existing slice numbers := []int{5, 6, 7} fmt.println(sum_all(..numbers)) }
va_list is one of the least safe things in C: neither the argument count nor the types are known to the compiler, which is why printf format-string mismatches are a security class of their own. For genuinely mixed types Odin uses ..any, which carries a typeid with each value.
Immutable parameters
Odin procedure parameters are immutable bindings, so the C habit of reassigning a parameter to normalize it does not compile — you bind a local instead.
#include <stdio.h> /* Parameters are ordinary mutable locals; const is opt-in and does not propagate through a pointer automatically. */ static int normalize(int value) { if (value < 0) { value = 0; /* reassigning the parameter is fine */ } return value; } int main(void) { printf("%d %d\n", normalize(-5), normalize(7)); return 0; }
package main import "core:fmt" normalize :: proc(value: int) -> int { // value = 0 // rejected: parameters are immutable result := value if result < 0 { result = 0 } return result } main :: proc() { fmt.println(normalize(-5), normalize(7)) }
This is const-correctness as the default rather than an annotation you have to remember and propagate through every signature. When a procedure genuinely must mutate, it takes a pointer, and &value at the call site announces it.
Error Handling
errno and sentinel returns
An Odin procedure that can fail returns the error alongside the value, and the error is conventionally an enum whose zero member is None.
#include <stdio.h> #include <stdlib.h> #include <errno.h> #include <string.h> int main(void) { errno = 0; char *end = NULL; long value = strtol("not-a-number", &end, 10); /* Three things to check, and errno is global state */ if (end == "not-a-number" || errno != 0) { printf("failed: %s\n", errno ? strerror(errno) : "no digits"); } else { printf("%ld\n", value); } return 0; }
package main import "core:fmt" import "core:strconv" Parse_Error :: enum { None, Not_A_Number, Out_Of_Range, } parse_positive :: proc(text: string) -> (value: int, error: Parse_Error) { parsed, ok := strconv.parse_int(text) if !ok { return 0, .Not_A_Number } if parsed <= 0 { return 0, .Out_Of_Range } return parsed, .None } main :: proc() { value, error := parse_positive("42") fmt.println(value, error) _, failure := parse_positive("not-a-number") fmt.println("failed:", failure) }
C error reporting is a patchwork — a sentinel return, a global errno, an out-parameter, or all three at once — and none of it is enforced. An error enum costs one integer, has no global state, is thread-safe by construction, and forms a closed set the compiler can check exhaustively in a switch.
Propagating errors
or_return takes the last returned value as the error and, if it is not the zero value, returns from the enclosing procedure immediately passing that error along. Because the enclosing results are named, the compiler knows what to return.
#include <stdio.h> typedef enum { OK, BAD_INPUT } Status; static Status parse_value(int input, int *out) { if (input < 0) return BAD_INPUT; *out = input; return OK; } static Status double_value(int input, int *out) { int value; Status status = parse_value(input, &value); if (status != OK) return status; /* checked by hand */ *out = value * 2; return OK; } int main(void) { int result; if (double_value(21, &result) == OK) printf("%d\n", result); if (double_value(-1, &result) != OK) printf("failed\n"); return 0; }
package main import "core:fmt" Status :: enum {None, Bad_Input} parse_value :: proc(input: int) -> (value: int, error: Status) { if input < 0 { return 0, .Bad_Input } return input, .None } double_value :: proc(input: int) -> (result: int, error: Status) { // Returns early with the same error if parse_value fails value := parse_value(input) or_return return value * 2, .None } main :: proc() { result, error := double_value(21) fmt.println(result, error) _, failure := double_value(-1) fmt.println("failed:", failure) }
This is what makes return-code error handling bearable at scale: the three-line manual check in the C column collapses to a suffix, and adding a layer to the call chain adds one word rather than a block. It is Rust's ? without the trait machinery.
Defaults with or_else
or_else supplies a fallback inline for anything returning a value plus an ok flag or an error — including a map lookup, as in settings["retries"] or_else 3.
#include <stdio.h> #include <stdlib.h> int main(void) { char *end = NULL; long parsed = strtol("not-a-port", &end, 10); int port = (end && *end == '\0') ? (int)parsed : 8080; printf("port %d\n", port); return 0; }
package main import "core:fmt" import "core:strconv" main :: proc() { // The fallback goes inline — no temporary, no ternary port := strconv.parse_int("not-a-port") or_else 8080 fmt.println("port", port) good := strconv.parse_int("9000") or_else 8080 fmt.println("port", good) }
Where or_return propagates a failure, or_else swallows it and substitutes a default. The C equivalent needs a temporary and a conditional, and the condition itself is easy to get subtly wrong — the left column has to check both end and what it points at.
Generics vs Macros
Generic procedures
The $ prefix marks a parameter the compiler should infer and specialize on, so proc(values: []$T) is called like any other procedure with no explicit type argument.
#include <stdio.h> /* The C options: a macro (no type checking, double evaluation), void* plus a size and a comparator, or copy-paste per type. */ #define LARGEST(a, b) ((a) > (b) ? (a) : (b)) int main(void) { printf("%d\n", LARGEST(3, 17)); printf("%.1f\n", LARGEST(1.5, 0.5)); /* Double evaluation: this increments twice */ int counter = 0; printf("%d\n", LARGEST(counter++, 5)); return 0; }
package main import "core:fmt" // One real procedure, specialized per type at compile time largest :: proc(values: []$T) -> T { best := values[0] for value in values[1:] { if value > best { best = value } } return best } main :: proc() { fmt.println(largest([]int{3, 17, 8})) fmt.println(largest([]f64{1.5, 0.5})) fmt.println(largest([]string{"pear", "apple"})) }
Unlike a macro, arguments are evaluated exactly once (the C column shows the double-evaluation trap), types are checked, and a debugger can step in. Unlike the void *-plus-size_t-plus-comparator approach, there is no type erasure and no indirect call.
Generic containers
A generic struct takes its parameter as $T: typeid and is instantiated by writing Stack(int). Procedures over it declare the parameter in the same position — ^Stack($T) — which both constrains the argument and binds T for the body.
#include <stdio.h> #include <stdlib.h> #include <string.h> /* The void* approach: no type safety, and every element access needs a cast and a size the caller supplies. */ typedef struct { void *items; size_t count, element_size; } Vector; int main(void) { int backing[4]; Vector vector = {backing, 0, sizeof(int)}; int value = 42; memcpy((char *)vector.items + vector.count * vector.element_size, &value, vector.element_size); vector.count++; printf("%d\n", ((int *)vector.items)[0]); return 0; }
package main import "core:fmt" Stack :: struct($T: typeid) { items: [dynamic]T, } stack_push :: proc(stack: ^Stack($T), value: T) { append(&stack.items, value) } main :: proc() { numbers: Stack(int) defer delete(numbers.items) stack_push(&numbers, 10) stack_push(&numbers, 20) // Type-safe: stack_push(&numbers, "text") would not compile fmt.println(numbers.items, len(numbers.items)) }
Compare the C column: void * plus a hand-carried element_size, a memcpy for every insert, and a cast on every read, none of it checked. Pushing a string onto a Stack(int) is a compile error here.
Compile-time value parameters
[$N]int matches an array of any length and makes that length available as the compile-time constant N — usable in the body and in the return type, as doubled shows.
#include <stdio.h> /* An array length cannot be a parameter. A function taking a fixed-size array either hard-codes one length or accepts a pointer and takes the count separately. */ static int sum_three(const int values[3]) { return values[0] + values[1] + values[2]; } int main(void) { int triple[3] = {1, 2, 3}; printf("%d\n", sum_three(triple)); return 0; }
package main import "core:fmt" // $N binds the array LENGTH — a value, not a type sum_fixed :: proc(values: [$N]int) -> int { total := 0 for value in values { total += value } return total } // N is usable in the body AND in the return type doubled :: proc(values: [$N]int) -> [N]int { result: [N]int for value, index in values { result[index] = value * 2 } return result } main :: proc() { fmt.println(sum_fixed([3]int{1, 2, 3})) fmt.println(sum_fixed([5]int{1, 2, 3, 4, 5})) fmt.println(doubled([3]int{1, 2, 3})) }
Odin's parametric polymorphism binds values as well as types, so doubled returns an array of exactly the length it received, checked at compile time. C's const int values[3] parameter is a fiction: it decays to const int * and the 3 is ignored entirely.
Safety & Undefined Behavior
Integer overflow
Signed integer overflow is defined in Odin to wrap two's-complement. It is not undefined behavior, so the optimizer cannot reason around it.
#include <stdio.h> #include <limits.h> int main(void) { /* Signed overflow is undefined behavior — the optimizer is allowed to assume it never happens, which has produced real bugs where a check was deleted entirely. */ int large = INT_MAX; unsigned int wrapped = (unsigned int)large + 1u; /* defined: wraps */ printf("%u\n", wrapped); return 0; }
package main import "core:fmt" main :: proc() { // Signed overflow is DEFINED to wrap two's-complement. // Not undefined, so the optimizer cannot reason around it. large: i32 = 2147483647 wrapped := large + 1 fmt.println(wrapped) // And explicit checked / saturating forms exist fmt.println(max(i32), min(i32)) }
Signed overflow being undefined in C is not academic: compilers use it to delete overflow checks that programmers wrote deliberately, which has produced real security bugs. Odin has far less undefined behavior generally, though rawptr misuse and out-of-bounds writes in release builds remain unsafe.
Return-value discipline
Odin requires every return value to be bound or explicitly discarded with _. A call returning two values must acknowledge both.
#include <stdio.h> static int might_fail(int input, int *out) { if (input < 0) return 0; *out = input * 2; return 1; } int main(void) { int result = 0; /* Ignoring the return value compiles silently. So does reading result when the call failed. */ might_fail(-1, &result); printf("%d\n", result); return 0; }
package main import "core:fmt" might_fail :: proc(input: int) -> (result: int, ok: bool) { if input < 0 { return 0, false } return input * 2, true } main :: proc() { // Discarding a result requires writing _ explicitly value, ok := might_fail(-1) if ok { fmt.println(value) } else { fmt.println("call failed, value not used") } // _ = might_fail(-1) // must acknowledge BOTH results _, _ = might_fail(5) fmt.println("done") }
C lets you ignore a return value silently, which is how failed calls get missed — warn_unused_result is a non-standard attribute applied function by function. Making the discard explicit means ignoring an error is something you type on purpose rather than something you forget.
Assertions
assert is the runtime form and is removed in release builds; #assert is the compile-time form, checked during compilation. panic("message") always aborts.
#include <stdio.h> #include <assert.h> int main(void) { int count = 5; /* Compiled out by NDEBUG. static_assert is C11. */ assert(count > 0); _Static_assert(sizeof(int) >= 4, "int must be at least 32 bits"); printf("count is %d\n", count); return 0; }
package main import "core:fmt" main :: proc() { count := 5 // Runtime assert, removed in release builds assert(count > 0, "count must be positive") // Compile-time assert, no separate keyword needed #assert(size_of(int) >= 4) fmt.println("count is", count) }
The capabilities match C's assert and _Static_assert, with two conveniences: the compile-time form needs no separate keyword, and the runtime form takes an optional message and reports its source location. There is no NDEBUG equivalent that can disable panic.
C Interoperability
Calling C from Odin
Odin has no header parser, so a C function is declared by hand inside a foreign block, with --- marking it as having no body. The core:c package supplies c.int, c.size_t and friends so the ABI types are exact. Note the when around the import: the library name differs by platform.
#include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { /* Calling libc from C needs no ceremony at all */ printf("abs: %d\n", abs(-42)); printf("strlen: %zu\n", strlen("hello")); return 0; }
package main import "core:fmt" import "core:c" // The library name is platform-specific, so pick it at compile time when ODIN_OS == .Darwin { foreign import libc "system:System.framework" } else { foreign import libc "system:c" } // Declare the signatures; the --- marks them as external foreign libc { abs :: proc(value: c.int) -> c.int --- strlen :: proc(text: cstring) -> c.size_t --- } main :: proc() { fmt.println("abs:", abs(-42)) fmt.println("strlen:", strlen("hello")) }
There is no marshaling layer and no runtime cost — a foreign call is an ordinary call. The when wrapper is not decoration: a bare "system:c" works on Linux and fails on macOS, and this example is verified running on both.
ABI-compatible structs
A plain Odin struct already uses the platform C layout, so passing one across an FFI boundary needs no attribute. The proc "c" calling convention marks a procedure as callable from C, which is what you need for a callback.
#include <stdio.h> /* A struct passed across a library boundary */ typedef struct { int id; double weight; } Record; static void show(Record record) { printf("%d %.1f\n", record.id, record.weight); } int main(void) { Record record = {7, 1.5}; show(record); return 0; }
package main import "core:fmt" import "core:c" // Layout matches the C struct; use c.* types at the boundary Record :: struct { id: c.int, weight: c.double, } // #force_inline and calling conventions are available too show :: proc "c" (record: Record) { // A "c" procedure has no Odin context, so use the C-safe paths _ = record } main :: proc() { record := Record{id = 7, weight = 1.5} show(record) fmt.printfln("%d %.1f (size %d)", record.id, record.weight, size_of(Record)) }
#packed and #align are there when a specific layout is required. One catch worth knowing: a "c" procedure receives no implicit context, so it cannot allocate through context.allocator until you set one up with context = runtime.default_context().
Struct of arrays
Prefixing an array type with #soa tells the compiler to store each field as its own contiguous column — while entities[index].field indexing stays exactly the same. Converting an array of structs to parallel arrays is a standard optimization for cache-bound loops.
#include <stdio.h> typedef struct { float x, y; int health; } Entity; int main(void) { /* Array of structs: fields interleaved. A loop over health alone strides past x and y on every iteration. */ Entity entities[4] = {0}; entities[0].health = 50; /* To get struct-of-arrays you declare a DIFFERENT type and rewrite every access site in the program. */ float xs[4] = {0}, ys[4] = {0}; int healths[4] = {0}; healths[0] = 50; printf("%d %d\n", entities[0].health, healths[0]); (void)xs; (void)ys; return 0; }
package main import "core:fmt" Entity :: struct { x, y: f32, health: int, } main :: proc() { // Array of structs — interleaved, as in C interleaved: [4]Entity interleaved[0].health = 50 // Struct of arrays — every health contiguous. // The indexing syntax is IDENTICAL. columnar: #soa[4]Entity columnar[0].health = 50 columnar[1].health = 75 fmt.println(interleaved[0].health) fmt.println(columnar[0].health, columnar[1].health) fmt.println("health column:", columnar.health) }
Because the reading code does not change, the optimization becomes a one-keyword experiment rather than a refactor you have to justify in advance. In C it means a new type and touching every access site, which is why it usually does not happen until profiling forces it. Neither Rust nor Zig can express this without macros or code generation.