Partitioning in Java 8
-
Last Updated: June 10, 2024
-
By: javahandson
-
Series
Partitioning in Java 8 splits a stream into exactly two groups using a predicate. This guide covers both partitioningBy overloads, downstream collectors, nested partitions, and the traps that catch beginners.
Partitioning in Java 8 answers a question you hit constantly: which items pass my test, and which ones do not? Even or odd. Paid or unpaid. Above the threshold or below it.
The Streams API gives you a one-liner instead. Hand Collectors.partitioningBy a predicate, and it hands back a map with two entries. One holds everything that passed. The other holds everything that failed.
Small as it is, this method hides a few sharp edges. The returned map behaves differently from a normal HashMap, and a second overload opens up a whole family of downstream collectors. Let us work through all of it.
We start from the plain idea and build up to nested partitions and real reports. Here is the plan:
Some familiarity with streams and lambdas helps here. If either feels shaky, our guides on lambda expressions and predefined functional interfaces make a good warm-up.
Partitioning is a special case of grouping. The special part is the number of groups, and that number never changes.
Grouping lets you carve a stream into as many buckets as your classifier invents. Group employees by department and you might get three buckets, or thirty.
Partitioning fixes the count at two. Your predicate returns a boolean, and a boolean only has two possible values. So you get one bucket for true and one for false, every single time.
A Predicate<T> takes one value and returns a boolean. The collector calls it once per element and files the element under the answer.
So the result type reads as Map<Boolean, List<T>>. Fetch map.get(true) for the passers and map.get(false) for the rest.
Here is the rule that matters most, and the javadoc states it plainly: the returned map always contains both keys. Even when no element passes, you still get a true entry holding an empty list. We will see why that saves you from a whole class of bugs in section 3.1.
The method sits in java.util.stream.Collectors, alongside groupingBy, toList, and the rest of the factory methods. Java 8 shipped both overloads together, and neither has changed since.
Our introduction to the Collectors class covers the wider family if you want the full map first.
Both methods build a map from a stream. People often ask which one to use for a yes-or-no split, since groupingBy accepts a boolean classifier too.
This is the difference that bites. A groupingBy only creates a key when at least one element lands there. If nothing matches, the key never appears.
List<Integer> odds = Arrays.asList(1, 3, 5);
Map<Boolean, List<Integer>> grouped = odds.stream()
.collect(Collectors.groupingBy(n -> n % 2 == 0));
System.out.println(grouped); // Output: {false=[1, 3, 5]}
System.out.println(grouped.get(true)); // Output: null
Map<Boolean, List<Integer>> partitioned = odds.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println(partitioned); // Output: {false=[1, 3, 5], true=[]}
System.out.println(partitioned.get(true)); // Output: []Look at those two get(true) calls. The grouping version returns null, so grouped.get(true).size() throws a NullPointerException. The partition version returns an empty list, and size() quietly gives you zero.
| Aspect | partitioningBy | groupingBy |
|---|---|---|
| Number of groups | Always 2 | One per distinct value |
| Key type | Boolean | Any type |
| Function argument | Predicate | Function |
| Empty group | Present but empty | Absent |
| Map type | Fixed two-entry map | HashMap |
| Lookup cost | No hashing | Hash, then probe |
| Null key possible | No | Throws NPE |
| Downstream support | Yes | Yes |
Use partitioningBy whenever the split is genuinely binary. It states your intent in the code, guarantees both keys, and skips the hashing work.
Switch to groupingBy the moment a third category appears. Low, medium, and high cannot fit into two trays, and forcing it produces awkward nested code. Our article on grouping in Java 8 covers that case properly.
One more option deserves a mention. If you want two completely different summaries of the same stream, such as a count and an average, look at Collectors.teeing. Java 12 added it, and it feeds every element to two collectors before merging the results.
The simpler overload takes a single argument. It collects each group into a List, which suits most everyday work.
static <T> Collector<T, ?, Map<Boolean, List<T>>>
partitioningBy(Predicate<? super T> predicate)Generic signatures scare people off. Take it slowly and it falls apart into three easy pieces.
Start with static <T>. The method belongs to the class, not to an instance, so you call it as Collectors.partitioningBy(…). The T stands for whatever type your stream carries.
Next comes the return type, Collector<T, ?, Map<Boolean, List<T>>>. A Collector takes three type parameters:
Finally, Predicate<? super T> is your test. The ? super T part simply means a predicate written for a parent type still works. A Predicate<Object> can test a stream of Strings without complaint.
Let us write a program to separate the even and odd numbers in a list.
package com.javahandson.collectors.partitioning;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class PartitioningEx {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 2, 3, 4, 5, 6, 7, 7, 8, 9, 10);
Map<Boolean, List<Integer>> map = list.stream()
.collect(Collectors.partitioningBy(number -> number % 2 == 0));
System.out.println(map);
}
}
// Output: {false=[1, 3, 5, 7, 7, 9], true=[2, 2, 4, 6, 8, 10]}The predicate asks “is this number even?”. Every number that answers yes lands under true, and the rest land under false.
Two details are worth noticing. The duplicates 2 and 7 both survive, because a List keeps every element. And the printed order puts false first, which is how the JDK lays out its two entries.
The labels true and false mean nothing on their own. They describe your predicate, so flipping the test flips the buckets.
List<Integer> list = Arrays.asList(1, 2, 2, 3, 4, 5, 6, 7, 7, 8, 9, 10);
Map<Boolean, List<Integer>> map = list.stream()
.collect(Collectors.partitioningBy(number -> number % 2 != 0));
System.out.println(map);
// Output: {false=[2, 2, 4, 6, 8, 10], true=[1, 3, 5, 7, 7, 9]}A List of raw elements is not always what you want. Sometimes you need a count, a total, a Set, or just one field from each object. The second overload covers all of that.
static <T, D, A> Collector<T, ?, Map<Boolean, D>>
partitioningBy(Predicate<? super T> predicate,
Collector<? super T, A, D> downstream)Three type parameters now instead of one. Each has a plain-English meaning:
Compare the return types of the two overloads. The first hard-codes List<T> as the value. The second swaps in D, so whatever the downstream produces becomes your map value.
Every tray runs its own independent copy of the downstream collector. So a counting() downstream gives you two counts, one per group, not a single total.
When you skip the second argument, Java quietly uses Collectors.toList() for you. The single-argument overload is just a convenient shorthand.
Our earlier list held duplicate 2s and 7s. Passing Collectors.toSet() as the downstream drops them.
package com.javahandson.collectors.partitioning;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public class PartitioningEx {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 2, 3, 4, 5, 6, 7, 7, 8, 9, 10);
Map<Boolean, Set<Integer>> map = list.stream()
.collect(Collectors.partitioningBy(
number -> number % 2 == 0,
Collectors.toSet()));
System.out.println(map);
}
}
// Output: {false=[1, 3, 5, 7, 9], true=[2, 4, 6, 8, 10]}The extra 2 and the extra 7 have gone. Notice that the declared map type changed to Map<Boolean, Set<Integer>> as well, because the downstream now decides the value type.
One caution: toSet() gives no ordering promise. Small integers happen to print in a tidy order here, but never lean on that.
Often you only want the size of each side. Use counting(), and skip building the lists altogether.
package com.javahandson.collectors.partitioning;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import static java.util.stream.Collectors.counting;
import static java.util.stream.Collectors.partitioningBy;
public class PartitioningEx {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 10);
Predicate<Integer> isEven = number -> number % 2 == 0;
Map<Boolean, Long> map = list.stream()
.collect(partitioningBy(isEven, counting()));
System.out.println("Count of even numbers : " + map.get(true));
System.out.println("Count of odd numbers : " + map.get(false));
}
}
// Output:
// Count of even numbers : 5
// Count of odd numbers : 4Pulling the predicate into its own named variable pays off here. The collect line reads almost like a sentence, and you can unit-test isEven on its own.
The downstream slot accepts any Collector at all. A handful of them cover almost every report you will ever write.
| Downstream | Map value | Use it for |
|---|---|---|
| toList() | List<T> | Default; keeps duplicates |
| toSet() | Set<T> | Dropping duplicates |
| counting() | Long | How many in each group |
| summingInt(fn) | Integer | A total per group |
| averagingDouble(fn) | Double | A mean per group |
| summarizingInt(fn) | IntSummaryStatistics | All five figures at once |
| mapping(fn, …) | Downstream result | Extracting one field |
| joining(“, “) | String | A readable line per group |
| maxBy(cmp) | Optional<T> | The top item in each group |
| collectingAndThen(…) | Function result | Post-processing a result |
Say you want a readable line per group rather than a list of objects. Chain mapping into joining, and the map values come out as plain strings.
Map<Boolean, String> names = students.stream()
.collect(Collectors.partitioningBy(
student -> student.getMarks() > 400,
Collectors.mapping(Student::getName,
Collectors.joining(", "))));
System.out.println("Above 400 : " + names.get(true));
System.out.println("400 or below : " + names.get(false));
// Output:
// Above 400 : Suraj, Iqbal
// 400 or below : Amar, Amit, Suchit, KartikThe maxBy collector needs a Comparator and returns an Optional. Why Optional? Because a group might be empty, and there is no sensible maximum of nothing.
Map<Boolean, Optional<Student>> topper = students.stream()
.collect(Collectors.partitioningBy(
student -> student.getMarks() > 400,
Collectors.maxBy(Comparator.comparingInt(Student::getMarks))));
System.out.println(topper.get(true).get().getName()); // Output: Iqbal
System.out.println(topper.get(false).get().getName()); // Output: AmitRemember rule one from section 2.2. Both keys always exist, so topper.get(true) never returns null. The Optional inside might still be empty, though, which is exactly why those two calls to get() are risky in real code.
Nobody enjoys a Map<Boolean, Optional<Student>>. The collectingAndThen collector runs a finishing function over each group’s result, so you can flatten it right there.
Map<Boolean, String> topperName = students.stream()
.collect(Collectors.partitioningBy(
student -> student.getMarks() > 400,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Student::getMarks)),
best -> best.map(Student::getName).orElse("none"))));
System.out.println(topperName);
// Output: {false=Amit, true=Iqbal}The empty case now produces the string none instead of an exception. That single default turns a fragile report into a safe one.
Java 9 added two more downstream collectors, and both pair beautifully with partitioning.
Consider Collectors.filtering. You might reach for stream.filter(…) instead, but the two behave differently. A filter drops elements before the split, so a group can vanish. A filtering downstream keeps both groups and simply empties one.
// Keeps both keys, one may hold an empty list
Map<Boolean, List<Student>> onlyA = students.stream()
.collect(Collectors.partitioningBy(
student -> student.getMarks() > 400,
Collectors.filtering(
student -> student.getName().startsWith("A"),
Collectors.toList())));The other new arrival, flatMapping, flattens a nested collection per group. Use it when each element holds a list of its own, such as an order with several line items.
Now for the downstream that packs the most value into one call. A single summarizingInt gives you five numbers per group.
package com.javahandson.collectors.partitioning;
public class Student {
int rollNumber;
String name;
int marks;
public Student(int rollNumber, String name, int marks) {
this.rollNumber = rollNumber;
this.name = name;
this.marks = marks;
}
public String getName() {
return name;
}
public int getMarks() {
return marks;
}
}Let us split the students on whether they scored above 400, then gather the count, total, minimum, maximum, and average for each side.
package com.javahandson.collectors.partitioning;
import java.util.Arrays;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class PartitioningDemo {
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student(101, "Suraj", 450),
new Student(102, "Iqbal", 470),
new Student(103, "Amar", 330),
new Student(104, "Amit", 400),
new Student(105, "Suchit", 380),
new Student(106, "Kartik", 290));
Predicate<Student> marksAbove400 = student -> student.getMarks() > 400;
Map<Boolean, IntSummaryStatistics> stats = students.stream()
.collect(Collectors.partitioningBy(
marksAbove400,
Collectors.summarizingInt(Student::getMarks)));
System.out.println("Students who scored more than 400:");
System.out.println(stats.get(true));
System.out.println("Students who scored 400 or less:");
System.out.println(stats.get(false));
}
}
// Output:
// Students who scored more than 400:
// IntSummaryStatistics{count=2, sum=920, min=450, average=460.000000, max=470}
// Students who scored 400 or less:
// IntSummaryStatistics{count=4, sum=1400, min=290, average=350.000000, max=400}The true group holds Suraj on 450 and Iqbal on 470. Two students, 920 marks between them, so the average lands on 460.
The false group holds the other four. Watch Amit carefully: he scored exactly 400, and the predicate asks for strictly more than 400. So he falls on the false side.
Boundary values like that cause real bugs. Decide early whether your threshold is inclusive, then write > or >= to match, and put a test on that exact value.
You can also pull single fields off the statistics object with getAverage(), getMax(), and friends. Our guide on Java 8 summarizing methods digs into the whole family.
Here is a neat trick. A partition is itself a collector, so you can feed one partition into another.
Two independent yes-or-no questions produce four combinations. Nesting captures all of them in one pass over the data.
Map<Boolean, Map<Boolean, List<String>>> report = students.stream()
.collect(Collectors.partitioningBy(
student -> student.getMarks() > 400,
Collectors.partitioningBy(
student -> student.getName().startsWith("A"),
Collectors.mapping(Student::getName,
Collectors.toList()))));
System.out.println(report);
// Output: {false={false=[Suchit, Kartik], true=[Amar, Amit]},
// true={false=[Suraj, Iqbal], true=[]}}The outer key answers the first question: did this student score above 400? The inner key answers the second: does the name start with A?
So report.get(true).get(false) reads as “scored above 400, name does not start with A”, which gives Suraj and Iqbal. Chaining two get calls like that is safe, since every level guarantees both keys.
Look at the last bucket, true={…, true=[]}. No student both scored above 400 and had a name starting with A. A nested groupingBy would have dropped that combination entirely, and your report would have a hole in it.
A quick look inside explains both the performance and the odd behaviour of the returned map.
The JDK keeps a tiny private class with exactly two fields, one per side. For each element, the collector calls your predicate and picks a field with a ternary expression. No key gets boxed, and no hash table gets touched.
Compare that with groupingBy. It boxes the classifier result, hashes it, probes a HashMap, and may create a new list on a miss. All of that happens once per element.
That tiny two-field class implements Map, but it is not a HashMap. The javadoc promises nothing about the type, mutability, serializability, or thread safety of the result.
In practice you get a fixed two-entry view. Calling put on it fails, and casting it to HashMap throws a ClassCastException.
Map<Boolean, List<Integer>> map = list.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
// map.put(true, new ArrayList<>()); // fails, treat the map as read-only
// Need a real mutable map? Copy it.
Map<Boolean, List<Integer>> mutable = new HashMap<>(map);
mutable.put(true, new ArrayList<>()); // fineTreat the result as read-only and copy it when you need more. That habit keeps your code correct across JDK versions, whatever the implementation does next.
The collector ships with a combiner, so it works on a parallel stream without any change from you. Each thread fills its own pair of slots, and the combiner merges them at the end.
Two conditions keep that safe. Your predicate must have no side effects, and it must return the same answer for the same input every time. Break either rule and the results turn unpredictable.
Do not reach for parallelStream() by reflex, though. It pays off on large datasets with real work per element, and it costs you on small lists. Our article on parallel streams in Java 8 covers when the trade lands in your favour.
Six mistakes account for most of the partitioning bugs we see in code reviews.
Low, medium, and high do not fit two trays. Developers sometimes nest partitions to fake a third category, and the result reads terribly.
// Hard to read, and one bucket is meaningless
partitioningBy(s -> s.getMarks() > 400,
partitioningBy(s -> s.getMarks() > 200, toList()));
// Say what you mean
groupingBy(s -> s.getMarks() > 400 ? "HIGH"
: s.getMarks() > 200 ? "MEDIUM" : "LOW");The opposite mistake also happens. Developers coming from groupingBy guard every lookup out of habit.
// Unnecessary, get(true) never returns null here
List<Integer> evens = map.get(true);
if (evens != null) { ... }
// Enough on its own
if (!map.get(true).isEmpty()) { ... }The collector never produces a null key, because a boolean cannot be null. Your predicate can still explode on a null element, though.
List<String> names = Arrays.asList("Suraj", null, "Amit");
// Throws NullPointerException on the middle element
names.stream().collect(partitioningBy(n -> n.startsWith("A")));
// Filter first, or write a null-safe predicate
names.stream()
.filter(Objects::nonNull)
.collect(partitioningBy(n -> n.startsWith("A")));A common surprise: you wanted a list of names and got a list of Student objects printing as memory addresses.
The predicate chooses the tray. It never changes what goes in. Wrap a mapping(Student::getName, toList()) around your downstream and the values become what you expected.
Let us pull everything together into one small program you could drop into a real project.
We have a list of orders and need a short daily report. The business wants three things:
All three come from one stream, and none of them needs a loop.
package com.javahandson.collectors.partitioning;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class OrderReport {
static class Order {
private final String id;
private final double amount;
private final boolean paid;
Order(String id, double amount, boolean paid) {
this.id = id;
this.amount = amount;
this.paid = paid;
}
public String getId() { return id; }
public double getAmount() { return amount; }
public boolean isPaid() { return paid; }
}
public static void main(String[] args) {
List<Order> orders = Arrays.asList(
new Order("A-101", 1200.0, true),
new Order("A-102", 450.0, false),
new Order("A-103", 2300.0, true),
new Order("A-104", 150.0, false),
new Order("A-105", 980.0, true));
Predicate<Order> highValue = order -> order.getAmount() >= 1000;
// 1. Which ids fall on each side
Map<Boolean, List<String>> ids = orders.stream()
.collect(Collectors.partitioningBy(highValue,
Collectors.mapping(Order::getId, Collectors.toList())));
// 2. Revenue on each side
Map<Boolean, Double> revenue = orders.stream()
.collect(Collectors.partitioningBy(highValue,
Collectors.summingDouble(Order::getAmount)));
// 3. Each side split again by payment status
Map<Boolean, Map<Boolean, List<String>>> byPayment = orders.stream()
.collect(Collectors.partitioningBy(highValue,
Collectors.partitioningBy(Order::isPaid,
Collectors.mapping(Order::getId, Collectors.toList()))));
System.out.println("High value ids : " + ids.get(true));
System.out.println("Low value ids : " + ids.get(false));
System.out.println("High value revenue : " + revenue.get(true));
System.out.println("Low value revenue : " + revenue.get(false));
System.out.println("High value, unpaid : " + byPayment.get(true).get(false));
System.out.println("Low value, unpaid : " + byPayment.get(false).get(false));
}
}
// Output:
// High value ids : [A-101, A-103]
// Low value ids : [A-102, A-104, A-105]
// High value revenue : 3500.0
// Low value revenue : 1580.0
// High value, unpaid : []
// Low value, unpaid : [A-102, A-104]One predicate drives all three reports. Naming it highValue and reusing it means the threshold lives in exactly one place, so changing 1000 to 2000 is a one-line edit.
The revenue figures line up: 1200 plus 2300 gives 3500, and 450 plus 150 plus 980 gives 1580. Order A-105 at 980 sits just under the line, which is the boundary case worth a test.
Now look at the last two lines of output. No high-value order is unpaid, so that bucket prints as an empty list rather than crashing. A nested groupingBy would have returned null there, and the print statement would have thrown.
That empty bracket is the whole argument for partitioningBy in one character.
A: Partitioning splits a stream into exactly two groups using a predicate. Collectors.partitioningBy returns a Map with a Boolean key, where true holds the elements that passed the test and false holds the rest.
A: partitioningBy takes a Predicate and always produces exactly two Boolean keys. groupingBy takes a Function and creates one key per distinct classifier value. The big practical difference: partitioningBy includes an empty group, while groupingBy leaves that key out of the map.
A: Yes. The javadoc guarantees mappings for both keys, even on an empty stream. So map.get(true) never returns null, which saves you a null check on every lookup.
A: The first takes only a Predicate and returns Map<Boolean, List<T>>. Adding a downstream Collector gives you the second overload, which returns Map<Boolean, D>, where D is whatever that collector produces. Passing Collectors.toList() as that downstream matches the one-argument form exactly.
A: Pass Collectors.counting() as the downstream collector. The result type becomes Map<Boolean, Long>, so read the counts with map.get(true) and map.get(false). No intermediate lists get built.
A: No. A predicate returns a boolean, so two groups is the hard limit. For three or more categories use groupingBy with a classifier that returns an enum or a String. Nesting partitions to fake a third group makes the code very hard to read.
A: It decides what each group turns into after the predicate picks a side. Each group runs its own copy, so counting() gives two counts rather than one total. Common choices are toSet, counting, summingInt, summarizingInt, mapping, and joining.
A: Treat it as read-only. The javadoc makes no promise about type, mutability, serializability, or thread safety. The JDK returns a small fixed two-entry map, so calling put on it fails. Copy it with new HashMap<>(map) when you need to change it.
A: Yes. The collector supplies a combiner, so each thread fills its own pair of slots and the results merge at the end. Your predicate must be stateless and free of side effects, which is the same rule every parallel stream operation follows.
A: Pass Collectors.toSet() as the second argument to partitioningBy. The result type becomes Map<Boolean, Set<T>> and duplicates disappear. Keep in mind that toSet gives no ordering guarantee.
A: Slightly, yes. partitioningBy keeps two fields and picks one with a ternary, so nothing gets boxed or hashed. groupingBy boxes the key and probes a HashMap for every element. The gap only shows on large datasets, but the clearer intent is reason enough on its own.
Let us wrap up what we covered. Partitioning splits a stream into exactly two groups using a predicate, and the result is a Map<Boolean, List<T>>.
Both keys always exist. That one guarantee is the real reason to prefer partitioningBy over a groupingBy with a boolean classifier, because an empty group can never turn into a null.
The second overload opens the door to everything else. Swap in toSet, counting, joining, maxBy, or summarizingInt, and each group shapes itself the way your report needs.
Finally, treat the returned map as read-only, declare your variables as Map, and watch your boundary values. Get those three habits right and partitioning becomes one of the most reliable tools in the Streams API.