Collections & Arrays
Lean provides two main sequential collections: List (linked lists) andArray (dynamic arrays). Knowing when to use each is crucial.
Lists
List is a singly-linked list—the default collection in functional programming:
1-- Create lists with bracket syntax2def nums : List Nat := [1, 2, 3, 4, 5]3def empty : List String := []45-- Prepend with :: (cons)6def moreNums := 0 :: nums -- [0, 1, 2, 3, 4, 5]78-- Concatenate with ++9def combined := [1, 2] ++ [3, 4] -- [1, 2, 3, 4]1011-- Common operations12#eval nums.length -- 513#eval nums.head? -- some 114#eval nums.tail? -- some [2, 3, 4, 5]15#eval nums.reverse -- [5, 4, 3, 2, 1]16#eval nums.take 3 -- [1, 2, 3]17#eval nums.drop 2 -- [3, 4, 5]Arrays
Array is a dynamic array with O(1) random access:
1-- Create arrays with #[] syntax2def nums : Array Nat := #[1, 2, 3, 4, 5]3def empty : Array String := #[]45-- Random access with []6#eval nums[0]! -- 17#eval nums[2]! -- 389-- Safe access returns Option10#eval nums[10]? -- none1112-- Push elements (creates a new array)13def moreNums := nums.push 6 -- #[1, 2, 3, 4, 5, 6]1415-- Common operations16#eval nums.size -- 517#eval nums.toList -- [1, 2, 3, 4, 5]18#eval nums.reverse -- #[5, 4, 3, 2, 1]The Three Ways to Index
The ! and ?suffixes above are not decoration — they are three genuinely different answers to "what if the index is out of range?"
1def nums : Array Nat := #[1, 2, 3]23-- arr[i]! : panics at runtime if out of bounds. Requires Inhabited.4#eval nums[0]! -- 15#eval nums[5]! -- Error: index out of bounds (a real runtime panic)67-- arr[i]? : returns Option. Always safe, you handle the none.8#eval nums[5]? -- none910-- arr.getD i default : returns a fallback instead of failing11#eval nums.getD 5 0 -- 01213-- arr[i] : NO suffix. Lean demands a PROOF that i is in range,14-- and then the access cannot fail — no check at runtime.15example : nums[1] = 2 := rfl1617def safeSecond (a : Array Nat) (h : 1 < a.size) : Nat := a[1]18#eval safeSecond #[9, 8, 7] (by decide) -- 8arr[i] works with no visible proof: Lean discharges the side condition automatically with by get_elem_tactic. The proof obligation only becomes visible when the array or the index is a variable — and at that point, providing it is exactly what buys you a bounds-check-free access. Vector α n — an Array bundled with a proof that its size is n. Then v[i] for i : Fin n needs no side condition at all, and the compiler can drop every bounds check.When to Use Which?
| Use Case | List | Array |
|---|---|---|
| Random access (index) | O(n) - slow | O(1) - fast |
| Prepend (add to front) | O(1) - fast | O(n) - slow |
| Append (add to end) | O(n) - slow | O(1)* - fast |
| Pattern matching | Natural | Less common |
| Proofs & reasoning | Preferred | More complex |
Complexity Tips
A quick mental model: Lists are great for building up results and pattern matching, while Arrays are great for indexing and bulk computation. When performance matters, convert to the structure that matches the dominant operation.
List for pattern matching, proofs, and when building from the front. Use Array for computation, random access, and performance-critical code.Transforming Collections
Both List and Array support similar transformation methods:
1def nums := [1, 2, 3, 4, 5]23-- Map: apply a function to each element4#eval nums.map (· * 2) -- [2, 4, 6, 8, 10]5#eval nums.map toString -- ["1", "2", "3", "4", "5"]67-- Filter: keep elements matching a predicate8#eval nums.filter (· > 2) -- [3, 4, 5]9#eval nums.filter (· % 2 == 0) -- [2, 4]1011-- FilterMap: map and filter in one step12#eval nums.filterMap (fun n => if n > 2 then some (n * 10) else none)13-- [30, 40, 50]1415-- Find: first element matching a predicate16#eval nums.find? (· > 3) -- some 4Folding and Reducing
Fold operations reduce a collection to a single value:
1def nums := [1, 2, 3, 4, 5]23-- foldl: fold from left to right4#eval nums.foldl (· + ·) 0 -- 15 (sum)5#eval nums.foldl (· * ·) 1 -- 120 (product)6#eval nums.foldl max 0 -- 5 (maximum)78-- Build a string9#eval nums.foldl (fun acc n => acc ++ toString n) "" -- "12345"1011-- foldr: fold from right to left12#eval nums.foldr (· :: ·) [] -- [1, 2, 3, 4, 5] (copy)1314-- Specialized operations15#eval nums.sum -- 15 (requires Add instance)16#eval nums.all (· > 0) -- true17#eval nums.any (· > 4) -- trueRanges
[start:stop] builds a range, which is a small record describing an iteration — not a list. Its job is to drive a for loop:
1-- A range is a value describing start/stop/step, NOT a list2#eval [0:5]3-- { start := 0, stop := 5, step := 1, step_pos := _ }45-- Its purpose is to be iterated6#eval Id.run do7 let mut acc := #[]8 for i in [0:5] do9 acc := acc.push i10 return acc -- #[0, 1, 2, 3, 4]1112-- With a step13#eval Id.run do14 let mut acc := #[]15 for i in [1:10:2] do16 acc := acc.push i17 return acc -- #[1, 3, 5, 7, 9]1819-- If you want an actual list, ask for one:20#eval List.range 5 -- [0, 1, 2, 3, 4] (0 to n-1)21#eval List.range' 1 5 2 -- [1, 3, 5, 7, 9] (start, count, step)2223-- Useful for generating test data24def squares := (List.range 10).map (fun n => n * n)25#eval squares -- [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] List.range' start count step takes a count, not a stop value — List.range' 1 5 2 produces five elements starting at 1, not the elements below 5. Mixing these two up is a classic off-by-many.Deep Dive: Array Performance
Arrays in Lean use a clever optimization: when an array has only one reference, mutations happen in-place. This means code that looks functional can be as efficient as imperative code.
1-- This is efficient because 'arr' is only referenced once2def buildArray (n : Nat) : Array Nat := Id.run do3 let mut arr := #[]4 for i in [0:n] do5 arr := arr.push i6 return arrThe compiler detects single-ownership and optimizes accordingly.
Zipping Collections
Combine two collections element-wise:
1def names := ["Alice", "Bob", "Carol"]2def ages := [30, 25, 35]34-- Zip into pairs5#eval names.zip ages6-- [("Alice", 30), ("Bob", 25), ("Carol", 35)]78-- Zip with a function. Note the argument order:9-- List.zipWith (f : α → β → γ) (xs : List α) (ys : List β)10-- so the FUNCTION comes first, and ys comes last.11#eval names.zipWith (fun name age => s!"{name}: {age}") ages12-- ["Alice: 30", "Bob: 25", "Carol: 35"]1314-- Pair each element with its index15#eval names.zipIdx16-- [("Alice", 0), ("Bob", 1), ("Carol", 2)]zip stops at the shorter list — no error, no padding. [1,2,3].zip [10] is [(1, 10)]. That is usually what you want, but it means a length mismatch fails silently, so check lengths first if they are supposed to agree.Converting Between List and Array
1def myList : List Nat := [1, 2, 3]2def myArray : Array Nat := #[1, 2, 3]34-- List to Array5#eval myList.toArray -- #[1, 2, 3]67-- Array to List 8#eval myArray.toList -- [1, 2, 3]910-- Chain operations11#eval #[1, 2, 3].toList.reverse.toArray -- #[3, 2, 1]HashMap and HashSet
For efficient key-value storage and membership testing, use Std.HashMap andStd.HashSet:
1-- These two imports go at the TOP of the file, before any definition2import Std.Data.HashMap3import Std.Data.HashSet45open Std67-- Create a HashMap. ∅ (or {}) is the empty map; the type annotation8-- is what tells Lean which map you mean.9def ages : HashMap String Nat := (∅ : HashMap String Nat)10 |>.insert "Alice" 3011 |>.insert "Bob" 251213-- Lookup — three equivalent spellings14#eval ages["Alice"]? -- some 30 (GetElem? syntax, most idiomatic)15#eval ages.get? "Alice" -- some 3016#eval ages["Unknown"]? -- none1718-- Total lookup with a fallback19#eval ages.getD "Unknown" 0 -- 02021-- Check membership, count, enumerate22#eval ages.contains "Bob" -- true23#eval ages.size -- 224#eval ages.toList -- [("Alice", 30), ("Bob", 25)]2526-- HashSet for unique values27def seen : HashSet String := (∅ : HashSet String)28 |>.insert "a"29 |>.insert "b"30 |>.insert "a" -- Duplicate, ignored3132#eval seen.size -- 233#eval seen.contains "a" -- trueHashMap.toList returns entries in unspecifiedorder — it depends on hash values, not insertion order. Never rely on it for output you compare against a fixed expectation; sort first. If you need deterministic ordering, use Std.TreeMap instead, which keeps keys sorted.BEq and Hashable instances for the key type. Most standard types already have these.Strings as Collections
Strings can be treated as collections of characters:
1-- Iterate over characters2#eval "hello".toList -- ['h', 'e', 'l', 'l', 'o']34-- Map over characters (String.map exists and returns a String)5#eval "hello".map Char.toUpper -- "HELLO"67-- Fold characters8#eval "hello".foldl (fun acc c => acc ++ toString c ++ "-") ""9-- "h-e-l-l-o-"1011-- Check all/any12#eval "HELLO".all Char.isUpper -- true13#eval "Hello".any Char.isLower -- true1415-- There is NO String.filter. Go through the character list:16#eval "he11o".toList.filter Char.isAlpha -- ['h', 'e', 'o']17#eval String.ofList ("he11o".toList.filter Char.isAlpha) -- "heo"String supports the operations that can be done in a single UTF-8 pass (map, foldl, all, any) but not ones that would need arbitrary indexing. For anything else, the round trip .toList → operate → String.ofList is the normal idiom, and it is what the missing functions would do anyway.Convert an Array to a List, filter even numbers, and convert back to an array.
1def nums : Array Nat := #[1, 2, 3, 4, 5, 6]23def evens : Array Nat :=4 nums.toList.filter (· % 2 == 0) |>.toArray56#eval evens -- #[2, 4, 6]