Local Scope
Use let bindings to create intermediate values and structure your computations clearly.
Let Bindings
The let keyword introduces local variables. These are scoped to the expression that follows:
1def circleArea (radius : Float) : Float :=2 let pi := 3.141593 let radiusSquared := radius * radius4 pi * radiusSquared56#eval circleArea 5.0 -- 78.53975Each let binding creates a name that's available in all subsequent code within the same scope.
Let with Type Annotations
You can add type annotations to let bindings for clarity:
1def example1 : Nat :=2 let x : Nat := 103 let y : Nat := 204 x + y56-- Type annotations are optional when inferable7def example2 : Nat :=8 let x := 10 -- Inferred as Nat9 x * 2Nested Let Bindings
Let bindings can be nested, and each inner binding can use outer ones:
1def compute : Nat :=2 let a := 53 let b := a * 2 -- Uses 'a'4 let c := a + b -- Uses both 'a' and 'b'5 c * 2 -- Result: 3067#eval compute -- 30Variable Shadowing
You can reuse names in inner scopes. The inner definition "shadows" the outer one:
1def shadowExample : Nat :=2 let x := 103 let x := x + 5 -- This 'x' shadows the previous one4 let x := x * 2 -- And this shadows again5 x -- Final value: 3067#eval shadowExample -- 30Let in Expressions
let can be used inline within expressions:
1-- Inline let2#eval let x := 5; x * x -- 2534-- Multiple inline lets5#eval let a := 2; let b := 3; a + b -- 567-- Let in function arguments8#eval List.map (fun n => let doubled := n * 2; doubled + 1) [1, 2, 3]9-- [3, 5, 7]Let with Pattern Matching
You can destructure values directly in a let binding.
1def swap (p : Nat × Nat) : Nat × Nat :=2 let (a, b) := p3 (b, a)45#eval swap (3, 7) -- (7, 3)Let vs Where
Both let and where create local bindings, but they have different use cases:
1-- 'let' bindings come BEFORE the main expression2def withLet (x : Nat) : Nat :=3 let doubled := x * 24 let tripled := x * 35 doubled + tripled67-- 'where' bindings come AFTER the main expression8def withWhere (x : Nat) : Nat :=9 doubled + tripled10where11 doubled := x * 212 tripled := x * 31314-- Both produce the same result15#eval withLet 5 -- 2516#eval withWhere 5 -- 25let for step-by-step computation (top to bottom). Use where when you want the main logic first with supporting definitions below.Rewrite the following function to use let bindings for clarity.
1def bmi (weightKg heightM : Float) : Float :=2 weightKg / (heightM * heightM)34-- Refactor into named steps5def bmi' (weightKg heightM : Float) : Float :=6 let heightSq := heightM * heightM7 let ratio := weightKg / heightSq8 ratioScope Isolation
Variables defined in one scope don't leak into others:
1def outer : Nat :=2 let x := 103 let inner := 4 let y := 20 -- 'y' is only visible here5 x + y -- Can access 'x' from outer scope6 -- 'y' is not accessible here7 inner + x -- Can use 'inner' and 'x'89#eval outer -- 40Deep Dive: Let is Not Mutation
Coming from imperative languages, let might look like variable assignment. But Lean is purely functional—there is no mutation.
Each let creates a new, immutable binding. Shadowing creates a new binding with the same name, not a modification of the original.
1-- This is NOT mutation:2def notMutation : Nat :=3 let x := 54 let x := 10 -- New binding, old 'x' is gone5 x -- 10Practical Example
Here's a more realistic example showing local scope in action:
1def formatPrice (cents : Nat) : String :=2 let dollars := cents / 1003 let remaining := cents % 1004 let dollarStr := toString dollars5 let centStr := if remaining < 10 6 then "0" ++ toString remaining 7 else toString remaining8 "$" ++ dollarStr ++ "." ++ centStr910#eval formatPrice 1234 -- "$12.34"11#eval formatPrice 50 -- "$0.50"12#eval formatPrice 1005 -- "$10.05"