PONY λ M2 Modula-2

Rust.CodeCompared.To/Odin

An interactive executable cheatsheet comparing Rust and Odin

Rust 1.97 Odin 2026-07a
Hello World & Building
Hello, World
fmt.println is a procedure, not a macro — Odin has no macro system, so it takes variadic ..any arguments and formats at runtime.
fn main() { println!("Hello, World!"); }
package main import "core:fmt" main :: proc() { fmt.println("Hello, World!") }
Rust needs println! to be a macro so it can typecheck the format string at compile time. Odin gives that up, and has nothing to get it back with — the first of many deliberate omissions on this page.
No Cargo
Odin has no package manager, no lockfile, no version resolution, and no central registry. Dependencies are vendored into your source tree and imported by relative path; core: and vendor: ship with the compiler.
// Cargo does dependency resolution, building, testing, // benchmarking, docs, and publishing: // cargo new project // cargo add serde // cargo build --release // cargo test // cargo doc --open // cargo publish fn main() { println!("crates.io has well over 100,000 crates"); }
// There is no Cargo, and no crates.io equivalent. // odin build . compile a directory // odin test . run the test procedures // odin run . -o:speed optimized // Dependencies are VENDORED — you copy the source into your // tree, or add it as a git submodule, and import by path: // import mylib "shared/mylib" package main import "core:fmt" main :: proc() { fmt.println("core: and vendor: ship with the compiler") }
This is the biggest genuine loss, and it should inform whether you use Odin at all. For the game and graphics work Odin targets — short dependency lists, mostly C libraries, already vendored — it hurts less than it sounds, and vendor: covers SDL, raylib, and OpenGL. If your Rust workflow leans on crates.io, no amount of language design compensates.
Compilation model
Odin compiles a whole directory in one pass, with no borrow checker, no trait resolution, no macro expansion, and no crate graph. -vet opts into extra static checks such as unused variables and shadowing.
// Rust compiles per crate, monomorphizes generics, and runs // a borrow-check pass plus heavy LLVM optimization. A clean // release build of a mid-sized project is minutes. // cargo build debug // cargo build --release optimized // cargo check typecheck only, much faster fn main() { println!("incremental compilation helps, but it is still slow"); }
// Odin compiles a whole directory in one pass with no borrow // checker and a much smaller frontend. Whole-project builds of // tens of thousands of lines are typically well under a second. // odin build . // odin build . -o:speed // odin build . -vet extra static checks package main import "core:fmt" main :: proc() { fmt.println("fast enough that you stop thinking about it") }
This is an ergonomic difference rather than a benchmark point: a sub-second edit-compile-run loop changes how you work, especially for the graphics and gameplay iteration Odin is aimed at.
Variables & Types
Mutability
Odin has no mut keyword — every local is mutable. The only true immutability is ::, a compile-time constant with no storage.
fn main() { let immutable = 1; let mut counter = 0; counter += 1; // immutable = 2; // rejected: not declared mut println!("{immutable} {counter}"); }
package main import "core:fmt" main :: proc() { // Locals are ALWAYS mutable — there is no `mut` keyword counter := 0 counter += 1 // Immutability at the value level means a compile-time constant IMMUTABLE :: 1 fmt.println(IMMUTABLE, counter) }
Odin does not track mutability in the type system at all, so &mut T versus &T has no counterpart. Procedure parameters are the exception and are immutable, which is the reverse of Rust, where a parameter can be declared mut.
Shadowing
Re-declaring a name in the same scope is a compile error in Odin — "Redeclaration of 'x' in this scope". Shadowing inside a nested scope is still allowed, as the braces below show.
fn main() { let value = "42"; // Rust allows re-binding the SAME name, even at a new type let value: i32 = value.parse().unwrap(); let value = value * 2; println!("{value}"); }
package main import "core:fmt" import "core:strconv" main :: proc() { text := "42" // value := ...; value := ... is "Redeclaration in this scope" parsed, _ := strconv.parse_int(text) doubled := parsed * 2 // Inner SCOPES may shadow, however { doubled := 999 fmt.println("inner:", doubled) } fmt.println("outer:", doubled) }
The Rust idiom of successively re-binding one name through a parse-and-transform pipeline therefore has to become distinct names. If you would rather nested shadowing did not happen either, -vet warns about it.
Newtypes
distinct f64 creates a separate type that keeps the underlying type's operators and printing — so there is no wrapper struct, no .0 to unwrap, and no derive list to restore what the inner type already had.
#[derive(Debug, Clone, Copy, PartialEq)] struct Celsius(f64); #[derive(Debug, Clone, Copy, PartialEq)] struct Fahrenheit(f64); fn to_fahrenheit(degrees: Celsius) -> Fahrenheit { Fahrenheit(degrees.0 * 9.0 / 5.0 + 32.0) } fn main() { let indoors = Celsius(21.5); // let outdoors: Fahrenheit = indoors; // rejected println!("{:?}", to_fahrenheit(indoors)); }
package main import "core:fmt" // distinct gives the newtype behavior without a wrapper struct, // so the arithmetic operators still work directly on it. 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, same as Rust fmt.println(to_fahrenheit(indoors)) }
It is the same safety property as Rust's newtype with none of the ceremony. Note there is no #[derive] in Odin at all, because there is no trait system to derive into — the row on derive below explains why that costs less than it sounds.
Ownership & Lifetimes
No borrow checker
Odin has no borrow checker, no lifetimes, no ownership tracking, and no unsafe keyword — in Rust's terms the whole language is unsafe. The right column compiles, and is a dangling pointer if the append reallocates.
fn main() { let mut numbers = vec![1, 2, 3]; // The borrow checker rejects this: numbers is mutably // borrowed by push while first still holds a shared borrow. // let first = &numbers[0]; // numbers.push(4); // println!("{first}"); // So you copy the value out first, or restructure. let first = numbers[0]; numbers.push(4); println!("{first} {:?}", numbers); }
package main import "core:fmt" main :: proc() { numbers: [dynamic]int defer delete(numbers) append(&numbers, 1, 2, 3) // This COMPILES. It is also a dangling pointer if the // append reallocates — nothing warns you. first := &numbers[0] append(&numbers, 4) // Correct by construction: copy the value out. safe_first := numbers[0] fmt.println(safe_first, numbers) _ = first }
This is the headline difference and deserves an honest verdict rather than a sales pitch. What you get is no lifetime annotations and no fighting the checker to express a graph or a back-pointer. Whether that is a good trade depends entirely on the program: for a game with an arena per frame it usually is, for a networked service handling untrusted input it usually is not.
Drop vs defer
There is no Drop trait and no destructor — nothing runs when a value goes out of scope unless you wrote a defer. Deferred statements run in reverse order, matching Drop's ordering.
struct Resource { name: String, } impl Drop for Resource { fn drop(&mut self) { println!("dropping {}", self.name); } } fn main() { let _first = Resource { name: "first".to_string() }; let _second = Resource { name: "second".to_string() }; println!("end of scope"); // Drop runs automatically, in reverse declaration order }
package main import "core:fmt" Resource :: struct { name: string, } // No Drop trait — cleanup is an ordinary procedure you call resource_destroy :: proc(resource: Resource) { fmt.println("destroying", resource.name) } main :: proc() { first := Resource{name = "first"} defer resource_destroy(first) second := Resource{name = "second"} defer resource_destroy(second) fmt.println("end of scope") // defers run in reverse order, same as Drop }
Writing the cleanup one line below the acquisition is arguably clearer than an impl Drop several hundred lines away. But the cost is real: a forgotten defer is a leak, where RAII cannot be forgotten. This is the single largest source of leaks when moving from Rust to Odin.
Moves and clones
Odin has no move semantics. Assignment and argument passing copy — and for a slice or dynamic array that copies only the small header (pointer plus length), not the elements.
fn consume(values: Vec<i32>) -> usize { values.len() } fn main() { let numbers = vec![1, 2, 3]; let count = consume(numbers.clone()); // clone, or lose it // consume(numbers) would MOVE numbers; using it after // that point is a compile error. println!("{count} {:?}", numbers); }
package main import "core:fmt" // Nothing is moved. This receives a copy of the slice HEADER // (pointer + length), pointing at the same elements. count_of :: proc(values: []int) -> int { return len(values) } main :: proc() { numbers: [dynamic]int defer delete(numbers) append(&numbers, 1, 2, 3) count := count_of(numbers[:]) // numbers is still perfectly usable — there was no move fmt.println(count, numbers) }
Nothing is invalidated by being passed somewhere, so .clone() largely disappears and a value stays usable after a call. The flip side is that two copies of a [dynamic]int header both believe they own the buffer, and calling delete on both is a double free that nothing catches.
Box, Rc & RefCell
The whole smart-pointer family is absent: no Box, Rc, Arc, RefCell, Weak, or Cow. A heap value is a pointer you free yourself, and sharing is a plain pointer.
use std::cell::RefCell; use std::rc::Rc; fn main() { // Box: unique heap ownership let boxed: Box<i32> = Box::new(42); // Rc + RefCell: shared ownership with runtime borrow checks let shared = Rc::new(RefCell::new(vec![1, 2])); let second = Rc::clone(&shared); second.borrow_mut().push(3); println!("{boxed} {:?} count={}", shared.borrow(), Rc::strong_count(&shared)); }
package main import "core:fmt" main :: proc() { // Box is just a pointer you free yourself boxed := new(int) defer free(boxed) boxed^ = 42 // There is no Rc, no Arc, no RefCell, no Weak. // Sharing is a plain pointer; lifetime is your problem. shared: [dynamic]int defer delete(shared) append(&shared, 1, 2) second := &shared append(second, 3) fmt.println(boxed^, shared) }
Since there is no ownership system, there is nothing for those types to encode. In practice Odin programs sidestep most of what Rc exists for by allocating related objects in one arena and freeing the arena as a unit — a different answer to the same problem rather than a missing feature.
Allocators
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 this scope and everything it calls — which is why collect uses the arena without a signature change.
// Rust has ONE global allocator, swappable process-wide with // #[global_allocator]. Per-collection allocators (allocator_api) // are still unstable, so on stable Rust a library cannot be told // "allocate from this arena" without threading a type parameter // through every signature. fn collect() -> Vec<i32> { vec![1, 2, 3] // always the global allocator } fn main() { println!("{:?}", collect()); }
package main import "core:fmt" import "core:mem" // Takes no allocator parameter and knows nothing about arenas collect :: proc() -> []int { values := make([]int, 3) values[0] = 1 values[1] = 2 values[2] = 3 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) fmt.println("from the arena:", collect()) fmt.println("no individual frees needed") }
If the borrow checker is what Odin removed, this is what it added. Rust's allocator_api aims at the same capability but is unstable and viral through type signatures; Odin's is dynamic, ambient, and costs one pointer in the context struct.
The temporary allocator
The context carries a second allocator for short-lived values. Anything taken from it is released by a single free_all(context.temp_allocator) rather than one deallocation per value.
fn describe(value: i32) -> String { // Allocates; freed when the String is dropped format!("value is {value}") } fn main() { for value in 1..=3 { println!("{}", describe(value)); } }
package main import "core:fmt" describe :: proc(value: int) -> string { // Scratch memory — no individual free return fmt.aprintf("value is %d", value, allocator = context.temp_allocator) } main :: proc() { defer free_all(context.temp_allocator) for value in 1 ..= 3 { fmt.println(describe(value)) } // One free_all reclaims all three at once }
Rust's RAII frees each String at its own drop point, which is correct but pays a deallocation per value; the arena resets a pointer once. This is also why returning an unowned string is normal in Odin, where returning a dangling &str is impossible in Rust.
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 reports whatever was never freed.
fn main() { // Safe Rust largely prevents leaks by construction, though // Rc cycles and mem::forget can still leak. Detection means // an external tool: valgrind, heaptrack, or dhat-rs. let leaked: &'static mut i32 = Box::leak(Box::new(42)); println!("{leaked}"); }
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) } }
Odin makes leaks possible, so it ships the tool to find them in the standard library — no external profiler, no special build, and scopeable to one subsystem. Most projects install it behind when ODIN_DEBUG and print the report at exit. It is the pragmatic answer to a problem Rust solves at compile time.
Strings
String vs &str
Odin has one string type — a pointer and a length. Whether you own the bytes is a convention, not part of the type: slicing returns a view that must not be deleted, while strings.concatenate returns owned memory that must be.
fn main() { let borrowed: &str = "Hello"; // view, 'static let owned: String = borrowed.to_string(); // heap, owned // The distinction is enforced by lifetimes throughout let combined: String = format!("{owned}, Rust!"); println!("{combined} ({} bytes)", combined.len()); }
package main import "core:fmt" import "core:strings" main :: proc() { // ONE type. Whether it is owned is a convention, not a type. borrowed: string = "Hello" combined := strings.concatenate({borrowed, ", Odin!"}) defer delete(combined) fmt.println(combined, "(", len(combined), "bytes )") }
Rust's String/&str split encodes ownership in the type system, which is precisely what Odin does not do. Reading the return documentation therefore matters here in a way it does not in Rust.
UTF-8 handling
Both languages store strings as UTF-8 and decode runes when ranging. Odin does not enforce validity, so byte indexing is allowed. Note the loop-variable order: value first, offset second.
fn main() { let greeting = "héllo"; // Rust GUARANTEES a str is valid UTF-8, and refuses to // index by byte position at all. println!("bytes: {}", greeting.len()); println!("chars: {}", greeting.chars().count()); for (offset, character) in greeting.char_indices() { print!("{offset}:{character} "); } println!(); }
package main import "core:fmt" import "core:unicode/utf8" main :: proc() { greeting := "héllo" // Byte indexing IS allowed — validity is not enforced fmt.println("bytes:", len(greeting)) fmt.println("runes:", utf8.rune_count_in_string(greeting)) // NOTE the order: value FIRST, offset second for character, offset in greeting { fmt.printf("%d:%c ", offset, character) } fmt.println() }
Rust makes invalid UTF-8 in a str unrepresentable and therefore forbids byte indexing outright. Odin trusts you instead — and the reversed loop variables relative to char_indices are an easy thing to get backwards.
Building strings
strings.Builder works as Rust's does, but needs an explicit builder_destroy, and strings.to_string returns a view into the builder's buffer rather than a copy.
use std::fmt::Write; fn main() { let mut builder = String::new(); for index in 1..=5 { write!(builder, "{index} ").unwrap(); } println!("{builder}"); }
package main import "core:fmt" import "core:strings" main :: proc() { 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)) }
That view is valid only until the builder is destroyed. Rust's type system would stop you holding it too long; here it is on you, and strings.clone is how you keep it.
Collections
Vec vs dynamic arrays
Vec<T> is [dynamic]T and &[T] is []T, with matching slice syntax. append takes a pointer to the array rather than being a &mut self method, and delete is explicit because nothing drops for you.
fn main() { let mut numbers: Vec<i32> = Vec::new(); numbers.push(10); numbers.extend([20, 30]); println!("{:?} len={} cap={}", numbers, numbers.len(), numbers.capacity()); let slice: &[i32] = &numbers[1..]; println!("{:?}", slice); // Dropped automatically at end of scope }
package main import "core:fmt" main :: proc() { numbers: [dynamic]int defer delete(numbers) // no automatic Drop append(&numbers, 10) append(&numbers, 20, 30) fmt.println(numbers, "len =", len(numbers), "cap =", cap(numbers)) view := numbers[1:] fmt.println(view) }
Odin's slice is pointer plus length with no lifetime attached, so it can outlive the array it points into — which the borrow checker exists to prevent.
HashMap
map[K]V is built into the language rather than living in a collections module, and allocates from context.allocator. A read of a missing key yields the zero value, so scores[key] += 10 already does what or_insert(0) arranges.
use std::collections::HashMap; fn main() { let mut scores: HashMap<&str, i32> = HashMap::new(); scores.insert("alice", 1); scores.insert("bob", 2); // The Entry API has no Odin equivalent *scores.entry("alice").or_insert(0) += 10; match scores.get("bob") { Some(value) => println!("bob = {value}"), None => println!("bob absent"), } println!("entries: {}", scores.len()); }
package main import "core:fmt" main :: proc() { scores := make(map[string]int) defer delete(scores) scores["alice"] = 1 scores["bob"] = 2 // No Entry API — read, modify, write back scores["alice"] += 10 if value, found := scores["bob"]; found { fmt.println("bob =", value) } else { fmt.println("bob absent") } fmt.println("entries:", len(scores)) }
What is absent is the Entry API — no or_insert, and_modify, or or_insert_with. Removing one entry is delete_key(&scores, key), while delete(scores) frees the whole map.
No iterator adaptors
Odin has no Iterator trait, no adaptors, and no collect — it cannot, because adaptor chains are built from closures and Odin procedure literals cannot capture. So the loop comes back.
fn main() { let numbers = vec![1, 2, 3, 4, 5, 6]; // The heart of idiomatic Rust: lazy, composable, zero-cost let total: i32 = numbers .iter() .filter(|value| *value % 2 == 0) .map(|value| value * 10) .sum(); let names: Vec<String> = numbers .iter() .take(2) .map(|value| format!("n{value}")) .collect(); println!("{total} {names:?}"); }
package main import "core:fmt" main :: proc() { numbers := []int{1, 2, 3, 4, 5, 6} // Write the loop. There is no .iter().filter().map().sum(). total := 0 for value in numbers { if value % 2 == 0 { total += value * 10 } } names: [dynamic]string defer { for name in names { delete(name) } delete(names) } for value, index in numbers { if index >= 2 { break } append(&names, fmt.aprintf("n%d", value)) } fmt.println(total, names) }
For most Rust programmers this is the biggest day-to-day adjustment — bigger than the borrow checker, because you touch it in every function. Some of the pain is real: the three-line chain becomes eight, and the intermediate now needs its own cleanup. Some is illusory: the loop makes the allocation and the iteration order visible, which is the point of the language. core:slice covers common reductions by taking a non-capturing procedure.
Control Flow
Expressions vs statements
Odin is statement-oriented: if, switch, and blocks do not produce values, so every branch needs its own return. The one expression form is the ternary a if condition else b.
fn classify(score: i32) -> &'static str { // if is an EXPRESSION, and the tail is the return value if score >= 90 { "excellent" } else if score >= 70 { "good" } else { "needs work" } } fn main() { let doubled = { let base = 21; base * 2 }; // block expression println!("{} {doubled}", classify(75)); }
package main import "core:fmt" classify :: proc(score: int) -> string { // if is a STATEMENT — every branch needs an explicit return if score >= 90 { return "excellent" } else if score >= 70 { return "good" } return "needs work" } main :: proc() { // Ternary-style expression for the simple case base := 21 doubled := base * 2 label := "big" if doubled > 40 else "small" fmt.println(classify(75), doubled, label) }
Nothing here is a safety difference — it is a style adjustment you will feel for about a day. The ternary covers the common case that Rust's tail-expression if is usually used for.
Loops
Ranging over a collection needs no .iter().enumerate() — the index is the optional second variable, the reverse of Rust's enumerate. Ranges spell their bound explicitly: ..< excludes, ..= includes.
fn main() { for index in 0..3 { print!("{index} "); } println!(); let numbers = [10, 20, 30]; for (index, value) in numbers.iter().enumerate() { print!("{index}:{value} "); } println!(); // loop is an expression that can break with a value let mut counter = 0; let found = loop { counter += 1; if counter == 3 { break counter * 10; } }; println!("{found}"); }
package main import "core:fmt" main :: proc() { for index in 0 ..< 3 { fmt.print(index, "") } fmt.println() numbers := [3]int{10, 20, 30} // value FIRST, index second — and no .enumerate() needed for value, index in numbers { fmt.printf("%d:%d ", index, value) } fmt.println() // No break-with-value; assign before breaking counter := 0 found := 0 for { counter += 1 if counter == 3 { found = counter * 10 break } } fmt.println(found) }
Odin has no break value, because loops are not expressions; assign to a variable declared outside the loop instead. Labeled break and continue work as they do in Rust, with the label attached to the loop.
Functions & Closures
No closures
An Odin procedure literal cannot capture surrounding locals. There is no Fn/FnMut/FnOnce hierarchy and no move — captured state has to become an explicit struct passed by pointer.
fn make_counter() -> impl FnMut() -> i32 { let mut count = 0; // Captures count by move; the closure owns it move || { count += 1; count } } fn main() { let mut next = make_counter(); println!("{} {} {}", next(), next(), next()); }
package main import "core:fmt" // No capture is possible, so the state becomes explicit Counter :: struct { count: int, } next :: proc(counter: ^Counter) -> int { counter.count += 1 return counter.count } main :: proc() { counter: Counter fmt.println(next(&counter), next(&counter), next(&counter)) }
A procedure value is therefore a bare code pointer with no environment and no allocation, which is exactly why the language omits closures: a capturing closure must store its environment somewhere, and Odin refuses to do that invisibly. The struct in the right column is what the Rust closure compiles to anyway.
Callbacks
A non-capturing Rust closure and an Odin procedure literal are the same thing — a function pointer. For the capturing case, Odin makes you pass the captured state as an extra parameter, or bundle it into a struct alongside the procedure pointer.
fn apply_to(operation: impl Fn(i32) -> i32, value: i32) -> i32 { operation(value) } fn main() { // A non-capturing closure works as a plain fn pointer println!("{}", apply_to(|value| value * 2, 21)); // A capturing one needs the generic or a Box<dyn Fn> let factor = 3; println!("{}", apply_to(move |value| value * factor, 21)); }
package main import "core:fmt" Transform :: proc(value: int) -> int apply_to :: proc(operation: Transform, value: int) -> int { return operation(value) } // The capturing case: pass the state alongside Scaled :: proc(value: int, factor: int) -> int apply_scaled :: proc(operation: Scaled, value, factor: int) -> int { return operation(value, factor) } main :: proc() { fmt.println(apply_to(proc(value: int) -> int { return value * 2 }, 21)) factor := 3 fmt.println(apply_scaled(proc(value, factor: int) -> int { return value * factor }, 21, factor)) }
That struct-plus-pointer pair is precisely what Box<dyn Fn> is under the hood, and what impl Fn monomorphizes into. Odin just makes you write it.
Default & named arguments
Odin has both default parameter values and call-by-name, so an argument can be set by name while earlier ones keep their defaults.
// Rust has neither. The idiom is a builder, or Default + // struct update syntax, or several named constructors. #[derive(Default)] struct GreetOptions { greeting: Option<String>, punctuation: Option<String>, } fn greet(name: &str, options: GreetOptions) { let greeting = options.greeting.unwrap_or_else(|| "Hello".into()); let punctuation = options.punctuation.unwrap_or_else(|| "!".into()); println!("{greeting}, {name}{punctuation}"); } fn main() { greet("Ada", GreetOptions::default()); greet("Bob", GreetOptions { punctuation: Some("?".into()), ..Default::default() }); }
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 = "?") }
One of the few places where Odin is unambiguously less ceremonious. Rust has neither, so the ecosystem reaches for the builder pattern, Default plus struct update syntax, or a family of constructors.
Structs & Methods
No impl blocks
There are no impl blocks, no self, no Self, and no method-call syntax. A procedure operating on a type takes it as an ordinary parameter, conventionally prefixed with the type name.
struct Rectangle { width: f64, height: f64, } impl Rectangle { fn new(width: f64, height: f64) -> Self { Rectangle { width, height } } fn area(&self) -> f64 { self.width * self.height } fn scale(&mut self, factor: f64) { self.width *= factor; self.height *= factor; } } fn main() { let mut box_shape = Rectangle::new(3.0, 4.0); println!("{}", box_shape.area()); box_shape.scale(2.0); println!("{}", box_shape.area()); }
package main import "core:fmt" Rectangle :: struct { width: f64, height: f64, } // Free procedures, conventionally prefixed with the type name rectangle_make :: proc(width, height: f64) -> Rectangle { return Rectangle{width = width, height = height} } rectangle_area :: proc(rectangle: Rectangle) -> f64 { return rectangle.width * rectangle.height } rectangle_scale :: proc(rectangle: ^Rectangle, factor: f64) { rectangle.width *= factor rectangle.height *= factor } main :: proc() { box := rectangle_make(3, 4) fmt.println(rectangle_area(box)) rectangle_scale(&box, 2) fmt.println(rectangle_area(box)) }
The &self versus &mut self distinction becomes value versus pointer, which reads almost the same at the definition. At the call site you write rectangle_area(box) rather than box.area(), and namespacing is by naming convention rather than by scope.
Composition
using on 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.
struct Named { name: String, } struct Employee { identity: Named, // no field promotion in Rust salary: i32, } impl Employee { // Delegation is written out by hand, or via Deref (discouraged) fn name(&self) -> &str { &self.identity.name } } fn main() { let worker = Employee { identity: Named { name: "Ada".to_string() }, salary: 100, }; println!("{} {}", worker.name(), worker.salary); }
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 — no delegating accessor to write fmt.println(worker.name, worker.salary) fmt.println(worker.identity.name) }
Rust has no field promotion — you write a delegating accessor, or abuse Deref, which the community rightly discourages for non-smart-pointer types. Since Odin has no methods, only fields are promoted, never behavior.
Traits & Polymorphism
There are no traits
Odin has no traits: no impl Trait, no dyn, no trait objects, no associated types, no blanket impls, no derive, and no operator overloading. For a closed set of implementations, a tagged union takes their place.
trait Shape { fn area(&self) -> f64; } struct Circle { radius: f64 } struct Rectangle { width: f64, height: f64 } impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.radius * self.radius } } impl Shape for Rectangle { fn area(&self) -> f64 { self.width * self.height } } fn total_area(shapes: &[Box<dyn Shape>]) -> f64 { shapes.iter().map(|shape| shape.area()).sum() } fn main() { let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { radius: 2.0 }), Box::new(Rectangle { width: 3.0, height: 4.0 }), ]; println!("{:.2}", total_area(&shapes)); }
package main import "core:fmt" Circle :: struct { radius: f64 } Rectangle :: struct { width, height: f64 } // A tagged union replaces the trait for a CLOSED set Shape :: union { Circle, Rectangle, } area :: proc(shape: Shape) -> f64 { switch specific in shape { case Circle: return 3.14159 * specific.radius * specific.radius case Rectangle: return specific.width * specific.height } return 0 } total_area :: proc(shapes: []Shape) -> f64 { total := 0.0 for shape in shapes { total += area(shape) } return total } main :: proc() { shapes := []Shape{Circle{2}, Rectangle{3, 4}} fmt.printfln("%.2f", total_area(shapes)) }
This is the second-largest removal after the borrow checker. For a closed set the union is arguably better: no boxing, no vtable pointer, values inline, dispatch as a jump table, and the compiler enforcing exhaustiveness — adding a variant surfaces every site that must handle it, which dyn Shape cannot do. What you lose is open extension, and any equivalent of implementing a foreign trait for your own type.
Open extension by hand
When you need open extension, you build the trait object yourself: a struct holding a rawptr to the data plus one procedure pointer per operation.
trait Writer { fn write(&mut self, text: &str) -> usize; } struct CountingWriter { total: usize } impl Writer for CountingWriter { fn write(&mut self, text: &str) -> usize { self.total += text.len(); text.len() } } fn write_twice(writer: &mut dyn Writer, text: &str) { writer.write(text); writer.write(text); } fn main() { let mut counter = CountingWriter { total: 0 }; write_twice(&mut counter, "hello"); println!("{}", counter.total); }
package main import "core:fmt" // The trait object, written out: data pointer + procedure table Writer :: struct { data: rawptr, write: proc(data: rawptr, text: string) -> int, } Counting_Writer :: struct { total: int, } counting_writer_write :: proc(data: rawptr, text: string) -> int { writer := cast(^Counting_Writer)data writer.total += len(text) return len(text) } write_twice :: proc(writer: Writer, text: string) { writer.write(writer.data, text) writer.write(writer.data, text) } main :: proc() { counter: Counting_Writer writer := Writer{data = &counter, write = counting_writer_write} write_twice(writer, "hello") fmt.println(counter.total) }
That is exactly what &mut dyn Writer is — a fat pointer of data plus vtable — except here it is visible, so the indirect call and the type erasure are in the source rather than in the compiler. Odin's own Allocator and Logger are built this way, which is what makes the allocator swappable.
No derive
Every Odin struct already prints with %v, compares with == when its fields do, copies on assignment, hashes as a map key, and has a zero value — with no attribute to opt in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] struct Point { x: i32, y: i32, } fn main() { let origin = Point { x: 3, y: 4 }; let copied = origin; // Copy let zero = Point::default(); // Default println!("{origin:?} {}", origin == copied); // Debug, PartialEq println!("{zero:?}"); }
package main import "core:fmt" Point :: struct { x: int, y: int, } main :: proc() { origin := Point{x = 3, y = 4} copied := origin // everything is Copy; there is no move zero: Point // everything has a zero value // Printing and equality are built in for every struct fmt.printfln("%v %v", origin, origin == copied) fmt.printfln("%v", zero) }
That is why there is no derive: there is nothing to derive, because those are properties of the language rather than traits. What is genuinely lost is custom derives — serde in particular has no Odin counterpart, and serialization is written by hand or generated externally.
No operator overloading
Odin has no operator overloading, by design, so that a + b is always a machine operation and never a hidden call. Fixed-size arrays cover the main use case natively: element-wise arithmetic plus GLSL-style .xyzw swizzles.
use std::ops::Add; #[derive(Debug, Clone, Copy)] struct Vector2 { x: f64, y: f64 } impl Add for Vector2 { type Output = Vector2; fn add(self, other: Vector2) -> Vector2 { Vector2 { x: self.x + other.x, y: self.y + other.y } } } fn main() { let sum = Vector2 { x: 1.0, y: 2.0 } + Vector2 { x: 10.0, y: 20.0 }; println!("{sum:?}"); }
package main import "core:fmt" // No impl Add — but ARRAYS already do element-wise arithmetic, // which is what most vector overloads exist for. Vector2 :: [2]f64 main :: proc() { left := Vector2{1, 2} right := Vector2{10, 20} sum := left + right // built in, and SIMD where available fmt.println(sum, sum.yx) // swizzles too // For a named struct, write the procedure Point :: struct { x, y: f64 } point_add :: proc(a, b: Point) -> Point { return Point{a.x + b.x, a.y + b.y} } fmt.println(point_add(Point{1, 2}, Point{10, 20})) }
So [2]f64 and [4]f32 behave as vectors with no impl Add, and there is a first-class matrix[R, C]T type with real multiplication. For a named struct you write the procedure, as the second half of the column shows.
Enums & Pattern Matching
Enums with and without payloads
Odin splits Rust's enum in two. enum is only a set of named values; payload-carrying variants go in a union of named struct types.
#[derive(Debug)] enum Direction { North, South, East, West } #[derive(Debug)] enum Event { Click { x: i32, y: i32 }, KeyPress(u32), Quit, } fn main() { let heading = Direction::East; let event = Event::Click { x: 3, y: 4 }; println!("{heading:?} {event:?}"); }
package main import "core:fmt" // A plain enum: no payloads, just named values Direction :: enum {North, South, East, West} // Payloads live in a UNION of struct types, not in the enum Click :: struct { x, y: int } Key_Press :: struct { code: u32 } Quit :: struct {} Event :: union { Click, Key_Press, Quit, } main :: proc() { heading := Direction.East event: Event = Click{x = 3, y = 4} fmt.println(heading, event) }
That means each variant is a real type usable on its own — you can pass a Click to a procedure expecting one — where a Rust enum variant is not a type. Both forms give exhaustive matching.
match vs switch
Exhaustiveness carries over — a switch over a union or enum must cover every case, with #partial to opt out. Pattern matching does not: there is no destructuring, no bindings inside a pattern, no @ bindings, no nested patterns, and no guards.
enum Shape { Circle(f64), Rectangle(f64, f64), } fn area(shape: &Shape) -> f64 { // Exhaustive, with destructuring bindings match shape { Shape::Circle(radius) => 3.14159 * radius * radius, Shape::Rectangle(width, height) => width * height, } } fn main() { // Guards, bindings, and nested patterns all in one construct let value = 7; let label = match value { n if n < 0 => "negative", 0 => "zero", 1..=9 => "single digit", _ => "large", }; println!("{:.2} {label}", area(&Shape::Circle(2.0))); }
package main import "core:fmt" Circle :: struct { radius: f64 } Rectangle :: struct { width, height: f64 } Shape :: union {Circle, Rectangle} area :: proc(shape: Shape) -> f64 { // Exhaustive over the union, but no destructuring switch specific in shape { case Circle: return 3.14159 * specific.radius * specific.radius case Rectangle: return specific.width * specific.height } return 0 } main :: proc() { value := 7 label: string switch { case value < 0: label = "negative" case value == 0: label = "zero" case value >= 1 && value <= 9: label = "single digit" case: label = "large" } fmt.printfln("%.2f %s", area(Circle{2}), label) }
The union switch binds the whole variant value and you reach into its fields. Ranges (1 ..= 9) work on values, and a conditionless switch replaces guard arms — which is what the second half of the right column does.
Option vs Maybe
Maybe(T) is Option<T> — a union of T and nothing — and the .? suffix unwraps it into a value plus a flag, reading much like if let Some(x).
fn find_index(values: &[i32], wanted: i32) -> Option<usize> { values.iter().position(|value| *value == wanted) } fn main() { let numbers = [10, 20, 30]; if let Some(index) = find_index(&numbers, 20) { println!("found at {index}"); } // The combinator vocabulary is the real value of Option let doubled = find_index(&numbers, 20).map(|index| index * 2).unwrap_or(0); println!("{doubled} {:?}", find_index(&numbers, 99)); }
package main import "core:fmt" find_index :: proc(values: []int, wanted: int) -> Maybe(int) { for value, index in values { if value == wanted { return index } } return nil } main :: proc() { numbers := []int{10, 20, 30} if index, found := find_index(numbers, 20).?; found { fmt.println("found at", index) } // No .map()/.and_then() — unwrap, then compute doubled := 0 if index, found := find_index(numbers, 20).?; found { doubled = index * 2 } fmt.println(doubled, find_index(numbers, 99) == nil) }
What is missing is the combinator vocabulary: no map, and_then, unwrap_or_else, ok_or, or filter, because all of those take closures. or_else covers unwrap_or; the rest becomes an if.
Error Handling
Result vs multiple returns
Odin returns the value and the error side by side rather than wrapping them in a sum type. The error is conventionally an enum whose zero member is None.
#[derive(Debug)] enum ParseError { NotANumber, OutOfRange, } fn parse_positive(text: &str) -> Result<i32, ParseError> { let value: i32 = text.parse().map_err(|_| ParseError::NotANumber)?; if value <= 0 { return Err(ParseError::OutOfRange); } Ok(value) } fn main() { println!("{:?}", parse_positive("42")); println!("{:?}", parse_positive("abc")); }
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() { fmt.println(parse_positive("42")) fmt.println(parse_positive("abc")) }
That costs one integer, needs no allocation, and stays a closed set the compiler can check exhaustively. The trade-off is that nothing forces you to inspect it — Rust's #[must_use] on Result has no equivalent, though you must at least bind or _ every return value.
The ? operator
or_return is ?: it takes the last returned value as the error and, if it is not the zero value, returns from the enclosing procedure immediately with it. Named results tell the compiler what to return.
#[derive(Debug)] enum AppError { Parse } fn parse_value(text: &str) -> Result<i32, AppError> { text.parse().map_err(|_| AppError::Parse) } fn double_value(text: &str) -> Result<i32, AppError> { let value = parse_value(text)?; // early return on Err Ok(value * 2) } fn quadruple(text: &str) -> Result<i32, AppError> { Ok(double_value(text)? * 2) } fn main() { println!("{:?}", quadruple("21")); println!("{:?}", quadruple("nope")); }
package main import "core:fmt" import "core:strconv" App_Error :: enum {None, Parse} parse_value :: proc(text: string) -> (value: int, error: App_Error) { parsed, ok := strconv.parse_int(text) if !ok { return 0, .Parse } return parsed, .None } double_value :: proc(text: string) -> (result: int, error: App_Error) { value := parse_value(text) or_return // early return on error return value * 2, .None } quadruple :: proc(text: string) -> (result: int, error: App_Error) { doubled := double_value(text) or_return return doubled * 2, .None } main :: proc() { fmt.println(quadruple("21")) fmt.println(quadruple("nope")) }
The one thing ? does that or_return cannot is perform a From conversion between error types, which needs traits. So multi-layer error hierarchies are usually flattened into one enum, or a union of enums, rather than converted as they propagate.
panic & no unwinding
Odin has panic and assert but no unwinding and no catch_unwind — a panic terminates the process. Anything a caller might handle therefore has to be a return value.
fn main() { // Rust panics unwind by default, running Drop along the way, // and can be caught at a boundary with catch_unwind. let result = std::panic::catch_unwind(|| { panic!("something broke"); }); println!("caught: {}", result.is_err()); println!("execution continues"); }
package main import "core:fmt" risky :: proc(should_fail: bool) -> (result: string, ok: bool) { // There is no catch_unwind and no unwinding at all, so a // recoverable failure MUST be a return value. if should_fail { return "", false } return "succeeded", true } main :: proc() { assert(1 + 1 == 2, "arithmetic still works") // panic("something broke") // would abort the process outright fmt.println(risky(false)) fmt.println(risky(true)) fmt.println("execution continues") }
Since there is no Drop either, there would be nothing to run during an unwind anyway. The Rust pattern of panicking on an invariant violation and catching it at a thread or request boundary simply does not transfer. assert is compiled out in release builds; panic is not.
Generics
Generic functions
The $ prefix marks a parameter the compiler infers and specializes on. There are no trait bounds to name — the body is instantiated per type and checked then.
fn largest<T: PartialOrd + Copy>(values: &[T]) -> T { let mut best = values[0]; for &value in &values[1..] { if value > best { best = value; } } best } fn main() { println!("{}", largest(&[3, 17, 8])); println!("{}", largest(&[1.5, 0.5])); }
package main import "core:fmt" // $T is inferred; no trait bounds to name 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"})) }
Both languages monomorphize, but Rust verifies the bounds at the definition, so the body is guaranteed valid for any conforming type. Odin reports an error only if an operation turns out to be unsupported, closer to C++ templates: more flexible, less safe, and the error surfaces inside the procedure rather than at your call site.
Constraints
Odin's where takes an arbitrary compile-time boolean — a type predicate from base:intrinsics, a size comparison, a relation between two parameters — not a list of trait bounds. Note the base:intrinsics import.
use std::ops::Add; fn sum_all<T>(values: &[T]) -> T where T: Add<Output = T> + Copy + Default, { let mut total = T::default(); for &value in values { total = total + value; } total } fn main() { println!("{}", sum_all(&[1, 2, 3])); println!("{}", sum_all(&[1.5, 2.5])); }
package main import "core:fmt" import "base:intrinsics" // A `where` clause is any compile-time boolean expression sum_all :: proc(values: []$T) -> T where intrinsics.type_is_numeric(T) { total: T for value in values { total += value } return total } main :: proc() { fmt.println(sum_all([]int{1, 2, 3})) fmt.println(sum_all([]f64{1.5, 2.5})) }
That is strictly more expressive than a trait bound, which can only enumerate a type set, but it proves nothing about the body. Both languages spell it where and mean quite different things by it.
Const generics
[$N]int binds the array length as a compile-time value usable in the body and the return type — the same job as Rust's const N: usize.
// Rust has const generics, so this much DOES transfer fn sum_fixed<const N: usize>(values: [i32; N]) -> i32 { values.iter().sum() } fn doubled<const N: usize>(values: [i32; N]) -> [i32; N] { let mut result = [0; N]; for index in 0..N { result[index] = values[index] * 2; } result } fn main() { println!("{}", sum_fixed([1, 2, 3])); println!("{}", sum_fixed([1, 2, 3, 4, 5])); println!("{:?}", doubled([1, 2, 3])); }
package main import "core:fmt" // $N binds the array length, same idea as const generics sum_fixed :: proc(values: [$N]int) -> int { total := 0 for value in values { total += value } return total } 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})) }
This is the one area where the two languages genuinely converge. Odin's is slightly more general in that any parameter can be marked $ to become compile-time, without the restrictions Rust still has around const-generic expressions.
Macros & Compile Time
No macro system
Odin has no macro system whatsoever — no macro_rules!, no derives, no attribute macros, no proc macros. Their jobs are split across parametric polymorphism, when, #load, and #force_inline.
// macro_rules!, derive macros, proc macros, attribute macros macro_rules! square { ($value:expr) => { $value * $value }; } fn main() { println!("{}", square!(7)); // Plus derive macros (#[derive(Serialize)]), attribute // macros (#[tokio::main]), and function-like proc macros // (sqlx::query!) — an entire metaprogramming ecosystem. println!("{:?}", vec![1, 2, 3]); }
package main import "core:fmt" // No macros of any kind. A "macro" is a procedure. square :: proc(value: int) -> int { return value * value } main :: proc() { fmt.println(square(7)) // The jobs macros do are split across other features: // metaprogramming -> parametric polymorphism ($T, $N) // conditional code -> when // embedding data -> #load("file.bin") // inlining -> #force_inline values := []int{1, 2, 3} fmt.println(values) }
The design position is that macros make code unreadable and compilers slow. What is genuinely lost is the derive ecosystem: serde has no counterpart, and serialization is hand-written or generated by an external tool.
Conditional compilation
when is a language construct over typed constants (ODIN_OS, ODIN_ARCH, ODIN_DEBUG) rather than an attribute plus a macro plus a Cargo.toml feature table. Only the taken branch is compiled.
fn main() { // cfg! is a macro; #[cfg] is an attribute if cfg!(target_os = "macos") { println!("compiled for macOS"); } else if cfg!(target_os = "linux") { println!("compiled for Linux"); } else { println!("compiled for something else"); } println!("debug assertions: {}", cfg!(debug_assertions)); }
package main import "core:fmt" main :: proc() { // when is ordinary Odin, not an attribute or a macro 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("debug build:", ODIN_DEBUG) fmt.println("architecture:", ODIN_ARCH) }
The capability matches #[cfg]/cfg! and the spelling is simpler. #config(NAME, default) covers values passed on the command line, which is what Cargo features are usually used for.
Concurrency & Data Layout
Threads without Send and Sync
Odin has no Send, no Sync, and no compile-time data-race prevention. Shared state travels as a struct of pointers because the thread procedure cannot capture, and sync.guard takes the lock and releases it at scope exit.
use std::sync::{Arc, Mutex}; use std::thread; fn main() { // Send + Sync are checked at COMPILE time; sharing a // non-Sync value across threads simply does not compile. let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new(); for _ in 0..4 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; } })); } for handle in handles { handle.join().unwrap(); } println!("counter: {}", *counter.lock().unwrap()); }
package main import "core:fmt" import "core:sync" import "core:thread" Shared :: struct { counter: ^int, mutex: ^sync.Mutex, } main :: proc() { counter := 0 mutex: sync.Mutex shared := Shared{&counter, &mutex} workers: [4]^thread.Thread for index in 0 ..< 4 { workers[index] = thread.create_and_start_with_poly_data(&shared, proc(data: ^Shared) { for _ in 0 ..< 1000 { sync.guard(data.mutex) data.counter^ += 1 } }) } for worker in workers { thread.join(worker) thread.destroy(worker) } fmt.println("counter:", counter) }
"Fearless concurrency" is exactly what the borrow checker buys, so removing the checker removes the guarantee — forgetting the mutex here compiles and races. In exchange there is no Arc to clone per thread and no lock().unwrap() ceremony.
Struct of arrays
Prefixing an array type with #soa stores each field as its own contiguous column while entities[index].field indexing stays identical.
struct Entity { x: f32, y: f32, health: i32, } fn main() { // Array of structs. Converting to struct-of-arrays means a // new type and rewriting every access — or the soa_derive // crate, which generates a parallel API with different names. let mut entities = vec![ Entity { x: 0.0, y: 0.0, health: 50 }, Entity { x: 1.0, y: 1.0, health: 75 }, ]; entities[0].health = 60; println!("{} {}", entities[0].health, entities[1].health); }
package main import "core:fmt" Entity :: struct { x, y: f32, health: int, } main :: proc() { // Array of structs — interleaved interleaved: [2]Entity interleaved[0].health = 60 // Struct of arrays — columns. IDENTICAL indexing syntax. columnar: #soa[2]Entity columnar[0].health = 60 columnar[1].health = 75 fmt.println(interleaved[0].health) fmt.println(columnar[0].health, columnar[1].health) fmt.println("health column:", columnar.health) }
In Rust this needs a proc macro such as soa_derive, which generates a parallel set of types and accessor names, so switching layouts is a refactor. For the cache-bound loops Odin targets, being able to try the change and revert it in seconds is the point.