Why Java Won't Let You Use That Variable in a Lambda
Every Java developer eventually writes something like this and hits a wall:
int total = 0;
orders.forEach(order -> total += order.amount());
// error: local variables referenced from a lambda expression must be final or effectively final
It looks like a completely normal thing to want — add up some numbers in a loop. Java disagrees, and the error message doesn’t explain why. Here’s what’s actually going on, and the right way to fix it.
In plain terms
Think of a lambda as a photo of the variable, taken at the moment the lambda is created — not a live window onto it. If total is 0 when the photo is taken, the lambda’s copy of total is permanently 0, no matter what happens to the real total afterward.
Java’s rule is simple: it will only let a lambda take that photo if it’s certain the variable will never change after the photo is taken. If you reassign the variable anywhere else in its scope — even once — Java can’t make that guarantee, so it refuses to compile rather than let the photo silently go stale.
That’s the entire idea. Everything below is just why the language is built that way, and what to do instead.
In technical terms
The rule has a name: a local variable is effectively final if it’s assigned exactly once and never reassigned afterward — whether or not you wrote the final keyword yourself. JLS §4.12.4 requires every local variable a lambda references to be effectively final.
The reason traces back to how a lambda captures a local variable: by copying its value into a field of the synthetic object the compiler generates for the lambda — not by holding a live reference to the variable’s storage on the stack. Local variables live on the calling thread’s stack frame, which can be gone by the time the lambda actually runs (the lambda might be handed to another thread, stored for later, or simply outlive the method). There’s no stack frame left to read from at that point — only the copy. Allowing reassignment after the copy was made would mean the variable and the lambda’s copy could silently diverge with no way to reconcile them, so the language closes that gap entirely: if it can change, you can’t capture it.
(Instance fields and array elements don’t have this restriction — they live on the heap, not the stack, so a lambda can read their current value through a live reference instead of a frozen copy. That’s why this.total += x inside a lambda is fine, but a bare local total is not.)
The classic mistake: accumulating in a loop
The forEach example at the top is the single most common way people hit this. The instinct to reach for a mutable local variable is reasonable — it’s how you’d write it in almost any other language — but Java’s streams already have a built-in answer that doesn’t fight the compiler:
int total = orders.stream().mapToInt(Order::amount).sum();
No accumulator variable, no lambda capture problem, and arguably more readable: the code says “sum the amounts” instead of “loop and add”. For anything beyond a simple sum, Stream.reduce generalizes the same idea:
Order biggest = orders.stream()
.reduce((a, b) -> a.amount() > b.amount() ? a : b)
.orElseThrow();
When a stream genuinely doesn’t fit
Sometimes the logic is awkward to express as a stream pipeline — nested loops, early termination, side effects that have to happen in a specific order. For those cases, the standard escape hatch is to give the lambda a mutable container instead of trying to mutate the variable itself:
AtomicInteger total = new AtomicInteger(0);
orders.forEach(order -> total.addAndGet(order.amount()));
total itself is never reassigned — it’s effectively final, holding one AtomicInteger object for the whole loop. What changes is the value inside that object, which the lambda can do freely because it’s reading the container through a live reference, exactly like the instance-field case above. The same trick works with a single-element array (int[] total = {0};) when you don’t need AtomicInteger’s thread-safety, just a mutable box.
The rule itself is permanent and well-justified — local variables don’t have a stable place to live once a lambda might outlive the stack frame they were declared in. Before reaching for a workaround, ask whether a stream operation already expresses what you’re doing; it usually does. AtomicInteger and the single-element array trick are real tools, but they’re the last resort, not the first instinct.
🔗 Source: JLS §4.12.4: Effectively Final Variables