Values and Immutability
In F#, the let keyword binds a name to a value, and by default that binding is immutable — once let x = 5 has executed, x can never be reassigned to point at a different value within that scope. This differs sharply from variables in C#, Python, or JavaScript, where x = 5 typically means 'this variable can change later'; in F#, immutability is the default assumption baked into the language, and mutability must be explicitly requested.
Cricket analogy: F#'s let x = 5 behaving like a permanent entry on the scorecard rather than a mutable variable is like a run scored being etched into the record the moment the umpire signals it — no one goes back and changes that ball's outcome.
The let Keyword and Binding Semantics
Because let creates a binding rather than a variable slot, let x = 5 is closer to defining a mathematical constant than declaring storage — the compiler enforces this, so x <- 6 on an immutable binding is a compile error, not a runtime surprise. Immutability by default eliminates an entire category of bugs where a shared value is unexpectedly changed by distant code, which is especially valuable in concurrent or multi-threaded systems where mutable shared state is a classic source of race conditions.
Cricket analogy: F#'s compiler rejecting x <- 6 on an immutable binding is like the third umpire overturning an on-field mistake before it's ever recorded in the official scorebook.
Opting Into Mutability
When mutation truly is the right tool — a loop counter, an accumulator updated in a tight performance loop, a UI-bound value — F# lets you opt in explicitly with let mutable x = 5, after which x <- x + 1 reassigns it using the <- operator, deliberately different from the = used for binding and equality comparison. This explicitness means every mutable variable in an F# codebase is visible at a glance, making it easy to audit exactly where state can change.
Cricket analogy: Explicitly marking a value mutable in F# is like a team specifically naming a designated pinch runner before the match — everyone knows in advance exactly who's allowed to sub in.
Shadowing vs Mutation
F# also supports shadowing: writing let x = 5 followed later by let x = x + 1 in the same scope does not mutate the original x — it creates a brand-new binding that happens to reuse the name, while the old value still exists (and may still be referenced by a closure created before the shadowing). This is a common source of confusion for beginners coming from mutable-variable languages, who read shadowing as mutation when it is actually the creation of an entirely separate, still-immutable value.
Cricket analogy: Shadowing let y = y * 2 creating a new binding is like a substitute batter walking in with the same shirt number as the player who just got out — same name on the back, but a completely different player at the crease.
let x = 5
// x <- 6 // Compile error: 'x' is not mutable
let mutable counter = 0
counter <- counter + 1
counter <- counter + 1
printfn "Counter: %d" counter // 2
// Shadowing: creates a new binding, doesn't mutate the old one
let y = 10
let y = y * 2 // new 'y', shadows the old one
printfn "Shadowed y: %d" y // 20Immutability by default is one of the biggest reasons F# code tends to have fewer bugs around shared state — if a value can't change, you never have to trace through the whole call stack to find out who modified it.
Don't reach for mutable out of habit from C# or Python. Overusing mutable state defeats F#'s safety guarantees and makes code harder to reason about — reserve it for genuine cases like tight loops or interop with mutable APIs.
- let in F# creates an immutable binding by default — reassignment is a compile-time error unless the value is marked mutable.
- Use let mutable x = ... to opt into mutability, and the <- operator, not =, to reassign.
- Immutability by default reduces bugs from unexpected shared-state changes, especially in concurrent code.
- Shadowing (let x = ... reused in the same scope) creates a new binding — it does not mutate the original value.
- A closure created before a shadowing let still sees the old value, since shadowing doesn't touch the original binding.
- Reserve mutable for genuine cases like loop counters or performance-critical accumulation — default to immutable values.
Practice what you learned
1. What does let x = 5 create in F# by default?
2. How do you make a binding mutable in F#?
3. Which operator is used to reassign a mutable value in F#?
4. What happens when you write let x = 5 and then later let x = x + 1 in the same scope?
5. Why does F# default to immutability?
Was this page helpful?
You May Also Like
F# Syntax Basics
A tour of F#'s significant whitespace, function definitions, the pipe and composition operators, and basic types.
Your First F# Program
A walkthrough of scaffolding, understanding, and running a basic F# console application, from Program.fs to dotnet run.
What Is F#?
An overview of F# as a functional-first, statically typed language on .NET, its origins, and why developers choose it.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics