PONY λ M2 Modula-2

Go.CodeCompared.To/Odin

An interactive executable cheatsheet comparing Go and Odin

Go 1.26.2 Odin 2026-07a
Hello World & Declarations
Hello, World
Structurally almost identical to Go: a main package, an import, an entry point. Odin's standard-library packages carry a core: prefix, and main :: proc() uses ::, the compile-time constant declaration.
package main import "fmt" func main() { fmt.Println("Hello, World!") }
package main import "core:fmt" main :: proc() { fmt.println("Hello, World!") }
The remaining differences are cosmetic — procedure names are snake_case rather than exported-by-capital, and there are no semicolons or parentheses where Go would not need them either.
The :: declaration operator
Odin has one operator for every compile-time declaration. NAME :: value declares a constant, and a type or procedure is just a constant whose value happens to be a type or some code.
package main import "fmt" const Limit = 100 type Counter int func report(value Counter) { fmt.Println("value:", value) } func main() { var total Counter = Limit * 2 report(total) }
package main import "core:fmt" // One operator for every compile-time declaration: LIMIT :: 100 // a constant Counter :: distinct int // a type report :: proc(value: Counter) { // a procedure fmt.println("value:", value) } main :: proc() { total: Counter = LIMIT * 2 report(total) }
Once that clicks, the whole declaration grammar collapses to two forms: :: for anything known at compile time, and name := value or name: Type for a runtime variable. Declaration order at file scope does not matter, as in Go.
Exported vs private
Capitalization carries no meaning at all in Odin. Everything is exported unless marked @(private), and @(private = "file") narrows it further.
package main import "fmt" // Capitalization IS the visibility rule func Exported() string { return "visible outside the package" } func unexported() string { return "package-local" } func main() { fmt.Println(Exported()) fmt.Println(unexported()) }
package main import "core:fmt" // Capitalization carries no meaning at all in Odin exported :: proc() -> string { return "visible outside the package" } @(private) package_local :: proc() -> string { return "package-local" } @(private = "file") file_local :: proc() -> string { return "visible only in this file" } main :: proc() { fmt.println(exported()) fmt.println(package_local()) fmt.println(file_local()) }
This is the habit hardest to unlearn — renaming Foo to foo changes nothing about who can see it. Odin also gives a finer scope than Go offers at all. Community style is snake_case for procedures and variables, Ada_Case for types.
Packages & imports
The model is nearly Go's: a package is a directory, files share one namespace, imports are qualified. core: is the standard library and vendor: the bundled bindings; anything else is a relative path.
package main import ( "fmt" "strings" str "strconv" // aliased ) func main() { fmt.Println(strings.ToUpper("go")) fmt.Println(str.Itoa(42)) }
package main import "core:fmt" import "core:strings" import convert "core:strconv" // aliased main :: proc() { buffer: [8]byte shouted := strings.to_upper("odin") defer delete(shouted) fmt.println(shouted) fmt.println(convert.itoa(buffer[:], 42)) }
There is no module system and no remote import path. Note also that strings.to_upper allocates and needs a delete: with no garbage collector, any standard-library call returning a new string hands you ownership of it.
Variables & Types
Variables & zero values
This transfers directly — same guaranteed zero-initialization, same :=. The var keyword just disappears: ratio: f64 is the explicit form.
package main import "fmt" func main() { count := 42 // inferred var ratio float64 // zero value: 0 var name string // zero value: "" var ready bool // zero value: false ratio = 2.5 fmt.Println(count, ratio, name == "", ready) }
package main import "core:fmt" main :: proc() { count := 42 // inferred ratio: f64 // zero value: 0 name: string // zero value: "" ready: bool // zero value: false ratio = 2.5 fmt.println(count, ratio, name == "", ready) }
Odin adds one thing Go deliberately withholds: buffer: [1024]byte = --- opts out of zeroing, leaving the memory uninitialized as a performance escape hatch.
Numeric types
The same system Go uses, with shorter names — i32 rather than int32 — and a pointer-sized int. Odin adds 128-bit integers and endian-specific types.
package main import "fmt" func main() { var small int8 = -128 var large int64 = 9223372036854775807 var unsigned uint32 = 4294967295 var ratio float64 = 2.5 // Go has no built-in 128-bit integer fmt.Println(small, large, unsigned, ratio) fmt.Println("int size:", 8) }
package main import "core:fmt" main :: proc() { small: i8 = -128 large: i64 = 9223372036854775807 unsigned: u32 = 4294967295 ratio: f64 = 2.5 // Odin has 128-bit integers and endian-specific types huge: i128 = 170141183460469231731687303715884105727 big_endian: u32be = 1 fmt.println(small, large, unsigned, ratio) fmt.println(huge) fmt.println("big endian value:", big_endian, "int size:", size_of(int)) }
u32be and u32le byte-swap on access, which removes most hand-written encoding/binary code when parsing a file or wire format. Go has no built-in 128-bit integer at all.
Untyped constants
Odin's untyped constants behave the way Go's do — arbitrary precision until used, then range-checked against the type they land in.
package main import "fmt" const Big = 1 << 40 func main() { var asInt64 int64 = Big var asFloat float64 = Big // A constant too large for its target is a COMPILE error // var tooSmall int8 = Big fmt.Println(asInt64, asFloat) }
package main import "core:fmt" BIG :: 1 << 40 main :: proc() { as_i64: i64 = BIG as_f64: f64 = BIG // Same as Go: this would not compile // too_small: i8 = BIG fmt.println(as_i64, as_f64) }
If you have internalized Go's constant rules they carry over unchanged, including the compile-time rejection of a constant that will not fit its target type.
Type conversion
Both languages require explicit conversions everywhere, and Odin's distinct is Go's named-type-with-underlying-type. Note that strconv procedures write into a caller-supplied buffer rather than allocating.
package main import ( "fmt" "strconv" ) type Celsius float64 func main() { whole := 7 ratio := float64(whole) // explicit, as always in Go var temperature Celsius = 21.5 // var plain float64 = temperature // rejected: named type plain := float64(temperature) text := strconv.Itoa(whole) parsed, err := strconv.Atoi("42") fmt.Println(ratio, plain, text, parsed, err) }
package main import "core:fmt" import "core:strconv" Celsius :: distinct f64 main :: proc() { whole := 7 ratio := f64(whole) // explicit, as in Go temperature: Celsius = 21.5 // plain: f64 = temperature // rejected: distinct type plain := f64(temperature) buffer: [8]byte text := strconv.itoa(buffer[:], whole) parsed, ok := strconv.parse_int("42") fmt.println(ratio, plain, text, parsed, ok) }
That buffer-passing style is the recurring theme once the garbage collector is gone: the standard library prefers to let you own the memory rather than hand you some.
Arrays as vectors
Odin treats a fixed-size array as a numeric vector: +, -, *, and / are element-wise. The .xyzw and .rgba swizzles come from shader languages.
package main import "fmt" func main() { left := [3]int{1, 2, 3} right := [3]int{10, 20, 30} // Go has no element-wise operators — write the loop var sum [3]int for index := range left { sum[index] = left[index] + right[index] } fmt.Println(sum) }
package main import "core:fmt" main :: proc() { left := [3]int{1, 2, 3} right := [3]int{10, 20, 30} // Operators apply element-wise; the compiler emits SIMD where it can sum := left + right scaled := left * 3 fmt.println(sum, scaled) // Swizzling, borrowed from shader languages position := [3]f32{1, 2, 3} fmt.println(position.zyx, position.xy) }
The arithmetic lowers to SIMD where the target supports it. Go's arrays are containers only, so the equivalent is always the explicit loop in the left column — one of several places where Odin's graphics-programming origins show.
Strings
Strings & runes
Odin strings are UTF-8 byte slices with a length, exactly like Go's, and ranging over one decodes runes with byte offsets. The loop variables are reversed: Odin yields (value, index), Go yields (index, value).
package main import "fmt" func main() { greeting := "héllo" fmt.Println("bytes:", len(greeting)) for offset, character := range greeting { fmt.Printf("%d:%c ", offset, character) } fmt.Println() }
package main import "core:fmt" main :: proc() { greeting := "héllo" fmt.println("bytes:", len(greeting)) // NOTE the order: value FIRST, index second — the reverse of Go for character, offset in greeting { fmt.printf("%d:%c ", offset, character) } fmt.println() }
That reversal is the trap, not the semantics. It bites on strings, slices, and maps alike, and the compiler will not always catch it because both are often integers.
Who owns the result?
Every standard-library procedure that returns a new string allocated it, and you own it — nothing reclaims it for you. The convention is defer delete(x) on the line immediately after.
package main import ( "fmt" "strings" ) func main() { parts := []string{"alpha", "beta", "gamma"} // The GC reclaims every intermediate string joined := strings.Join(parts, ", ") shouted := strings.ToUpper(joined) fmt.Println(shouted) }
package main import "core:fmt" import "core:strings" main :: proc() { parts := []string{"alpha", "beta", "gamma"} // Each of these ALLOCATES and hands you ownership joined := strings.join(parts, ", ") defer delete(joined) shouted := strings.to_upper(joined) defer delete(shouted) fmt.println(shouted) }
This is the single most common mistake a Go programmer makes in their first Odin program. Procedures that return a view into existing memory — slicing, strings.trim_space, strings.split_iterator — allocate nothing and must not be deleted, so the return documentation is worth reading.
Building strings
The pattern maps one to one — strings.Builder in both, formatted writes in both. Odin's needs an explicit builder_destroy.
package main import ( "fmt" "strings" ) func main() { var builder strings.Builder for index := 1; index <= 5; index++ { fmt.Fprintf(&builder, "%d ", index) } fmt.Println(builder.String()) }
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)) }
strings.to_string returns a view into the builder's buffer rather than a copy, so it is valid only until the builder is destroyed. Copy it with strings.clone if it must outlive the builder.
Formatting values
The verbs are Go's, deliberately — %v, %T, width and precision all behave as you expect. printfln saves the trailing newline, and %#v is the verbose struct form where Go writes %+v.
package main import "fmt" type Point struct { X, Y int } func main() { origin := Point{3, 4} fmt.Printf("%v\n", origin) fmt.Printf("%+v\n", origin) fmt.Printf("%T\n", origin) fmt.Printf("%6.2f|%-8s|\n", 3.14159, "left") }
package main import "core:fmt" Point :: struct { x: int, y: int, } main :: proc() { origin := Point{x = 3, y = 4} fmt.printfln("%v", origin) fmt.printfln("%#v", origin) fmt.printfln("%T", origin) fmt.printfln("%6.2f|%-8s|", 3.14159, "left") }
Odin's plain %v already prints field names for a struct, so you rarely need the verbose form at all.
Arrays, Slices & Maps
Slices
An Odin slice is pointer plus length only — there is no capacity field. Growth is the job of [dynamic]T, a separate type.
package main import "fmt" func average(values []int) float64 { total := 0 for _, value := range values { total += value } return float64(total) / float64(len(values)) } func main() { numbers := [6]int{10, 20, 30, 40, 50, 60} middle := numbers[1:4] fmt.Println(middle, len(middle), cap(middle)) fmt.Println(average(numbers[:])) }
package main import "core:fmt" average :: proc(values: []int) -> f64 { total := 0 for value in values { total += value } return f64(total) / f64(len(values)) } main :: proc() { numbers := [6]int{10, 20, 30, 40, 50, 60} middle := numbers[1:4] // A slice is a pointer + length only — there is no capacity fmt.println(middle, len(middle)) fmt.println(average(numbers[:])) }
That removes the aliasing surprise where a Go append either mutates the backing array in place or silently reallocates depending on spare capacity. A []T in Odin never reallocates behind your back.
Growable arrays
Odin splits Go's single slice type in two: []T is a non-owning view, [dynamic]T owns a growable buffer. Because it owns memory, append takes a pointer and needs no reassignment.
package main import "fmt" func main() { // One type does both jobs: append may or may not reallocate numbers := []int{} numbers = append(numbers, 10, 20, 30) numbers = append(numbers, 40) fmt.Println(numbers, len(numbers), cap(numbers)) }
package main import "core:fmt" main :: proc() { // A distinct type from []int — this one owns its memory numbers: [dynamic]int defer delete(numbers) append(&numbers, 10, 20, 30) append(&numbers, 40) fmt.println(numbers, len(numbers), cap(numbers)) // Hand it to any []int procedure with a slice expression view := numbers[:] fmt.println("as a slice:", view) }
So there is no numbers = append(numbers, …) dance and no way to forget it. It does need delete, and numbers[:] produces a view for any procedure written against []int.
Maps
The comma-ok read is identical and iteration order is unspecified in both. Watch the naming collision: delete(map) frees the whole map, so removing one entry is delete_key(&map, key).
package main import ( "fmt" "sort" ) func main() { scores := map[string]int{"alice": 1, "bob": 2} scores["carol"] = 3 value, found := scores["alice"] fmt.Println(value, found) delete(scores, "bob") keys := make([]string, 0, len(scores)) for key := range scores { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { fmt.Printf("%s=%d ", key, scores[key]) } fmt.Println() }
package main import "core:fmt" import "core:slice" main :: proc() { scores := make(map[string]int) defer delete(scores) scores["alice"] = 1 scores["bob"] = 2 scores["carol"] = 3 value, found := scores["alice"] fmt.println(value, found) delete_key(&scores, "bob") keys, _ := slice.map_keys(scores) defer delete(keys) slice.sort(keys) for key in keys { fmt.printf("%s=%d ", key, scores[key]) } fmt.println() }
That is the opposite of Go, where delete(m, k) removes one entry and nothing frees the map. slice.map_keys allocates the key slice, so it needs its own delete too.
Slice utilities
Go's slices package and Odin's core:slice cover much the same ground, both generic over the element type. The recurring adjustment is ownership: slice.clone allocates and you release it.
package main import ( "fmt" "slices" ) func main() { numbers := []int{5, 3, 9, 1} sorted := slices.Clone(numbers) slices.Sort(sorted) fmt.Println(sorted) fmt.Println(slices.Contains(numbers, 9)) fmt.Println(slices.Index(numbers, 3)) fmt.Println(slices.Max(numbers)) }
package main import "core:fmt" import "core:slice" main :: proc() { numbers := []int{5, 3, 9, 1} sorted := slice.clone(numbers) defer delete(sorted) slice.sort(sorted) fmt.println(sorted) fmt.println(slice.contains(numbers, 9)) fmt.println(slice.linear_search(numbers, 3)) fmt.println(slice.max(numbers)) }
Note also that slice.linear_search returns an index and a found flag rather than Go's -1 sentinel.
Fixed-size arrays
Array value semantics carry over exactly — length is part of the type, arrays copy on assignment and compare with ==. Odin adds the enum-indexed array: [Fruit]int has one slot per enum member.
package main import "fmt" func main() { // Length is part of the type, and arrays are values original := [3]int{1, 2, 3} copied := original copied[0] = 99 fmt.Println(original, copied, original == copied) }
package main import "core:fmt" main :: proc() { // Same rules: length is part of the type, arrays are values original := [3]int{1, 2, 3} copied := original copied[0] = 99 fmt.println(original, copied, original == copied) // A fixed array indexed by an ENUM — one slot per member, enforced Fruit :: enum {Apple, Banana, Cherry} counts: [Fruit]int counts[.Apple] = 3 fmt.println(counts) }
That is the lookup table Go programmers usually build as a map[Fruit]int, but with no hashing and no allocation — and adding a fourth Fruit resizes it automatically rather than leaving a gap.
Control Flow
Loops
Odin's for covers the same four shapes Go's does, including the single-condition while form. Ranges are explicit about their bound: ..< excludes, ..= includes.
package main import "fmt" func main() { for index := 0; index < 3; index++ { fmt.Print(index, " ") } fmt.Println() for index := range 3 { // Go 1.22+ range over int fmt.Print(index, " ") } fmt.Println() countdown := 3 for countdown > 0 { countdown-- } fmt.Println("done", countdown) }
package main import "core:fmt" main :: proc() { for index := 0; index < 3; index += 1 { fmt.print(index, "") } fmt.println() // ..< excludes the upper bound, ..= includes it for index in 0 ..< 3 { fmt.print(index, "") } fmt.println() countdown := 3 for countdown > 0 { countdown -= 1 } fmt.println("done", countdown) }
There is no ++ or -- operator — index += 1 is the only form.
Switch
Immediately familiar: no implicit fallthrough, comma-separated values, and a conditionless switch for an if-else chain. The default branch is a bare case: rather than default:.
package main import "fmt" func main() { score := 75 switch { case score >= 90: fmt.Println("excellent") case score >= 70: fmt.Println("good") // no fallthrough by default default: fmt.Println("needs work") } grade := 'B' switch grade { case 'A', 'B': fmt.Println("passing") default: fmt.Println("other") } }
package main import "core:fmt" main :: proc() { score := 75 switch { case score >= 90: fmt.println("excellent") case score >= 70: fmt.println("good") // no fallthrough by default case: fmt.println("needs work") } grade := 'B' switch grade { case 'A', 'B': fmt.println("passing") case 'C' ..= 'F': // ranges, which Go lacks fmt.println("other") case: fmt.println("unknown") } }
Odin adds ranges ('C' ..= 'F'), which Go has no syntax for at all.
Exhaustiveness
An Odin enum is a genuine closed type, so a switch over one must cover every member or the program does not compile. #partial switch opts out deliberately.
package main import "fmt" type Direction int const ( North Direction = iota South East West ) func describe(heading Direction) string { // Omitting West compiles fine — only a linter would complain switch heading { case North: return "up" case South: return "down" case East: return "right" } return "unknown" } func main() { fmt.Println(describe(East), describe(West)) }
package main import "core:fmt" Direction :: enum {North, South, East, West} describe :: proc(heading: Direction) -> string { // Omitting West here is a COMPILE ERROR switch heading { case .North: return "up" case .South: return "down" case .East: return "right" case .West: return "left" } return "unknown" } main :: proc() { fmt.println(describe(.East), describe(.West)) fmt.println("member count:", len(Direction), "last:", max(Direction)) }
Go's iota constants are just integers, so nothing about them is a closed set — a switch can miss a case and the compiler is content. Adding a fifth Direction in Odin turns every incomplete switch into an error pointing at the exact line, instead of a silent behavior change you find in production.
Labels & defer
Labeled break and continue work as in Go, with the label attached to the loop itself rather than on its own line. defer also matches — LIFO at scope exit.
package main import "fmt" func main() { search: for row := range 3 { for column := range 3 { if row*column > 2 { fmt.Printf("stopped at %d,%d\n", row, column) break search } } } defer fmt.Println("second deferred, printed last") defer fmt.Println("first deferred, printed first") fmt.Println("body done") }
package main import "core:fmt" main :: proc() { // The label goes ON the loop, not on a line before it 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 } } } defer fmt.println("second deferred, printed last") defer fmt.println("first deferred, printed first") fmt.println("body done") }
One difference worth knowing: Odin defers to the end of the enclosing scope, not the enclosing procedure, so a defer inside a loop body runs on every iteration.
Procedures
Functions vs procedures
Multiple return values, named results, and _ to discard all work as they do in Go — the naked return with named results is the same feature.
package main import "fmt" func divide(numerator, denominator int) (int, int) { return numerator / denominator, numerator % denominator } func main() { quotient, remainder := divide(17, 5) fmt.Println(quotient, remainder) onlyQuotient, _ := divide(17, 5) fmt.Println(onlyQuotient) }
package main import "core:fmt" 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) only_quotient, _ := divide(17, 5) fmt.println(only_quotient) }
Only the spelling changes: proc for func, and -> introducing the results.
Parameters are immutable
Odin parameters are immutable bindings, unlike Go's, which are ordinary mutable locals. The common Go habit of reassigning a parameter to normalize it does not compile — bind a new local instead.
package main import "fmt" func normalize(name string) string { // Parameters are ordinary locals — reassigning is fine if name == "" { name = "anonymous" } return name } func main() { fmt.Println(normalize(""), normalize("ada")) }
package main import "core:fmt" normalize :: proc(name: string) -> string { // name = "anonymous" // rejected: parameters are immutable result := name if result == "" { result = "anonymous" } return result } main :: proc() { fmt.println(normalize(""), normalize("ada")) }
The payoff is that when reading a procedure body you know a parameter still holds what the caller passed.
Default & named arguments
Odin has default parameter values and call-by-name, so an argument can be set by name while earlier ones keep their defaults, in any order.
package main import "fmt" // Go has neither — the idiom is an options struct type GreetOptions struct { Greeting string Punctuation string } func greet(name string, options GreetOptions) { if options.Greeting == "" { options.Greeting = "Hello" } if options.Punctuation == "" { options.Punctuation = "!" } fmt.Printf("%s, %s%s\n", options.Greeting, name, options.Punctuation) } func main() { greet("Ada", GreetOptions{}) greet("Bob", GreetOptions{Punctuation: "?"}) }
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 two workarounds Go programmers reach for constantly: the options struct and the variadic ...Option functional-options pattern. It also makes a call with several boolean flags readable without a comment explaining which true is which.
Procedure values are not closures
An Odin procedure literal cannot capture surrounding local variables. Captured state has to become an explicit struct passed by pointer.
package main import "fmt" func makeCounter() func() int { count := 0 // The closure captures count; the GC keeps it alive return func() int { count++ return count } } func main() { next := makeCounter() fmt.Println(next(), next(), next()) }
package main import "core:fmt" // No capture, so the state must be an explicit parameter 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)) }
Closures are so routine in Go that their absence is the most disorienting thing on this page. A procedure value is a bare code pointer with no environment and no hidden allocation — which is precisely why the language omits them, since a capturing closure has to allocate somewhere. The struct is what the Go closure was doing anyway, just visibly.
Variadic parameters
Identical in behavior — trailing arguments collect into a slice, and an existing slice spreads into the call.
package main import "fmt" func sumAll(values ...int) int { total := 0 for _, value := range values { total += value } return total } func main() { fmt.Println(sumAll(1, 2, 3, 4)) numbers := []int{5, 6, 7} fmt.Println(sumAll(numbers...)) }
package main import "core:fmt" 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)) numbers := []int{5, 6, 7} fmt.println(sum_all(..numbers)) }
Only the punctuation moves: Odin writes ..int in the signature and ..numbers as a prefix at the call site, where Go writes ...int and a numbers... suffix.
Procedure groups
A procedure group binds one name to a fixed list of procedures — proc{a, b} — resolved at compile time by argument count and types.
package main import "fmt" // Go has no overloading — each variant needs its own name func areaOfCircle(radius float64) float64 { return 3.14159 * radius * radius } func areaOfRectangle(width, height float64) float64 { return width * height } func main() { fmt.Println(areaOfCircle(2), areaOfRectangle(3, 4)) }
package main import "core:fmt" area_of_circle :: proc(radius: f64) -> f64 { return 3.14159 * radius * radius } area_of_rectangle :: proc(width, height: f64) -> f64 { return width * height } // A procedure GROUP: one name, resolved by argument count and types area :: proc{area_of_circle, area_of_rectangle} main :: proc() { fmt.println(area(2.0), area(3.0, 4.0)) }
Because the members are listed explicitly there is no hidden overload set to search and no surprise about which one a call resolves to. It is a middle ground between Go's "one name, one function" and C++-style overloading.
Structs & Embedding
No methods, no receivers
Odin has no methods — no receiver syntax and no box.Area() form. A procedure that works on a Rectangle takes one, conventionally prefixed with the type name.
package main import "fmt" type Rectangle struct { Width, Height float64 } func (r Rectangle) Area() float64 { return r.Width * r.Height } func (r *Rectangle) Scale(factor float64) { r.Width *= factor r.Height *= factor } func main() { box := Rectangle{Width: 3, Height: 4} fmt.Println(box.Area()) box.Scale(2) fmt.Println(box.Area()) }
package main import "core:fmt" Rectangle :: struct { width: f64, height: f64, } // Ordinary procedures — no receiver syntax exists 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{width = 3, height = 4} fmt.println(rectangle_area(box)) rectangle_scale(&box, 2) fmt.println(rectangle_area(box)) }
Two Go conveniences do survive: field access auto-dereferences a pointer, and you still choose value or pointer by what the procedure needs to do. What is lost is method-set-based abstraction — which the interfaces section covers.
Struct embedding
Odin's using on a struct field does what Go's embedding does — promote the inner fields into the outer struct while keeping the full path available. The field keeps an explicit name.
package main import "fmt" type Named struct { Name string } type Employee struct { Named // embedded: fields are promoted Salary int } func main() { worker := Employee{Named: Named{Name: "Ada"}, Salary: 100} fmt.Println(worker.Name, worker.Salary) fmt.Println(worker.Named.Name) }
package main import "core:fmt" Named :: struct { name: string, } Employee :: struct { using identity: Named, // promoted, but the field is still NAMED salary: int, } main :: proc() { worker := Employee{identity = Named{name = "Ada"}, salary = 100} fmt.println(worker.name, worker.salary) fmt.println(worker.identity.name) }
That naming is the improvement: two embedded fields of the same type do not collide, where Go addresses them by type name. Since Odin has no methods, only fields are promoted, never behavior.
Struct attributes
Odin's struct directives are compile-time layout instructions, not runtime metadata: #packed removes padding, #align(N) forces alignment, and size_of/offset_of verify the result.
package main import "fmt" // Tags are strings, parsed at RUNTIME via reflection type Message struct { Kind uint8 `json:"kind"` Length uint32 `json:"length"` } func main() { message := Message{Kind: 7, Length: 512} fmt.Println(message, "size is padded:", 8) }
package main import "core:fmt" // Layout is controlled by compile-time directives, not tags Message :: struct #packed { kind: u8, length: u32, } Aligned :: struct #align(16) { kind: u8, length: u32, } main :: proc() { message := Message{kind = 7, length = 512} fmt.println(message) fmt.println("packed:", size_of(Message), "aligned:", size_of(Aligned)) fmt.println("offset of length:", offset_of(Message, length)) }
Go struct tags are strings interpreted at runtime by a reflection-based library. This is the difference between describing a wire format to a JSON decoder and actually being the wire format — the latter is what you need when matching a C header or a binary file.
Struct literals & comparison
Odin uses = rather than : inside a struct literal, and a partial literal zero-fills the rest — so Point{x = 3} is valid.
package main import "fmt" type Point struct { X, Y int } func main() { positional := Point{3, 4} named := Point{X: 3, Y: 4} var zero Point fmt.Println(positional == named, zero) }
package main import "core:fmt" Point :: struct { x: int, y: int, } main :: proc() { named := Point{x = 3, y = 4} zero: Point // Partial literals fill the rest with zero values partial := Point{x = 3} fmt.println(named == Point{x = 3, y = 4}, zero, partial) }
Comparison with == works the same way in both, when every field is comparable. Go requires either all positional fields or the Field: form throughout.
Interfaces & Polymorphism
There are no interfaces
Odin has no interfaces, no implicit satisfaction, and no dynamic dispatch in the language. When the set of implementations is known and closed, a tagged union takes their place — and the switch over it is checked for exhaustiveness.
package main import "fmt" type Shape interface { Area() float64 } type Circle struct{ Radius float64 } type Rectangle struct{ Width, Height float64 } func (c Circle) Area() float64 { return 3.14159 * c.Radius * c.Radius } func (r Rectangle) Area() float64 { return r.Width * r.Height } func totalArea(shapes []Shape) float64 { total := 0.0 for _, shape := range shapes { total += shape.Area() } return total } func main() { shapes := []Shape{Circle{2}, Rectangle{3, 4}} fmt.Printf("%.2f\n", totalArea(shapes)) }
package main import "core:fmt" Circle :: struct { radius: f64 } Rectangle :: struct { width, height: f64 } // A tagged union is the closed-set replacement for an interface 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 largest conceptual gap on the page, so it gets three rows: the union here, an explicit vtable next, and any after. For a closed set the union is arguably better anyway — no boxing, values inline, dispatch as a jump table, and adding a variant surfaces every site that must handle it. What you give up is Go's open extension: a third party cannot add a Shape without editing this union.
Open extension: an explicit vtable
When you genuinely need open extension, you build the interface yourself: a struct holding a rawptr to the data and one procedure pointer per operation.
package main import "fmt" type Writer interface { Write(text string) int } type CountingWriter struct{ Total int } func (w *CountingWriter) Write(text string) int { w.Total += len(text) return len(text) } func writeTwice(writer Writer, text string) { writer.Write(text) writer.Write(text) } func main() { counter := &CountingWriter{} writeTwice(counter, "hello") fmt.Println(counter.Total) }
package main import "core:fmt" // The interface, written out by hand: data + a table of procedures 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) }
This is exactly what Go's runtime constructs behind an interface value — the difference is that here it is visible, so you can see the indirect call and the type erasure rather than inferring them. Odin's own Allocator and Logger are built this way, which is why the allocator is swappable at all.
any vs interface{}
Odin's any is a pointer plus a typeid, and the type switch over it reads almost exactly like Go's.
package main import "fmt" func describe(value any) { switch specific := value.(type) { case int: fmt.Println("int:", specific) case string: fmt.Println("string:", specific) default: fmt.Printf("other (%T): %v\n", value, value) } } func main() { describe(42) describe("hello") describe(3.5) }
package main import "core:fmt" describe :: proc(value: any) { switch specific in value { case int: fmt.println("int:", specific) case string: fmt.println("string:", specific) case: fmt.printfln("other (%T): %v", value, value) } } main :: proc() { describe(42) describe("hello") describe(3.5) }
The critical difference is lifetime: any points at the original value rather than boxing a copy, so storing one beyond the lifetime of what it references is a dangling pointer. Treat any as a parameter type for the duration of a call — which is how fmt uses it — not as something to keep in a struct.
Customizing behavior
Odin takes the comparison procedure as an ordinary argument, so there is no interface to implement. The literal is not a closure, but a comparison never needed to capture anything.
package main import ( "fmt" "sort" ) type Person struct { Name string Age int } type ByAge []Person func (a ByAge) Len() int { return len(a) } func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age } func main() { people := []Person{{"Ada", 36}, {"Bob", 25}} sort.Sort(ByAge(people)) fmt.Println(people) }
package main import "core:fmt" import "core:slice" Person :: struct { name: string, age: int, } main :: proc() { people := []Person{{"Ada", 36}, {"Bob", 25}} // Pass the comparison directly — no interface to implement slice.sort_by(people, proc(left, right: Person) -> bool { return left.age < right.age }) fmt.println(people) }
This is where the absence of interfaces reads as a simplification rather than a loss: Go's sort.Interface needs a named slice type and three methods to do the same job.
Enums, Unions & Bit Sets
Enums vs iota constants
An Odin enum is a genuine closed type, not an integer wearing a name. %v prints the member name with no generated code, and len, max, and for … in operate on the type itself.
package main import "fmt" type Direction int const ( North Direction = iota South East West ) // Printing the NAME requires a hand-written method or go:generate func (d Direction) String() string { return [...]string{"North", "South", "East", "West"}[d] } func main() { heading := East fmt.Println(heading, int(heading)) // Nothing prevents this — it is just an int bogus := Direction(99) fmt.Println(int(bogus)) }
package main import "core:fmt" Direction :: enum { North, South, East, West, } main :: proc() { heading := Direction.East // %v prints the NAME with no generated code fmt.println(heading, int(heading)) fmt.println("count:", len(Direction), "max:", max(Direction)) // Iterate the members directly for direction in Direction { fmt.print(direction, "") } fmt.println() }
Go's iota constants are integers, which is why printing one needs a String() method (usually generated by stringer) and why Direction(99) is a valid value there and has no Odin equivalent.
Unions & the nil variant
An Odin tagged union gives the exhaustiveness check Go's sealed-interface workaround cannot, and carries a nil state distinct from every variant.
package main import "fmt" // The Go idiom: an interface plus a type switch, with no // compiler check that every implementation is handled. type Event interface{ isEvent() } type Click struct{ X, Y int } type KeyPress struct{ Code int } func (Click) isEvent() {} func (KeyPress) isEvent() {} func handle(event Event) string { switch specific := event.(type) { case Click: return fmt.Sprintf("click at %d,%d", specific.X, specific.Y) case KeyPress: return fmt.Sprintf("key %d", specific.Code) } return "unknown" } func main() { fmt.Println(handle(Click{3, 4})) fmt.Println(handle(KeyPress{27})) }
package main import "core:fmt" Click :: struct { x, y: int } Key_Press :: struct { code: int } Event :: union { Click, Key_Press, } handle :: proc(event: Event) -> string { switch specific in event { case Click: return fmt.aprintf("click at %d,%d", specific.x, specific.y) case Key_Press: return fmt.aprintf("key %d", specific.code) } return "no event" } main :: proc() { click := handle(Click{3, 4}) defer delete(click) key := handle(Key_Press{27}) defer delete(key) fmt.println(click) fmt.println(key) // A union has a nil state meaning "no variant set" empty: Event fmt.println("empty is nil:", empty == nil) }
The Go column shows the standard workaround — an unexported marker method — which still gives you no exhaustiveness check. The nil state means "no event yet" is representable without a separate pointer or boolean; add #no_nil to require a variant always be set.
Bit sets vs bitmask constants
bit_set[Flag; u8] is a real type over an enum: the compiler assigns the bits, in tests membership, card counts, and set operators replace masking. The ; u8 pins the backing width.
package main import "fmt" type Permission uint8 const ( Read Permission = 1 << iota Write Execute ) func main() { granted := Read | Write // Nothing stops you writing granted | 64 — it is just a uint8 fmt.Println("can read:", granted&Read != 0) fmt.Println("can execute:", granted&Execute != 0) fmt.Printf("raw: %08b\n", granted) }
package main import "core:fmt" Flag :: enum { Read, Write, Execute, } Permission :: bit_set[Flag; u8] main :: proc() { granted: Permission = {.Read, .Write} fmt.println("can read:", .Read in granted) fmt.println("can execute:", .Execute in granted) fmt.println("count:", card(granted)) fmt.println("as a value:", granted, "size:", size_of(Permission)) // Set algebra rather than hand-rolled masking all: Permission = {.Read, .Write, .Execute} fmt.println("missing:", all &~ granted) }
Go's 1 << iota bitmask is a convention over a plain integer — the shifts are yours to get right, any integer can be assigned in, and printing means writing a decoder. Pinning the width matters when the layout must match an external format.
Optional values
Maybe(T) is a union of T and nothing, so absence lives in the return type. The .? suffix unwraps it into a value and a flag, reading much like Go's comma-ok.
package main import "fmt" // Go's options: a pointer, or a comma-ok pair, or a sentinel func findIndex(values []int, wanted int) (int, bool) { for index, value := range values { if value == wanted { return index, true } } return 0, false } func main() { numbers := []int{10, 20, 30} if index, found := findIndex(numbers, 20); found { fmt.Println("found at", index) } _, found := findIndex(numbers, 99) fmt.Println("99 found:", found) }
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) } result := find_index(numbers, 99) fmt.println("99 found:", result != nil) }
The difference is that a Maybe cannot be silently used without unwrapping, whereas ignoring Go's found hands you a zero value indistinguishable from a real one.
Error Handling
Errors without an error interface
The shape is Go's — a value returned alongside the result — but there is no error interface, because there are no interfaces. An error is usually an enum whose zero member is None.
package main import ( "errors" "fmt" "strconv" ) var ErrOutOfRange = errors.New("out of range") func parsePositive(text string) (int, error) { value, err := strconv.Atoi(text) if err != nil { return 0, fmt.Errorf("parsing %q: %w", text, err) } if value <= 0 { return 0, ErrOutOfRange } return value, nil } func main() { value, err := parsePositive("42") fmt.Println(value, err) _, err = parsePositive("abc") fmt.Println("failed:", err != nil) }
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("abc") fmt.println("failed:", failure != .None) }
That makes it a single integer with no allocation and, critically, a closed set the compiler can check in a switch. What you lose is Go's wrapping chain: %w and errors.Is have no direct equivalent, so context is added by returning a union of error enums or a struct instead.
or_return vs if err != nil
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 it along. Named results tell the compiler what to return.
package main import ( "fmt" "strconv" ) func parseValue(text string) (int, error) { return strconv.Atoi(text) } func doubleValue(text string) (int, error) { value, err := parseValue(text) if err != nil { return 0, err } return value * 2, nil } func quadruple(text string) (int, error) { doubled, err := doubleValue(text) if err != nil { return 0, err } return doubled * 2, nil } func main() { value, err := quadruple("21") fmt.Println(value, err) _, err = quadruple("nope") fmt.Println("failed:", err != nil) }
package main import "core:fmt" import "core:strconv" Parse_Error :: enum {None, Not_A_Number} parse_value :: proc(text: string) -> (value: int, error: Parse_Error) { parsed, ok := strconv.parse_int(text) if !ok { return 0, .Not_A_Number } return parsed, .None } double_value :: proc(text: string) -> (result: int, error: Parse_Error) { value := parse_value(text) or_return return value * 2, .None } quadruple :: proc(text: string) -> (result: int, error: Parse_Error) { doubled := double_value(text) or_return return doubled * 2, .None } main :: proc() { value, error := quadruple("21") fmt.println(value, error) _, failure := quadruple("nope") fmt.println("failed:", failure != .None) }
If one feature justifies this page for a Go programmer, this is it: the three-line if err != nil { return err } block becomes a suffix, and adding a layer to the call chain adds one word rather than a block. It does the same job as Rust's ? while leaving the early return visible in the expression.
or_else for defaults
or_else supplies a fallback inline for anything returning a value plus an ok flag or error — including a map lookup.
package main import ( "fmt" "strconv" ) func main() { port, err := strconv.Atoi("not-a-port") if err != nil { port = 8080 } fmt.Println("port", port) settings := map[string]int{"timeout": 30} retries, ok := settings["retries"] if !ok { retries = 3 } fmt.Println("retries", retries) }
package main import "core:fmt" import "core:strconv" main :: proc() { // No temporary, no if port := strconv.parse_int("not-a-port") or_else 8080 fmt.println("port", port) settings := make(map[string]int) defer delete(settings) settings["timeout"] = 30 retries := settings["retries"] or_else 3 fmt.println("retries", retries) }
Where or_return propagates a failure, or_else substitutes a default. It turns the four-line comma-ok-then-if that Go requires into a single expression.
panic & no recover
Odin has panic and assert but no recover — a panic terminates the process, so anything a caller might handle has to be a return value, as risky shows.
package main import "fmt" func risky() (result string) { defer func() { if problem := recover(); problem != nil { result = fmt.Sprint("recovered: ", problem) } }() panic("something broke") } func main() { fmt.Println(risky()) fmt.Println("execution continues") }
package main import "core:fmt" risky :: proc(should_fail: bool) -> (result: string, ok: bool) { // There is no recover(), so a failure a caller might handle // MUST be a return value — it cannot be a panic. if should_fail { return "", false } return "succeeded", true } main :: proc() { // assert is compiled out in release builds assert(1 + 1 == 2, "arithmetic still works") // panic("something broke") // would abort the process outright // os.exit(1) // and no defer below it would run fmt.println(risky(false)) fmt.println(risky(true)) fmt.println("execution continues") }
There is no stack unwinding, so the panic-and-recover-at-a-boundary pattern that Go HTTP servers and parsers lean on has no equivalent. One related compile-time nicety: because os.exit never returns, the compiler rejects any defer that could only run after it, so an unreachable cleanup is an error rather than a leak you find later.
Memory & Allocators
The garbage collector is gone
There is no garbage collector, no escape analysis, and no runtime deciding where a value lives — new allocates and someone must free. Ownership travels with the return value: build hands the caller a responsibility.
package main import "fmt" type Node struct { Value int Next *Node } func build() *Node { // Escape analysis puts this on the heap; the GC frees it later head := &Node{Value: 1} head.Next = &Node{Value: 2} return head } func main() { list := build() fmt.Println(list.Value, list.Next.Value) // Nothing to free }
package main import "core:fmt" Node :: struct { value: int, next: ^Node, } build :: proc() -> ^Node { head := new(Node) head.value = 1 head.next = new(Node) head.next.value = 2 return head // the CALLER now owns this } main :: proc() { list := build() defer { free(list.next) free(list) } fmt.println(list.value, list.next.value) }
Everything else on this page is syntax; this is the actual change. Saying so in a doc comment is the norm. In practice the next few rows matter more, because arenas and the temp allocator mean most code never writes an individual free at all.
context: same word, different thing
Odin's context is an implicit parameter carrying the allocator, a temp allocator, and a logger — never declared, never passed. Assigning context.allocator redirects allocation for this scope and everything beneath it.
package main import ( "context" "fmt" "time" ) // Go's context carries cancellation and deadlines, and must be // threaded explicitly through every function signature. func collect(ctx context.Context) []int { select { case <-ctx.Done(): return nil default: return []int{1, 2, 3} } } func main() { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() fmt.Println(collect(ctx)) }
package main import "core:fmt" import "core:mem" // No context parameter anywhere — it is implicit 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 allocation for this scope AND everything it calls context.allocator = mem.arena_allocator(&arena) values := collect() fmt.println("from the arena:", values) fmt.println("nothing to free — the arena owns it all") }
Both languages have a context and they have almost nothing in common — confusing them is the most likely early mistake. Go's carries cancellation and deadlines and must be threaded explicitly through every signature. Swapping in an arena, a pool, or a failing allocator for a whole subsystem is a one-line change no signature has to hear about.
The temporary allocator
The context carries a second allocator for short-lived values, reclaimed wholesale by free_all rather than one delete at a time.
package main import "fmt" func describe(value int) string { // Allocates; the GC reclaims it whenever it gets around to it return fmt.Sprintf("value is %d", value) } func main() { for value := 1; value <= 3; value++ { fmt.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 strings at once }
In a program with a natural cycle — a frame, a request, a tick — you call free_all(context.temp_allocator) once at the boundary and every scratch value disappears. This is the closest Odin comes to a garbage collector's convenience, except you choose the collection point and it is O(1).
Finding leaks
Because an allocator is an ordinary value, wrapping one is just composition. Tracking_Allocator records every allocation with its source location and reports whatever was never freed.
package main import ( "fmt" "runtime" ) func main() { // Go has no leaks to find — but you can watch the GC work var stats runtime.MemStats runtime.ReadMemStats(&stats) before := stats.Mallocs data := make([]byte, 1024) _ = data runtime.ReadMemStats(&stats) fmt.Println("allocations happened:", stats.Mallocs > before) }
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) } }
Go needs no such tool, but it also gives you no equivalent lever: this is scoped to whatever subsystem you install it on, works identically in a release build, and costs nothing when not installed.
Guaranteeing no allocation
Odin gives you two ways to guarantee no allocation: format into a caller-supplied buffer with fmt.bprintf, or install mem.panic_allocator() as context.allocator so any allocation in that scope aborts loudly.
package main import "fmt" func main() { // No way to say "this section must not allocate". // sync.Pool and careful buffer reuse reduce pressure, // but nothing makes an accidental allocation an error. buffer := make([]byte, 0, 64) for index := 1; index <= 3; index++ { buffer = fmt.Appendf(buffer, "%d ", index) } fmt.Println(string(buffer)) }
package main import "core:fmt" main :: proc() { // bprintf formats into a caller-supplied buffer: zero allocations backing: [128]byte written := 0 for index in 1 ..= 3 { written += len(fmt.bprintf(backing[written:], "%d ", index)) } fmt.println(string(backing[:written])) fmt.println("bytes used from the stack buffer:", written) }
For an audio callback, an interrupt handler, or a frame budget, the useful guarantee is not "allocates rarely" but "cannot allocate". Go's GC can be tuned and its pressure reduced, but there is no way to make an accidental allocation a hard error.
Generics
Generic procedures
The $ prefix marks a parameter the compiler infers and specializes on — there is no constraint interface to name.
package main import ( "cmp" "fmt" ) func Largest[T cmp.Ordered](values []T) T { best := values[0] for _, value := range values[1:] { if value > best { best = value } } return best } func main() { fmt.Println(Largest([]int{3, 17, 8})) fmt.Println(Largest([]float64{1.5, 0.5})) fmt.Println(Largest([]string{"pear", "apple"})) }
package main import "core:fmt" // $T is inferred; no constraint interface 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 specialize at compile time and both infer the type argument. Go requires a named constraint such as cmp.Ordered; Odin instantiates the body and reports an error if an operation is unsupported, closer to C++ templates. The cost is that a bad instantiation reports its error inside the procedure body rather than at the call site.
Constraining a type parameter
A where clause takes any compile-time boolean expression, not a type set — a predicate from base:intrinsics, a size comparison, a relation between two parameters. Note the base:intrinsics import.
package main import "fmt" type Number interface { ~int | ~int64 | ~float64 } func sumAll[T Number](values []T) T { var total T for _, value := range values { total += value } return total } func main() { fmt.Println(sumAll([]int{1, 2, 3})) fmt.Println(sumAll([]float64{1.5, 2.5})) }
package main import "core:fmt" import "base:intrinsics" // A `where` clause is a compile-time boolean, not an interface 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 Go constraint interface, which can only enumerate types, and it moves the error back to the call site where it belongs.
Generic over values, not just types
[$N]int matches an array of any length and makes that length available as the compile-time constant N — usable in the body and the return type.
package main import "fmt" // Go generics parameterize over TYPES only. An array length // cannot be a type parameter, so this takes a slice and // loses the compile-time length. func sumFixed[T ~int](values []T) T { var total T for _, value := range values { total += value } return total } func main() { triple := [3]int{1, 2, 3} fmt.Println(sumFixed(triple[:])) }
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})) }
So doubled returns an array of exactly the length it received, checked at compile time. Go generics parameterize over types only, so the closest equivalent takes a slice and loses the static length.
Concurrency
Threads, not goroutines
There is no go keyword, no scheduler, and no green threads. core:thread gives real OS threads, and because a thread procedure cannot be a closure, shared state is gathered into a struct and passed as poly data.
package main import ( "fmt" "sync" ) func main() { var counter int var mutex sync.Mutex var group sync.WaitGroup for range 4 { group.Add(1) go func() { defer group.Done() for range 1000 { mutex.Lock() counter++ mutex.Unlock() } }() } group.Wait() fmt.Println("counter:", counter) }
package main import "core:fmt" import "core:sync" import "core:thread" Shared :: struct { counter: ^int, mutex: ^sync.Mutex, waitgroup: ^sync.Wait_Group, } main :: proc() { counter := 0 mutex: sync.Mutex waitgroup: sync.Wait_Group shared := Shared{&counter, &mutex, &waitgroup} sync.wait_group_add(&waitgroup, 4) workers: [4]^thread.Thread for index in 0 ..< 4 { workers[index] = thread.create_and_start_with_poly_data(&shared, proc(data: ^Shared) { defer sync.wait_group_done(data.waitgroup) for _ in 0 ..< 1000 { sync.guard(data.mutex) data.counter^ += 1 } }) } sync.wait_group_wait(&waitgroup) for worker in workers { thread.destroy(worker) } fmt.println("counter:", counter) }
This is the feature Go programmers will miss most. OS threads are expensive to create, so you pool them rather than spawning thousands. sync.Mutex and sync.Wait_Group map directly onto their Go counterparts, and sync.guard is a scoped lock that saves the defer Unlock. The visible plumbing is what Go's closure capture was hiding.
No channels or select
Odin has no channels and no select. core:sync gives mutexes, semaphores, condition variables, futexes, and atomics, and you assemble the queue yourself.
package main import "fmt" func main() { results := make(chan int, 3) go func() { defer close(results) for value := 1; value <= 3; value++ { results <- value * 10 } }() for value := range results { fmt.Println("received", value) } }
package main import "core:fmt" import "core:sync" import "core:thread" // No channels: a mutex-guarded slice is the plain equivalent Mailbox :: struct { values: [dynamic]int, mutex: sync.Mutex, } main :: proc() { mailbox: Mailbox defer delete(mailbox.values) producer := thread.create_and_start_with_poly_data(&mailbox, proc(box: ^Mailbox) { for value in 1 ..= 3 { sync.guard(&box.mutex) append(&box.values, value * 10) } }) thread.join(producer) thread.destroy(producer) for value in mailbox.values { fmt.println("received", value) } }
The CSP style Go is built around does not transfer. This is a genuine trade, not a hidden win: Odin bought a smaller runtime and no scheduler in the binary, and the price is that structured concurrency is something you build rather than something you are given.
Atomics
sync.atomic_add and sync.atomic_load operate on an ordinary i64 rather than Go's wrapper type, and each takes an optional memory-ordering argument.
package main import ( "fmt" "sync" "sync/atomic" ) func main() { var counter atomic.Int64 var group sync.WaitGroup for range 4 { group.Add(1) go func() { defer group.Done() for range 1000 { counter.Add(1) } }() } group.Wait() fmt.Println("counter:", counter.Load()) }
package main import "core:fmt" import "core:sync" import "core:thread" Shared :: struct { counter: ^i64, waitgroup: ^sync.Wait_Group, } main :: proc() { counter: i64 waitgroup: sync.Wait_Group shared := Shared{&counter, &waitgroup} sync.wait_group_add(&waitgroup, 4) workers: [4]^thread.Thread for index in 0 ..< 4 { workers[index] = thread.create_and_start_with_poly_data(&shared, proc(data: ^Shared) { defer sync.wait_group_done(data.waitgroup) for _ in 0 ..< 1000 { sync.atomic_add(data.counter, 1) } }) } sync.wait_group_wait(&waitgroup) for worker in workers { thread.destroy(worker) } fmt.println("counter:", sync.atomic_load(&counter)) }
The default ordering matches Go's sequential consistency, so the extra control (.Relaxed, .Acquire, .Release) is opt-in rather than something you must reason about immediately.
Compile Time & Data Layout
Conditional compilation
when is part of the language: the condition is a typed expression over constants such as ODIN_OS, ODIN_ARCH, and ODIN_DEBUG. Only the taken branch is compiled.
package main import ( "fmt" "runtime" ) // Platform-specific code needs separate _darwin.go / _linux.go // files or //go:build tags — the compiler sees text, not values. func main() { switch runtime.GOOS { case "darwin": fmt.Println("running on macOS") case "linux": fmt.Println("running on Linux") default: fmt.Println("running on", runtime.GOOS) } fmt.Println("architecture:", runtime.GOARCH) }
package main import "core:fmt" main :: proc() { // A real typed expression — only the taken branch is compiled when ODIN_OS == .Darwin { fmt.println("compiled for macOS") } else when ODIN_OS == .Linux { fmt.println("compiled for Linux") } else { fmt.println("compiled for", ODIN_OS) } fmt.println("architecture:", ODIN_ARCH) fmt.println("debug build:", ODIN_DEBUG) }
Go splits platform code across _darwin.go / _linux.go files or //go:build tags, which are comments the toolchain reads before compiling — a typo silently excludes a file. Here a mistake is a compile error, and the untaken branches need not even be valid for that target.
Running code at compile time
Odin evaluates constant expressions, when branches, and #config values during compilation, and #load embeds a file's bytes directly into the binary.
package main import "fmt" // A lookup table must be built at init time, or generated // by go:generate and checked in. There is no compile-time // execution in the language. var squares [10]int func init() { for index := range squares { squares[index] = index * index } } func main() { fmt.Println(squares) }
package main import "core:fmt" // Built by the COMPILER — the table is baked into the binary build_squares :: proc() -> [10]int { table: [10]int for index in 0 ..< 10 { table[index] = index * index } return table } SQUARES :: #force_inline proc() -> [10]int { return build_squares() } main :: proc() { // A constant expression: no init cost, no runtime loop squares := build_squares() fmt.println(squares) fmt.println("computed at compile time:", #config(BAKED, true)) }
Go has no compile-time execution: a lookup table is either built in init(), paying the cost at startup, or generated by go:generate and checked in. Odin's is not the arbitrary compile-time interpreter Zig's comptime provides, but it removes most of what go:generate exists to do.
Struct of arrays
Prefixing an array type with #soa stores each field as its own contiguous column, while entities[index].field indexing stays exactly the same.
package main import "fmt" type Entity struct { X, Y float32 Health int } func main() { // Array of structs: X, Y, Health interleaved in memory. // A loop touching only Health strides past the rest. entities := make([]Entity, 4) entities[0].Health = 50 entities[1].Health = 75 // To get struct-of-arrays you declare a DIFFERENT type // and rewrite every access site. healths := make([]int, 4) healths[0] = 50 fmt.Println(entities[0].Health, entities[1].Health, healths[0]) }
package main import "core:fmt" Entity :: struct { x, y: f32, health: int, } main :: proc() { // Array of structs — interleaved, as in Go 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) // The columns are reachable as slices fmt.println("health column:", columnar.health) }
A hot loop touching one field then reads packed memory instead of striding past the others, and the change is one keyword rather than a new type and a rewrite of every access site. Go cannot express this at all — nor can C, Rust, or Zig without a macro. It is the clearest single reason Odin exists.