Odin · fixed arrays

A fixed array, four levels deep

A fixed array [N]T is N values of type T laid end to end in one block of memory — nothing in front, nothing between, no separate length stored anywhere. The count N is baked into the type itself: [4]int and [5]int are different types. Pick a slot below and watch where its bytes live.

Real bytes, measured from a compiled Odin program (little-endian). Each int slot is 8 bytes; byte index is top-left of each cell.
all four slots (one block) the slot you picked high bytes of each int (zero)

Widths (size_of)

int      = 8
[4]int  = 32 = 4 × 8
f32      = 4
Vec3   = 12 = 3 × 4
align_of([4]int) = 8

len(a) = 4 — a compile-time constant; it rides inside the type, not as a stored word.

What you're seeing

Because every slot is the same width and they sit back to back, reaching a[i] is a direct indexed access: start at the block, step i slots of 8 bytes, you're there. The cost of getting any element is the same as getting the first — there is no chain to follow off to somewhere else in memory. That uniform stride is the whole speed story of a packed array, and it is why a [N]T streams through the cache cleanly when you loop over it.

Level 1 was what it is. Level 2 is the property that surprises people: a fixed array is a value. Assigning it, or passing it to a proc, copies every element — you get a second, independent block. There is no sharing by default.

Two lines that look identical but mean opposite things. b := a copies all four ints into a fresh block named b. s := a[:] takes a slice — a view that points back at a's own storage, no copy. Then write 999 through each and watch who changes:

b := a

a copy — a second block of 32 bytes

s := a[:]

a slice — a view into a's bytes

The mechanism: the array literally is its bytes, so b := a duplicates those bytes. b and a now have no connection — a write to one cannot be seen by the other. A []int slice is a different thing entirely: it is a small handle (a start and a length) that refers to storage it does not own. Writing through the slice reaches a's real bytes, so the change is visible under both names. Copy versus reference — and the array picked copy.

The counterfactual: if you only ever read the array, the copy semantics never cost you a thought — handing a proc a [4]int gives it a private duplicate it cannot use to clobber yours. You only need to think about it when you want a write to stick, which is exactly when you reach for a slice or a pointer (Level 3). Value-by-default means the safe, non-aliasing case is the one you get for free.

the copy is so complete the parameter is read-only A by-value array parameter isn't just a copy you can mutate harmlessly — Odin treats it as immutable. Writing x[0] = 999 on a proc(x: [4]int) parameter doesn't silently mutate a throwaway copy; it doesn't compile: Error: Cannot assign to 'x[0]'. If you genuinely want a local scratch array, you copy the parameter into a local := x first and mutate that. The language won't let you mistake "I'm editing my copy" for "I'm editing the caller's array."

Level 2 sold you on value semantics. Level 3 is the bill: a copy is real bytes moved. At four ints it's nothing; at a few kilobytes, every assignment and every call quietly duplicates the whole block of bytes.

A [4]int is 32 bytes — copying it is a couple of register loads, free in practice. But the copy is proportional to size_of, and a fixed array can be large: a [4096]u8 pixel tile is 4096 bytes, copied in full every time you pass it by value. Toggle the element type and watch the per-call cost:

copied by value
^[N]T or []T handle

The fix: when the array is big and you don't need a private copy, hand over a handle instead of the bytes. A ^[4]int (a pointer to the array) or a []int slice is one 8-byte address (the slice adds a length word, so 16 bytes) — fixed and tiny no matter how big the array is. The proc reads and writes the caller's real storage through it. Below, by_value mutates its copy and the caller is untouched; by_pointer takes ^[4]int and the write lands on the original:



    

The payoff hiding in the same feature: because a numeric [N]T is a real value the language understands, it gives you element-wise arithmetic with the plain operators — no loop, no library. Vec3{1,2,3} + Vec3{10,20,30} is one expression:


    

That is why a fixed array is the natural type for small vectors and colors in game and graphics code: it is a packed value with built-in math, and the only tax is the copy you now know how to dodge when it grows.

a slice is not a copy — and not a separate length you pass The handle you reach for, []int, is a view, not a duplicate: it borrows the array's storage and carries the array's length with it, so len(s) always answers correctly. You never hand a proc the array and a separate count argument that could disagree — the length travels inside the slice (and inside the fixed array's own type). The class of bug where a length argument drifts out of sync with the data it describes simply has no place to live here.

The last level isn't about bytes — it's the correctness property that emerges from N living in the type. The length isn't a runtime number you hope is right; it's a fact the compiler knows, and it spends that knowledge guarding every access.

An out-of-bounds read splits into two cases, and Odin handles each at the earliest moment it possibly can. When the index is a compile-time constant, the compiler already knows the length, so it proves the access is bad and refuses to build — no runtime check is even emitted. Pick a case:


    

And because the length is part of the type, mixing lengths is a type error, caught the same way. Element-wise + demands matching shapes, so adding a [3]f32 to a [4]f32 never silently runs off the end of the shorter one — it doesn't build at all:

p: [3]f32 = {1, 2, 3}
q: [4]f32 = {10, 20, 30, 40}
r := p + q

Error: Mismatched types in binary expression 'p + q' : '[3]f32' vs '[4]f32'

The emergent payoff: a whole family of bugs — reading past the end, adding mismatched-length vectors, handing a proc the wrong-sized array — stops being a runtime hunt and becomes a compile error the moment you write it, because the length is a fact the type system carries rather than a number you track by hand. The runtime bounds check is the safety net for the one case the compiler genuinely can't decide ahead of time (an index it can't prove); everything it can prove, it catches before the program ever runs.

That's the arc: L1 N values end to end in one block, N baked into the type → L2 that block is a value, so assigning or passing it copies — slices are how you share instead → L3 the copy is real bytes, cheap when small, so big arrays travel by ^[N]T or []T handle → L4 N-in-the-type turns out-of-bounds and length-mismatch into compile errors, with a runtime check only for what can't be proven.

probes reproduce with odin run · widths, bytes, outputs & the L2/L4 errors are real compiler output (claims/lessons/05-arrays-fixed)