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.
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:
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.
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:
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.
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.