Associativity, Identity, and Folding - Why Your reduce Keeps Biting You
28 Nov 2025 Share on:
If you write Java today, you probably use Stream.reduce or something like it.
Sometimes it feels magical.
int sum = nums.stream().reduce(0, Integer::sum);Other times it feels cursed.
int weird = nums.parallelStream().reduce(0, (a, b) -> a - b);Same method, very different behavior.
Underneath these differences are three simple ideas:
- Associativity
- Identity
- Folding (reducing a collection into one value)
You do not need category theory to use them. You just need to recognize when your operation behaves like + (associative) and when it behaves like - (non-associative).
This post walks through concrete Java examples you are likely to hit in normal work and uses them to build intuition for those three words.
It also gives you an understanding of when using parallelStreams is probably safe.
Folding: what reduce really does
A fold or reduce is just this:
Start with some value.
Combine it with each element in a collection.
End up with a single result.
For example, summing numbers by hand:
- Start with
0 - Add the first number
- Add the second number
- Keep going until you have one, final, number left
In Java Streams:
int sum = nums.stream()
.reduce(0, (acc, x) -> acc + x);reduce needs two things from you.
- An initial value, sometimes called the identity.
- A function that combines the accumulator and the next element.
Everything else in this post is about choosing those two well.
Example 1: Integer sum, the happy path
Summing integers is the canonical example of reduce working perfectly, even in parallel.
import java.util.List;
List<Integer> nums = List.of(1, 2, 3, 4, 5);
// Sequential
int sum1 = nums.stream()
.reduce(0, Integer::sum);
// Parallel
int sum2 = nums.parallelStream()
.reduce(0, Integer::sum);
System.out.println(sum1); // 15
System.out.println(sum2); // 15Why does this behave?
The short version:
- Addition is associative.
- Zero is the identity for addition.
Let us unpack that.
Associativity means you can change parentheses without changing the result:
int a = (1 + 2) + 3;
int b = 1 + (2 + 3);
// a == bZero is the identity for + because it does nothing:
0 + x == x;
x + 0 == x;When both of these are true, the stream framework is free to:
- Split the list into chunks.
- Sum each chunk separately.
- Add the partial sums in any grouping it likes.
Sequential or parallel, left grouped or right grouped, the result is always the same.
Example 2: Subtraction with reduce, same shape, weird result
Change one thing. Use subtraction instead of addition.
List<Integer> nums = List.of(1, 2, 3, 4, 5);
// Sequential
int result1 = nums.stream()
.reduce(0, (a, b) -> a - b);
// Parallel
int result2 = nums.parallelStream()
.reduce(0, (a, b) -> a - b);
System.out.println(result1);
System.out.println(result2);On most machines you will see different numbers printed.
Subtraction is not associative:
int a = (1 - 2) - 3; // -4
int b = 1 - (2 - 3); // 2
// a != bOnce the operation is not associative, the way the stream groups operations changes the result.
Sequential reduce tends to behave as if it were:
((((0 - 1) - 2) - 3) - 4) - 5;Parallel reduce might do something more like:
(0 - (1 - 2)) - (3 - (4 - 5));Same numbers, same lambda, same reduce method. Different grouping, different answer.
The day to day lesson:
When you pass a lambda as the combiner to reduce, ask yourself whether it behaves more like + or more like -. If it is like -, parallel reduction will be surprising.
Example 3: Wrong identity value leads to skewed output
Even if your operation is associative, choosing the wrong initial value quietly skews your result.
List<Integer> nums = List.of(1, 2, 3);
// Wrong identity: 1 instead of 0
int sum = nums.stream()
.reduce(1, Integer::sum);
System.out.println(sum); // 7, but the real sum is 6Sequentially this works out as:
(((1 + 1) + 2) + 3) == 7;In parallel, the identity can be applied to every chunk, so the effect is even stranger.
The identity should be a ==do nothing== value for the operation:
combine(identity, x) == x
combine(x, identity) == x
For addition, the identity is 0. For multiplication, it is 1. For string concatenation, it is "".
The day to day rule:
If you call reduce(identity, combiner), check those two equations in your head for the operation you are using.
Example 4: String concatenation, grouping versus ordering
String concatenation is another operation you use constantly.
List<String> words = List.of("a", "b", "c");
String s1 = words.stream()
.reduce("", (a, b) -> a + b);
String s2 = words.parallelStream()
.reduce("", (a, b) -> a + b);
System.out.println(s1); // usually "abc"
System.out.println(s2); // also usually "abc"Concatenation is associative:
("a" + "b") + "c" == "a" + ("b" + "c");and the empty string is the identity:
"" + s == s;
s + "" == s;So grouping does not matter.
Order still matters though:
"a" + "b" != "b" + "a";Concatenation is associative but not commutative.
Streams will respect encounter order unless you explicitly undo that. So in practice the result is stable, but the moment you introduce reordering operations you lose that guarantee.
The message here:
- Associativity protects you from grouping changes.
- Commutativity protects you from ordering changes.
- Most real world operations are not commutative, so do not assume order is irrelevant unless you are sure.
Example 5: Floating point sums, almost associative
Now a more subtle one: floating point numbers.
List<Double> values = List.of(
1e16, 1.0, 1.0, 1.0, 1.0
);
double s1 = values.stream()
.reduce(0.0, Double::sum);
double s2 = values.parallelStream()
.reduce(0.0, Double::sum);
System.out.println(s1);
System.out.println(s2);In real math, addition is associative. In IEEE double precision, it is only approximately so.
When you add a very large number and a very small number, the small number can get rounded away. Different groupings of additions can lose or keep different tiny pieces of information.
Practically you see things like:
- Slightly different sums when you use parallel streams.
- Results like
999.9999999997instead of1000.0.
For many applications this does not matter. For financial calculations it absolutely does, which is one reason developers reach for BigDecimal or integer cents rather than double.
The take away:
Floating point addition mostly behaves like a good associative operation, but not perfectly. Do not expect bit identical results when you regroup or parallelise floating point reductions.
Example 6: Merging counts in maps, a realistic associative operation
Imagine you have words and you want to count how many times each appears.
A Note on Engineering vs. Math: While our Counts merge logic is mathematically associative (satisfying the requirements for reduce), using reduce for mutable objects like Maps is inefficient. It creates a copy of the map at every step. In Java, when you need to combine mutable containers (like Lists or Maps) in parallel, you should use the Mutable Reduction pattern via Stream.collect.
First, a small helper type:
import java.util.Map;
import java.util.HashMap;
record Counts(Map<String, Integer> counts) {
static Counts empty() {
return new Counts(new HashMap<>());
}
Counts addWord(String word) {
Map<String, Integer> copy = new HashMap<>(counts);
copy.merge(word, 1, Integer::sum);
return new Counts(copy);
}
Counts combine(Counts other) {
Map<String, Integer> copy = new HashMap<>(counts);
other.counts.forEach((w, c) ->
copy.merge(w, c, Integer::sum));
return new Counts(copy);
}
}Now we can use reduce with this.
import java.util.List;
List<String> words = List.of("a", "b", "a", "c", "b", "a");
Counts result = words.parallelStream()
.map(w -> Counts.empty().addWord(w)) // each word → its own count map
.reduce(Counts.empty(), Counts::combine);
System.out.println(result.counts());
// {a=3, b=2, c=1}Why this works well:
-
combineis associative.
Combining(A combine B) combine Cgives the same counts asA combine (B combine C). -
Counts.empty()is an identity.
Combiningemptywith anyCountsyields thatCountsback.
That is exactly what a framework like reduce wants: an associative operation with an identity element.
This is the same pattern you see in log aggregation, metrics systems, and analytics jobs. It is not an abstract trick, it is a workhorse.
A mental checklist for reduce
Whenever you write something like:
X result = stream.reduce(identity, (a, b) -> combine(a, b));you can run a short mental checklist.
-
If I ignore floating point quirks, is
combineassociative?combine(combine(a, b), c) == combine(a, combine(b, c))
-
Did I choose a true identity value?
Doescombine(identity, x)givexand doescombine(x, identity)givex? -
Do I care about order?
If I do, am I doing anything that changes encounter order?
If the answer to 1 and 2 is yes, reduce is usually safe, even in parallel.
If 1 or 2 fails, you either accept the weirdness, or you avoid parallelStream and regrouping.
Every reduce you write is a small promise.
You are telling the runtime:
- Here is how to combine two partial results.
- Here is the neutral element that represents “no information yet”.
If that promise matches reality, you get predictable, parallel friendly code.
If it does not, you get ghosts: bugs that only appear under load, only with parallel streams, only with certain sizes of input.
Thinking in terms of associativity and identity is not about being fancy. It is just a way to make that promise explicit in your own head, instead of leaving it as “whatever this lambda does”.
Once you start looking at your own code through that lens, you will find that a surprising amount of what you do every day is already algebra. You were just calling it “business logic”.