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.
To see why that matters, here's the kind of bug it prevents. A sign-up desk keeps a list of names in the order people arrived: Zoe, then Ann, then Max. The first to sign up wins a prize. A leaderboard elsewhere in the program wants the same names sorted A→Z, so both parts are handed the same data.
Step through the scene and predict who the desk thinks signed up first after the leaderboard sorts. Then flip the switch to an immutable list and run the same story again.
What made the difference? let board = signups never copies anything: it's a second name for the same data. In F#, an array ([| ... |]) is a row of slots that can be rearranged in place, so a sort done through one name shows up through every other name. A list ([ ... ]) can't be changed at all, so the only way to "sort" it is to build a new, sorted list. Sharing is harmless when nobody can change the data, and a trap when anybody can.
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.
Your function receives an array from its caller and calls Array.sortInPlace on it to find the middle value. What's the risk?
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.
let freezes the name, not always the data. let scores = [| 3; 1; 2 |] means scores will always point at that array, but the array's slots can still be changed: scores[0] <- 99 and Array.sortInPlace scores both work, no mutable needed. F# lists, strings and ordinary records can't be changed at all; arrays (and .NET collections like ResizeArray or Dictionary) can. When you share data between parts of a program, prefer a list or a record.
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. Lists work the same way: in the scene above, "Kai" :: signups made one new cell that points at the three old ones, instead of copying them.
bigList holds a million numbers. Roughly how much new memory does let more = 0 :: bigList need?
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.