C++ Lambda Capture: By Value, By Reference, Init Capture, and the Lifetime Bugs Each One Invites
Key takeaways
A lambda's capture list decides which data members its closure object gets. Almost every capture bug comes from forgetting that: a by-value capture is a copy taken when the lambda is created, a by-reference capture is a pointer that can dangle, and [=] inside a member function copies the this pointer, not the object.
What a capture actually is
A lambda expression creates an object of an unnamed class, the closure type. The capture list decides which data members that class has and how they are initialized. Everything about capture behavior follows from that one fact, so it is worth seeing the equivalent hand-written class once:
int x = 10;
auto f = [x](int y) { return x + y; };
// Roughly what the compiler generates:
struct __lambda_1 {
int x; // captured by value: a copy
int operator()(int y) const { return x + y; } // const by default
};
__lambda_1 f{x};
Three consequences:
- A by-value capture is copied when the lambda is created, not when it is called. Changing
xafterwards does not affectf. operator()isconstby default, so by-value members cannot be modified unless the lambda ismutable.- A by-reference capture is effectively a pointer member. It is only valid as long as the variable it refers to is alive.
int x = 10;
auto byValue = [x] { return x; };
auto byRef = [&x] { return x; };
x = 20;
std::cout << byValue() << ' ' << byRef() << '\n'; // 10 20
What is not captured at all: global variables, static locals, and namespace-scope objects. They are not in the closure; the body refers to them directly. So [=] does not snapshot a global:
int g = 1;
int main() {
auto h = [=] { return g; }; // g is not copied
g = 2;
std::cout << h() << '\n'; // 2
}
This occasionally surprises people who used [=] specifically to freeze a configuration value that happened to be a global.
Capture forms
[] // nothing
[x] // x by value
[&x] // x by reference
[=] // every local the body uses, by value
[&] // every local the body uses, by reference
[=, &x] // default by value, x by reference
[&, x] // default by reference, x by value
[this] // the this pointer (members accessed through it)
[*this] // a copy of the whole object (C++17)
[y = expr] // init capture: new member y initialized from expr (C++14)
[&r = expr] // init capture by reference (C++14)
Default captures ([=], [&]) only capture what the body actually uses, so they do not cost more than an explicit list. Their problem is readability: the reader has to scan the whole body to know what the closure depends on, and a later edit can add a dependency without anyone noticing. For lambdas that escape their scope, I prefer an explicit list precisely because it makes lifetimes reviewable.
By value, by reference, and mutable
int x = 10;
auto f1 = [x]() mutable { // modifies the closure's copy
return ++x;
};
std::cout << f1() << ' ' << f1() << ' ' << x << '\n'; // 11 12 10
auto f2 = [&x] { // modifies the original
return ++x;
};
std::cout << f2() << ' ' << x << '\n'; // 11 11
Note that f1 returns 11 and then 12: the copy lives inside the closure and persists across calls. That is what makes a mutable lambda a tiny stateful object, and it is also the root of the next pitfall.
Stateful lambdas get copied
auto counter = [n = 0]() mutable { return ++n; };
std::function<int()> a = counter;
std::function<int()> b = counter;
a(); a();
std::cout << a() << ' ' << b() << ' ' << counter() << '\n'; // 3 1 1
Every std::function holds its own copy of the closure, and so does anything else that takes a callable by value — including standard algorithms:
std::vector<int> v{1, 2, 3};
int count = 0;
std::for_each(v.begin(), v.end(), [count](int) mutable { ++count; });
std::cout << count << '\n'; // 0: the algorithm incremented its own copy
std::for_each returns the final copy of the function object if you need it, but for shared state it is clearer to capture by reference (when the lambda does not escape) or to capture a shared_ptr to the state (when it does).
Init capture (C++14)
Init capture declares a new closure member and initializes it from any expression. It solves two problems plain captures cannot: moving an object into the closure, and capturing a computed value.
auto ptr = std::make_unique<int>(10);
auto owner = [p = std::move(ptr)] { return *p; }; // closure owns the unique_ptr
std::string name = "config.json";
auto log = [path = "/etc/app/" + name] { std::cout << path << '\n'; };
// Capture a const reference to avoid copying, while making the intent explicit
std::vector<int> big(1'000'000);
auto size = [&v = std::as_const(big)] { return v.size(); };
Move-only captures make the lambda move-only
auto p = std::make_unique<int>(7);
auto m = [p = std::move(p)] { return *p; };
std::function<int()> fn = std::move(m); // error
GCC reports this from deep inside the standard library:
error: use of deleted function 'main()::<lambda()>::<lambda>(const main()::<lambda()>&)'
error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&)'
std::function requires a copyable target, and a closure with a unique_ptr member is not copyable. The fixes: std::move_only_function in C++23, a template parameter instead of std::function, or a shared_ptr if shared ownership is acceptable. This comes up constantly with task queues, which are natural places to store closures that own a buffer or a connection.
More detail on init capture patterns is in the init capture guide.
Capturing this
Inside a member function, members are not local variables, so they cannot be captured by name. The lambda captures this and reaches members through it.
class Counter {
int count_ = 0;
public:
auto incrementer() { return [this] { return ++count_; }; } // pointer
auto snapshotCounter() { return [*this]() mutable { return ++count_; }; } // copy (C++17)
int value() const { return count_; }
};
Counter c;
auto inc = c.incrementer();
inc(); inc();
std::cout << c.value() << '\n'; // 2
[=] captures the pointer, not the object
This is the capture bug I have seen cause the most real crashes, because the code looks like it copies:
class Widget {
int id_ = 42;
public:
std::function<void()> callback() {
return [=] { std::cout << id_ << '\n'; }; // really this->id_
}
};
std::function<void()> cb;
{
Widget w;
cb = w.callback();
}
cb(); // undefined behavior: this points to a destroyed Widget
[=] captures every local the body uses by value, and id_ is not a local — it is this->id_, so what gets copied is this. The typical victim is a callback registered with a timer, a UI framework or a network library that fires after the object has been destroyed. C++20 deprecates the implicit this capture by [=]; GCC says warning: implicit capture of 'this' via '[=]' is deprecated in C++20. Treat that warning as a real finding, not noise.
The options, depending on what the callback needs:
// Needs a snapshot of member values: copy them explicitly
return [id = id_] { std::cout << id << '\n'; };
// Needs the whole object, and it is cheap to copy: C++17
return [*this] { std::cout << id_ << '\n'; };
// Needs the live object, and the object is managed by shared_ptr
// (class derives from std::enable_shared_from_this<Widget>)
return [weak = weak_from_this()] {
if (auto self = weak.lock()) std::cout << self->id_ << '\n';
// otherwise the Widget is gone: do nothing
};
The weak_ptr version is the standard pattern for async callbacks: capturing a shared_ptr keeps the object alive until the callback runs (sometimes forever, if the callback is stored in the object itself and forms a cycle), while weak_ptr lets the callback notice that the object is gone.
Dangling references
std::function<int()> makeFunc() {
int x = 10;
return [&x] { return x; }; // x is destroyed when makeFunc returns
}
auto f = makeFunc();
f(); // undefined behavior
The clean-looking version of this bug is easy to spot. The ones that reach production are indirect:
- Threads and async work. A lambda capturing locals by reference is passed to
std::threadand the thread is detached, or tostd::async/a thread pool, and the function returns before the work runs. - Deferred execution. Lambdas stored in a task queue or event loop, with
[&]captured in a function that has long since returned. - Capturing a reference parameter by reference.
void f(const std::string& s) { queue.push([&s] {...}); }refers to the caller’s string, which may be a temporary. - Coroutine lambdas. If a lambda is itself a coroutine, its captures live in the closure object, not the coroutine frame. Once the temporary closure is destroyed, the suspended coroutine refers to freed memory. Pass the data as parameters instead, which are copied into the frame.
// Broken: detached thread outlives the captured local
void startWork() {
int result = 0;
std::thread([&result] {
std::this_thread::sleep_for(std::chrono::seconds(1));
result = 42; // writes to a dead stack frame
}).detach();
}
// Fixed: the thread owns what it uses and reports back through a future
std::future<int> startWorkFixed() {
return std::async(std::launch::async, [] {
std::this_thread::sleep_for(std::chrono::seconds(1));
return 42;
});
}
AddressSanitizer catches most of these at runtime as stack-use-after-return (enable with ASAN_OPTIONS=detect_stack_use_after_return=1 on older toolchains) or heap-use-after-free, which is far faster than reasoning through the crash dump.
A subtle one: replacing a std::function while it runs
A common state-machine pattern stores the current state as a std::function and lets a state switch to the next one:
std::function<void()> state;
state = [&] {
// ... idle work ...
state = [&] { std::cout << "Active\n"; }; // destroys the closure that is running
// any use of this lambda's captures after this line is undefined behavior
};
state();
Assigning to state destroys the closure currently executing, including its captured members. With [&] captures this often happens to work, which is why it survives code review; with by-value captures of a std::string or shared_ptr, the rest of the body reads freed memory. The fix is to never replace the running callable from inside itself: set a next variable and swap after the call returns.
std::function<void()> state, next;
state = [&] { next = [&] { std::cout << "Active\n"; }; };
state();
if (next) { state = std::move(next); next = nullptr; }
Other capture rules worth knowing
Structured bindings can be captured only since C++20. In C++17, auto [a, b] = pair; [a] { ... } is ill-formed: Clang rejects it, while GCC accepts it without a diagnostic, so code written on GCC can fail to build elsewhere. The portable C++17 form is an init capture: [a = a].
Closure size is just the size of its captures. sizeof of a lambda capturing one int by value is 4 on common platforms; an empty capture list gives a closure that is also implicitly convertible to a plain function pointer, which is what lets it be passed to C APIs like qsort or pthread_create.
Capturing by reference in a lambda that is immediately used is fine and idiomatic. The lifetime concerns above are about escaping lambdas. For std::sort(v.begin(), v.end(), [&](auto& a, auto& b) { return key(a) < key(b); }), [&] is the right choice.
Choosing a capture by when the lambda runs
| Situation | Capture | Why |
|---|---|---|
| Passed straight to an algorithm | [&] or explicit refs | Called before the scope ends; no copies |
| Returned, stored, or run later | Explicit by-value list | Copies cannot dangle |
| Needs to own a move-only resource | [p = std::move(p)] | Transfers ownership; lambda becomes move-only |
| Member function, needs member values | [id = id_] or [*this] | Copies the data, not the pointer |
| Member function, async callback | [weak = weak_from_this()] | Detects that the object is gone |
| State shared between copies | shared_ptr captured by value | std::function copies the closure |
The rule underneath all of them: a capture list is a list of data members. Ask what each member will point to at the moment the lambda is actually called, not the moment it is written.