← History

Immutable by Default, or Not: how eight languages treat change

Eight languages, one question - who has to ask permission before a value is allowed to change?

RustOCamlHaskellGujiGoPythonPerlRaku

Every language has an opinion about change, and most of the time that opinion is hidden in the default. The interesting question is not "can I mutate this?" - almost everywhere the answer is eventually yes - but rather "who has to ask?" When a binding is immutable by default, the programmer who wants mutation must say so out loud, and the reader can trust silence. When it is mutable by default, silence promises nothing. That single inversion shapes how each of these eight languages feels to write and to read.

Immutable by default: rust, ocaml, haskell, guji

Rust draws the line at the binding. A plain let is immutable, and you opt in to mutation with mut:

let x = 10;
// x = 20;        // error: cannot assign twice to immutable variable
let mut y = 10;
y = 20;           // fine

Rust then layers ownership and borrowing on top, so even mut is governed: you may have many shared & references or exactly one &mut, never both at once. Immutability here is not only a courtesy to the reader, it is load-bearing for memory safety without a garbage collector.

OCaml splits the idea differently. Variable bindings via let are immutable, but you do not write mut; instead mutation lives in specific cells. A ref or a mutable record field is the explicit, visible carrier of change:

let x = 10 in           (* a binding, never reassigned *)
let r = ref 0 in        (* a mutable cell *)
r := !r + 1             (* := and ! are the tell *)

So in OCaml the syntax itself flags the mutation site - := and ! are impossible to overlook - which keeps the imperative parts of a program legible against an otherwise functional grain.

Haskell takes the strictest stance. There is no reassignment to reach for, because = is definition, not assignment. A name denotes one value forever, and "change over time" is modelled as data flowing through pure functions:

let xs = [1,2,3]
    ys = map (*2) xs   -- ys is new; xs is untouched

Genuine mutable state exists (IORef, STRef, MVar), but it is quarantined inside the type system. A function that can mutate the world wears IO in its signature, so purity is not a convention you hope colleagues follow, it is a fact the compiler enforces. Laziness reinforces this: because values are computed on demand and never updated in place, sharing an unevaluated expression is always safe.

Guji, the in-house compiled language (v0.1-alpha), sits squarely in this camp and is worth dwelling on, because it makes a sharp, opinionated choice. Bindings are immutable by default, and reassignment is a hard error - caught the same way whether you run the reference interpreter or the native AOT compiler:

sub main() {
    $x = 10
    $x = 20          # runtime error: cannot reassign immutable binding $x
}

To get a reassignable local you write mut, exactly as in Rust:

sub main() {
    mut $total = 0
    @nums = [1, 2, 3, 4]
    for $n in @nums {
        $total = $total + $n
    }
    print("loop total = $total")   # loop total = 10
}

What makes guji distinctive is the next rule. A mut binding cannot be captured by a closure at all - the compiler refuses it and points you at the spec:

sub make_counter() {
    mut $n = 0
    sub() { $n = $n + 1 }   # error: cannot capture mut binding $n
}                           #        in a closure; bind it immutably first

The familiar mutable-counter-in-a-closure trick, beloved of JavaScript and Python, is simply disallowed. Guji is functional-first, so it pushes you toward the alternative: keep mut confined to a loop body, or, better, transform whole collections and let the old value stand. The collection operations are non-destructive by construction:

sub main() {
    @nums = [1, 2, 3, 4, 5]
    @doubled = @nums.map({ $_ * 2 })
    print("doubled = @doubled")        # [2, 4, 6, 8, 10]
    print("original = @nums")          # [1, 2, 3, 4, 5]
}

@doubled is a fresh list; @nums is exactly what it was. Because guji compiles ahead of time and keeps interpreter and compiler in parity, that immutability is not an interpreter quirk you might lose at the boundary - the native binary reports the same cannot reassign error and produces the same untouched original. Mutation is permitted, local, and conspicuous; shared mutable state through closures is off the table entirely.

Mutable by default: go, python, perl, raku

Go inverts the default. A variable declared with var or := is freely assignable, and there is no keyword to lock it:

x := 10
x = 20           // perfectly fine, always
const c = 10     // const is for compile-time constants only

const covers only constant expressions known at compile time, not arbitrary frozen values, so Go offers no general "this binding never changes" guarantee. Its discipline lives elsewhere - in small interfaces, copy-by-value structs, and the race detector - rather than in immutability of names.

Python is mutable at the binding level too, and adds a subtler split: some objects are immutable even though names are not. You can rebind any name, but you cannot mutate a tuple or a string in place:

t = (1, 2, 3)
t[0] = 9          # TypeError: 'tuple' object does not support item assignment

This object-versus-name distinction is the source of Python's most famous trap, the mutable default argument, which is created once and quietly accumulates:

def bad(x, acc=[]):
    acc.append(x)
    return acc
bad(1)            # [1]
bad(2)            # [1, 2]  - same list, reused across calls

The list is shared because the default is evaluated a single time at definition, and nothing about the call site warns you. It is the canonical cost of mutable-by-default semantics: the danger is invisible until it bites.

Perl is unapologetically mutable. A my variable is just a slot you write to as often as you like, and the language leans into change as a feature rather than a hazard. Larry Wall liked to say a language should be easy things easy and hard things possible, and Perl makes mutation about as easy as it gets:

my $x = 10;
$x = 20;                  # of course
use constant PI => 3.14159;   # constants are a deliberate, separate act

There is no immutable default; the most common way to gain immutability is to step outside the core with constant or a Readonly module. Perl's caution comes from copy semantics instead - assigning a list copies it - while references let you share and mutate on purpose when you reach for them.

Raku, Perl's sibling, is the most nuanced of the mutable-by-default group. A my $x is mutable, but Raku hands you a graded vocabulary of containers. my is rebindable; state persists across calls; binding with := ties a name directly to a value; and a sigilless \ or constant declaration gives true immutability:

my $x = 10;          # mutable container
$x = 20;             # ok
my \y = 10;          # y is the value 10, no container, immutable
constant TAU = 6.283185;

So while Raku's default is mutable, it arguably offers the richest spectrum of intent: you choose precisely how much mutability a name carries, from "free for all" up to "this is the value, full stop."

What the default buys you

The immutable-by-default four - rust, ocaml, haskell, guji - trade a little typing friction for a strong reading guarantee: an unannotated name is a promise. The mutable-by-default four - go, python, perl, raku - trade that guarantee for immediacy, asking you to opt into discipline rather than out of it. The line between the camps is exactly the line between let x = 10 that refuses a second assignment and x = 10 that shrugs at the tenth. Guji's contribution to the conversation is to take immutability past the binding and into the closure boundary itself: not only must change be declared, it cannot quietly escape into shared state. That is a strict rule, but a clarifying one - in guji, when a value sits still, you can be quite sure it always will.