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.