A parameter is an immutable binding welded to the value it arrived with. Inside the procedure you may read it, never reassign it — n += 1 on a parameter n is a compile error, for every type. So a procedure can't reach back and change what the caller handed it… unless the caller hands over an address. Pick how the call is made and watch whether the change escapes.
The rule, enforced. Try to write a value parameter directly and the build stops — and the error names the way out:
increment :: proc(some_int: int) { some_int += 1 // a parameter is an immutable binding } Error: Cannot assign to 'some_int' which is a procedure parameter Suggestion: Did you mean to pass 'some_int' by pointer?
That suggestion is the whole lesson in one line: the only way to make a write the caller can see is to take a ^T and write through it.
Level 1 was the rule. Level 2 is the ergonomics that ride on it: because each parameter is a fixed, named slot, the call site can be expressive — give some parameters defaults so callers can omit them, and name arguments at the call so the call reads itself.
Default values live in the signature: crit: bool = false. Omit that argument and the default is supplied for you. Named arguments let the caller write y = 5, x = 3 — in any order — instead of relying on position, and they compose with defaults so you can set a later parameter while leaving the earlier ones alone. Build a call and watch the result:
Why this is more than sugar. A positional call like spawn(3, 5, 100) makes the reader memorize the parameter order to know what 100 means. Naming it — spawn(x = 3, y = 5, hp = 100) — moves that knowledge into the call itself, and a default keeps the common case short. Both are possible only because a parameter is a named slot with a fixed identity, not a free local the body can reshuffle.
Level 2 sold you on the call site. Level 3 is the part that surprises people: even a large aggregate is passed as an immutable value — and to make that cheap, the compiler is allowed to hand the big one over as a hidden pointer. That promotion is invisible, with one exact exception worth seeing once.
A Big :: struct { a, b, c, d: int } is size_of = 32 bytes. The odin calling convention passes anything over 16 bytes by reference rather than copying every byte — but because Level 1 makes the parameter unwritable, you can't tell whether you got a copy or that hidden pointer. It changes nothing about how you reason… until you pass the same variable as both a big value parameter and an explicit ^Big. Toggle the two calls:
The fix: when you genuinely need a guaranteed-independent copy of a big value parameter, force one the same way you make any mutable local — shadow it: v := v as the first line. That makes a fresh value the explicit pointer can't reach. In practice the leak needs that one weird shape — the same object handed in twice, once by value and once by ^T — so you almost never meet it; the takeaway is that "passed by value" is a promise about semantics (immutable, can't be changed by the callee), not a promise that 32 bytes were physically copied.
The last level isn't a mechanism — it's a property that emerges once immutability and the explicit ^ work together: the call site becomes a readable contract. You can tell what a procedure is allowed to do to your data without reading its body.
Because a procedure can never reassign or mutate a plain value parameter, the only way it changes the caller is through a pointer the caller chose to hand over — and handing it over is spelled with a literal & at the call. So the call site advertises intent: a bare argument means "you only get to read this"; an & means "yes, I'm letting you change this." Reading a block of calls, your eye scans for the carets:
// no & — these can only read what they're given sum := inspect(world) pretty := format(player) // & — these are allowed to write back into the caller's storage add_point(&score) update_entity(&player, dt)
And the contract is machine-checked, not a convention. You can't accidentally let a write escape, and you can't accidentally pass-by-pointer — if a proc wants a ^int and you forget the &, the build stops you and even spells the fix:
add_point :: proc(score: ^int) { score^ += 1 } add_point(score) // forgot the & Error: Cannot assign value 'score' of type 'int' to '^int' in a procedure argument Suggestion: Did you mean `&score`
The emergent payoff: in a codebase with hundreds of calls, "which of these can change my state?" stops being a question you answer by reading procedure bodies and becomes one you answer by scanning for &. Immutability removes every other channel; the explicit caret is the single, visible, compiler-enforced one. Mutation is opt-in, written at the call, and impossible to do by accident — that's the whole reason a parameter is welded shut by default.
That's the arc: L1 a parameter is an immutable binding — to change the caller you take a ^T and write through it → L2 that fixed-slot identity is what lets the call site carry defaults and named arguments → L3 even big aggregates pass as immutable values (a hidden pointer for speed, observable only in the same-object aliasing case) → L4 immutability + the explicit & make the call site a readable, compiler-checked contract.