A pointer is a variable whose value is the address of some other variable. It does not hold the data — it holds where the data lives. Below, x is an int sitting somewhere in memory; p := &x is a second variable whose 8 bytes are that address. Write through p and watch x change, even though you never named x.
Level 1 was what it is. Level 2 is why you'd want one: shared mutation without copying. A proc that takes ^Player and one that takes Player have the same body — only the caret differs — and they behave completely differently.
Picture a heal proc that adds HP. You call it on a Player{name = "Ada", hp = 20}. The only question is whether the proc sees the caller's Player or a throwaway copy of it. Step through both:
The mechanism: a Player parameter is passed by copy — the proc gets its own Player, mutates that, and the copy is discarded when the proc returns. A ^Player parameter is passed an address, so the proc and the caller are reading and writing the same bytes. The pointer is what lets a callee reach back and change the caller's data. This is the same aliasing you met with slices in lesson 06 — two names for one storage, a write through either is visible through both.
The counterfactual: if heal only needed to read the Player (say, to print its HP), the copy would be fine — even preferable, because the callee then can't accidentally clobber the caller's data. You reach for ^Player precisely when you want the write to stick: the proc's whole job is to mutate the caller's object in place. No mutation needed, no pointer needed.
Level 2 sold you on the pointer. Level 3 is the bill: a pointer is just a number, and nothing keeps the thing it points at alive. Lifetimes are your job — Odin has no garbage collector and no compile-time lifetime tracking.
The signature footgun is the dangling pointer: a pointer into storage that has already been reclaimed. Watch what happens when a proc returns the address of its own local:
Where Odin helps — and where it doesn't. Odin has a targeted escape check that rejects the most obvious shape at compile time: returning the address of a local. That exact program does not build —
dangling :: proc() -> ^int { local := 7 return &local // the address of a local that's about to vanish } Error: It is unsafe to return the address of a local variable
But that check guards one shape, not lifetimes in general. An address that escapes indirectly — stored through an out-pointer, tucked into a struct that outlives the call, or held past an arena reset (lesson 09) — compiles silently and still dangles. The pointer has no idea whether its target is alive; reading through a stale one gives you whatever the next code left there, or a crash.
The last level is the payoff that emerges from the type system: Odin splits "pointer" into three distinct types, and the type itself tells you what you're allowed to do. Reading Odin, you know the author's intent; the compiler enforces it.
"Pointer" actually bundles three different jobs: name exactly one value, mark the start of a run whose length you track separately, and carry a bounded view that knows its own length. Collapse them into a single type and every reader has to guess which job a given pointer is doing. Odin refuses to guess — it gives each job its own type, and each type forbids the operations that don't belong to it:
| Type | Means | Length? | Index? | Deref p^? | p + i? |
|---|---|---|---|---|---|
| ^T | pointer to one T | no (always 1) | no | yes | no |
| [^]T | multi-pointer: an unknown count of T | no | yes (unchecked) | no | no (slice instead) |
| []T | slice: a bounded view (lesson 06) | yes (carries len) | yes (checked) | no | no |
widths: ^T = 8 · [^]T = 8 · []T = 16 (a slice is a ^T plus a length — one extra word of bookkeeping.)
Each "no" in that table is a real compile error, not a convention. Try to break the rules and the build stops you — pick one:
The emergent payoff: because the three jobs live in three types, the type is the documentation. A ^Player parameter promises "I touch exactly one Player." A [^]int announces "a run of unknown length that arrived with no count attached — I'll turn it into a []int the moment I know the length." A []Player says "a bounded run I can iterate safely." You don't read a comment to learn intent; you read the type, and the compiler has already checked that the code obeys it. That's the whole reason a language that hands you raw addresses spends a keyword on this: it makes sharing and mutation explicit and machine-checked instead of conventional and hopeful.
That's the arc: L1 a pointer is an address — write through it, the target changes → L2 that's why you pass ^T: shared mutation, no copy → L3 the bill is lifetimes, which are yours (Odin guards the obvious dangling shape, nothing more) → L4 three pointer types make intent explicit and compiler-checked.