Picture two ways to make an edit. The first is scribbling over the only paper copy of a document — once you've crossed something out, the original is gone forever, and anyone else reading that page just sees your changes appear out of nowhere. The second is editing a Google Doc that auto-saves every version — your change shows up as a new revision, and the old one is still sitting safely in the history.
Immutability is the second way, applied to data in your programs. An immutable value never changes after it's created. When you want a "changed" version, you don't overwrite the original — you make a new copy with the change baked in, and the original stays exactly as it was.
How it works
The trick is that "changing" immutable data is really creating. You start with an original value, apply a transform, and get back a brand-new value alongside it. The original is never touched — it's still there, unchanged, for anyone who's holding onto it.
This is the opposite of the scribble-over-paper approach, where the change destroys what was there before. With immutable data, the before and after both exist, side by side.
- Immutable valueNever edited in place — a change produces a fresh copy; the original stays put.
In F#, this is the default way of working. When you write let, you're naming a value that can't be reassigned later:
let x = 5
// x = 6 // not allowed: this isn't assignment, it's a comparison
// x is, and always will be, 5
That name x is bound to 5 for good. There's no sneaky way to make it mean something else later, so you never have to wonder whether some other line of code quietly changed it out from under you.
The real magic shows up with records. Say you have a user and you want to rename them. Instead of editing the record in place, you use the with keyword to make a copy that's identical except for the one field you're changing:
type User = { Name: string; Age: int }
let original = { Name = "Alex"; Age = 30 }
let renamed = { original with Name = "Sam" }
// original is still { Name = "Alex"; Age = 30 }
// renamed is now { Name = "Sam"; Age = 30 }
Notice that original didn't budge. The with expression produced a whole new record, renamed, and left the old one perfectly intact — just like a new revision in your version history.
This is predictable in exactly the way pure functions are. A pure function always gives the same answer for the same input and changes nothing else. Immutable data is the perfect partner: if the values you pass around can't be edited behind your back, then reasoning about what a function does becomes wonderfully boring — and boring is good.
Opting into mutation
F# does let you edit in place when you really want to — you just have to ask for it explicitly with let mutable:
let mutable counter = 0
counter <- counter + 1 // the <- arrow reassigns in place
counter <- counter + 1 // counter is now 2
The <- arrow overwrites the old value, scribble-over-the-paper style. The fact that you have to opt in with mutable is the whole point: editing in place is the exception you reach for deliberately, not the default that bites you by accident.
Why it's great
The biggest win is the disappearance of a whole class of spooky bugs. With mutable data, you can hand the same object to two parts of your program and one of them quietly changes it, leaving the other baffled by the "who changed this behind my back?" mystery. Immutable values simply can't be edited from under you, so that mystery never happens.
That same property makes immutable data safe to share across threads. Because nothing can change a value once it exists, two threads can read it at the same time without any locks or coordination — a big deal for concurrency, and a natural fit for keeping services stateless.
You also get undo and history almost for free. Since every "change" leaves the previous version intact, you can keep the old copies around and step backward through them whenever you like — the same reason a versioned doc lets you restore yesterday's draft.
Worried that copying everything is wasteful? It usually isn't. Immutable data structures use structural sharing: the new copy reuses all the parts that didn't change and only allocates the small bit that did. Renaming one field in a big record doesn't deep-copy the whole thing — the new value quietly points at the unchanged pieces of the old one.
- Age 30Unchanged — both records point at the same shared piece, so it isn't copied.
- Name "Sam"The only newly-allocated piece: just the field that actually changed.
Put it all together and immutability gives you data you can trust: it won't change when you're not looking, it's safe to share, and you can always get back to where you were. Make new copies instead of editing in place, lean on F#'s defaults, and let your programs become a little more predictable and a lot less spooky.