Closures (Basic Introduction)
A beginner-friendly introduction to closures in Rust covering syntax, environment capture, and practical usage with iterators and callbacks
Functions are the most common way to package reusable logic, but they come with a limitation: a function defined at the top of a file cannot reach into another scope and grab local variables. Rust provides closures to fill that gap — they are anonymous functions you can define right where you need them, and they can carry values from the surrounding scope into their body.
What Closures Are
A closure is an expression that creates an anonymous function. Like any expression, a closure evaluates to a value — in this case, a function-like value you can store in a variable, pass as an argument, or call immediately. What sets closures apart from regular fn functions is their ability to capture variables from the scope in which they were written.
let add_one = |x: i32| x + 1;
println!("{}", add_one(5)); // prints 6
The vertical bars |x: i32| declare the parameter list. The body x + 1 is an expression that becomes the return value — no return keyword needed for the final expression. The compiler infers the types of parameters and the return type from usage, though you may write them explicitly as shown.
Closures Are Expressions:
Because closures are expressions, they can appear anywhere an expression is expected: on the right-hand side of a let binding, inside a function argument list, or even as the condition in an if let guard (though that is less common). This inline nature is what makes them so useful for short-lived logic.
If the closure needs multiple statements, wrap the body in curly braces. The last expression still becomes the return value.
let describe = |age: u8| {
let msg = format!("{} years old", age);
println!("{}", msg);
msg // returned
};
let result = describe(7);
// prints "7 years old" and returns "7 years old"
Why Closures Exist
The core problem closures solve is code locality. Without closures, passing a tiny piece of custom behaviour to an iterator or a thread would require defining a separate named function somewhere else, and then manually threading any needed data through extra arguments or global state. That extra ceremony makes simple tasks feel heavyweight.
Closures let you write the logic directly at the call site. This matters most in three scenarios:
- Iterator adapters: Methods like
.map,.filter, and.foldall expect a closure that transforms, tests, or accumulates elements. - Callbacks and event handlers: Asynchronous runtimes, GUI frameworks, and test harnesses let you supply a closure that will be called later.
- Small, one-off operations where a full named function would add unnecessary distance between intent and execution.
Because closures can see variables from their enclosing scope, you also avoid tedious parameter plumbing. If a closure needs a configuration value that lives in the surrounding function, it can borrow that value directly rather than requiring the caller to pass it in.
How Closures Capture Variables
A closure’s relationship with the variables it uses from outside is governed by Rust’s ownership and borrowing rules. There is nothing magic here — the compiler analyses the closure body and automatically chooses the least invasive capture mode that satisfies the usage.
Immutable borrow (read-only access)
When a closure only reads a captured variable, it borrows an immutable reference. The original variable remains usable.
let name = String::from("Rust");
let greet = || println!("Hello, {}!", name);
greet();
println!("Still have access to name: {}", name);
Running this prints the greeting and then the second line, proving name is still owned by the outer scope. The closure holds a shared borrow &name that is released after the last use of the closure (in this case, after greet()).
Mutable borrow
If the closure body modifies a captured variable, the compiler requires a mutable borrow. The original binding must be declared mut, and the usual rule applies: while a mutable borrow exists, no other references (mutable or immutable) may be active.
let mut counter = 0;
let mut increment = || {
counter += 1;
println!("counter is now {}", counter);
};
increment();
increment();
// println!("{}", counter); // error: cannot borrow `counter` as immutable because it is also borrowed as mutable
Mutable Borrow Locks the Variable:
After a closure mutably borrows a variable, that variable is inaccessible until the borrow ends. In the example above, uncommenting the println! would fail because counter is still mutably borrowed by increment. This lock is released once the closure itself goes out of scope or is no longer used.
Taking ownership with move
The move keyword forces the closure to take ownership of every variable it captures, regardless of whether the body could have gotten by with a borrow. This is essential when the closure must outlive the current scope — for example, when spawning a thread or returning a closure from a function.
let greeting = String::from("hi");
std::thread::spawn(move || {
println!("{}", greeting);
}).join().unwrap();
// println!("{}", greeting); // error: value borrowed here after move
Because the spawned thread may run after the main thread leaves the current scope, greeting must be transferred into the closure. After the move, the outer scope can no longer use greeting.
Dangling References Cause Compile Errors:
Forgetting the move keyword when the closure escapes its defining scope results in a lifetime error. The compiler will complain that the captured reference does not live long enough. This is not a runtime crash — Rust rejects the program at compile time.
You Own It Now:
If you see output from a thread closure that prints a value you moved in, everything is working correctly. The closure correctly took ownership, the data lived long enough, and Rust’s safety checks ensured no other code could use it after the move.
Working with Iterator Methods
Iterator adapters are the most frequent place newcomers encounter closures. The standard library’s iterator methods accept closures that define what to do with each element.
let numbers = [1, 2, 3, 4, 5];
let squared: Vec<i32> = numbers
.iter()
.map(|n| n * n)
.collect();
println!("{:?}", squared); // [1, 4, 9, 16, 25]
let even_only: Vec<&i32> = numbers
.iter()
.filter(|&&n| n % 2 == 0)
.collect();
println!("{:?}", even_only); // [2, 4]
The closure in .map(|n| n * n) is called once per element. The parameter n is the reference yielded by .iter(); we dereference it inside the body. For .filter(|&&n| n % 2 == 0), the closure receives a double reference because .iter() produces &i32, and filter passes &&i32 to the closure. The pattern &&n destructures two layers so n is an i32. The closure returns a bool that decides whether the element stays.
This style of chaining closures keeps the processing logic in one place and avoids explicit loops. The iterator lazily applies the closures only when .collect() forces the chain to execute.
Passing Closures to Functions
A function that accepts a closure must use generics and trait bounds. For a basic introduction, you only need to recognise the pattern — the deep trait mechanics live in a later chapter.
fn run_twice<F>(mut action: F)
where
F: FnMut() -> String,
{
println!("{}", action());
println!("{}", action());
}
let mut word = String::from("Go");
let produce = || {
word.push('!');
word.clone()
};
run_twice(produce);
The function run_twice accepts any closure with signature FnMut() -> String. The closure produce mutates the captured word and returns a clone. Because the trait bound is FnMut, the closure can modify its environment. Calling run_twice(produce) transfers ownership of the closure (not the captured variables) into the function.
Common Mistakes Beginners Make
1. Expecting a closure to be a function pointer
Closures have anonymous, compiler-generated types. You cannot assign a closure that captures variables to a variable of type fn() (function pointer) unless the closure captures nothing. This often surprises developers who try to store different closures in a Vec<fn()> and find that only non-capturing closures compile.
let x = 10;
// let f: fn(i32) -> i32 = |n| n + x; // error: mismatched types
2. Trying to use a moved value after a move closure
After move, the original variable is consumed. Subsequent use of that variable is a compile-time error. The error message is clear, but beginners may be confused by the borrowing terminology.
3. Accidentally capturing a large value by reference when move is intended
If you spawn a thread and forget move, the compiler often catches it. But if the closure does not obviously outlive the scope, a move might be missing, leading to confusing lifetime errors later.
How a Beginner Should Think About Closures
A useful mental model is to see a closure as a snapshot of behaviour plus borrowed (or owned) data. If you imagine a regular function as a recipe on a printed card, a closure is a handwritten sticky note with that same recipe, and the note can be stuck directly onto the ingredients it references. The sticky note can be passed around and kept as long as the ingredients (or a copy of them) exist.
Closures are not a performance escape hatch; they compile to the same machine code as a hand‑written loop. The difference is in expressiveness and how you structure your data flow.
Summary
Closures tie together data and behaviour at the place you need them. They let iterators feel fluid, threads stay safe, and configuration stay local. Every closure you write implements one of the three closure traits (Fn, FnMut, FnOnce), which dictate how many times it can be called and whether it modifies or consumes captured state. In this introduction, you have seen the two most important modes — borrowing and moving — and used them in everyday iterator and callback patterns.
When you find yourself writing a three-line function and passing it to an adapter, replace it with a closure. When you need a piece of logic to outlive the scope that creates it, reach for move.