1. Introduction
In real life, you almost always have to group data. For example, split students by course, partition products by category, separate adults from children, and so on.
Without Stream API, such tasks were done manually: iterate a collection, check a classifier, add to the needed list in a Map. Here is typical “old‑school” code:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
String dept = employee.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}
It works, but it looks like you are sorting pasta into jars by hand. And what if you need to group by several classifiers? Or also compute sums per group? The code grows and becomes unreadable.
Stream API and special collectors (groupingBy, partitioningBy) let you do this in one or two lines — and look like a real Java wizard while doing it.
2. Collector groupingBy: grouping by a classifier
Core idea
groupingBy is a collector that turns a stream of elements into a Map where the key is the result of a classifier function, and the value is the list of elements matching that classifier.
Signature:
Collectors.groupingBy(Function<T, K>)
- T — the element type in the stream,
- K — the key (group) type returned by the function.
Simple example: grouping employees by department
Suppose we have a class:
public class Employee {
private final String name;
private final String department;
// ... constructor and getters
public Employee(String name, String department) {
this.name = name;
this.department = department;
}
public String getName() { return name; }
public String getDepartment() { return department; }
}
And a collection of employees:
List<Employee> employees = List.of(
new Employee("Alice", "IT"),
new Employee("Bob", "HR"),
new Employee("Clara", "IT"),
new Employee("Denis", "Finance"),
new Employee("Eva", "HR")
);
Group by department:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
What did we get?
- Key: department name (String).
- Value: the list of employees in that department (List<Employee>).
Print the result:
byDepartment.forEach((dept, emps) -> {
System.out.println(dept + ": " +
emps.stream().map(Employee::getName).toList());
});
Output:
IT: [Alice, Clara]
HR: [Bob, Eva]
Finance: [Denis]
How does it work under the hood?
First, for each element in the stream, a key is computed (for example, getDepartment()). If such a key already exists in the Map, the element is added to the corresponding list. If the key does not exist, a new list is created.
Analogy
Imagine you are sorting letters into folders: for each letter you check whether a folder with the required name exists, and if not — you create a new one and put the letter there.
3. Collector partitioningBy: splitting into two groups
Sometimes you don’t need to “group by value” so much as simply split a collection into two parts by a logical predicate (true/false). For example, employees with salaries above and below a certain threshold, or students into “passed/failed.”
For that, there is a special collector — partitioningBy.
Signature:
Collectors.partitioningBy(Predicate<T>)
Predicate is a function that returns a boolean value.
Example: splitting employees by salary level
Suppose we have:
public class Employee {
private final String name;
private final int salary;
// ... constructor and getters
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String getName() { return name; }
public int getSalary() { return salary; }
}
And a list:
List<Employee> employees = List.of(
new Employee("Alice", 120_000),
new Employee("Bob", 80_000),
new Employee("Clara", 150_000),
new Employee("Denis", 95_000)
);
Split into “high earners” and “others”:
Map<Boolean, List<Employee>> partitioned = employees.stream()
.collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
- true — employees with a salary greater than 100_000.
- false — the rest.
Print:
System.out.println("High earners: " +
partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("Others: " +
partitioned.get(false).stream().map(Employee::getName).toList());
Result:
High earners: [Alice, Clara]
Others: [Bob, Denis]
When to use partitioningBy, and when groupingBy?
- If there are more than two groups — use groupingBy.
- If there are exactly two groups by a boolean predicate — use partitioningBy: it’s faster and clearer.
4. Nested groupings: grouping by multiple classifiers
Sometimes you want to group not only by a single classifier, but also “nest” one grouping inside another. For example, group employees first by department, and within a department — by position.
Example:
Suppose our Employee class also has a position field:
public class Employee {
private final String name;
private final String department;
private final String position;
// ... constructor and getters
}
Nested grouping:
Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)));
- Outer key — department.
- Inner key — position.
- Value — the list of employees.
How to access employees from the IT department with the “Developer” position?
List<Employee> itDevs = byDeptAndPosition
.getOrDefault("IT", Map.of())
.getOrDefault("Developer", List.of());
Visual diagram
Map<Department, Map<Position, List<Employee>>>
└─ "IT"
├─ "Developer" -> [Alice, Clara]
└─ "QA" -> [Boris]
└─ "HR"
└─ "Recruiter" -> [Denis]
5. Practical grouping examples
Example 1: Grouping strings by length
List<String> words = List.of("cat", "dog", "elephant", "bee", "ant", "dolphin");
Map<Integer, List<String>> byLength = words.stream()
.collect(Collectors.groupingBy(String::length));
byLength.forEach((len, ws) -> System.out.println(len + ": " + ws));
Output:
3: [cat, dog, bee, ant]
8: [elephant, dolphin]
Example 2: Grouping numbers by parity
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
Map<String, List<Integer>> byParity = numbers.stream()
.collect(Collectors.groupingBy(n -> n % 2 == 0 ? "even" : "odd"));
System.out.println(byParity);
// {odd=[1, 3, 5], even=[2, 4, 6]}
Example 3: Using partitioningBy for strings
Split strings into those starting with “A” and all the others:
List<String> names = List.of("Alice", "Bob", "Anna", "Charlie");
Map<Boolean, List<String>> byA = names.stream()
.collect(Collectors.partitioningBy(s -> s.startsWith("A")));
System.out.println("A-names: " + byA.get(true)); // [Alice, Anna]
System.out.println("Other: " + byA.get(false)); // [Bob, Charlie]
5. Useful nuances
How to use the grouping result
Often after grouping you don’t just want a Map, but also to process it somehow:
- Iterate over all groups and print information.
- Find the group with the maximum number of elements.
- For each group compute, for example, a sum or an average — more on this in the next lecture.
Example: print the number of employees in each department
byDepartment.forEach((dept, emps) ->
System.out.println(dept + ": " + emps.size() + " employees"));
Comparison with a manual implementation
For reinforcement: here is what grouping by department would look like the “old-fashioned” way:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
String dept = e.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}
And with Stream API:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Takeaway: less code, fewer bugs, better readability.
Tips and tricks
If you need to group not into a List but, for example, into a Set — use Collectors.toSet() as the second parameter:
Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())
You can aggregate right away: for example, get the number of employees in each department:
Collectors.groupingBy(Employee::getDepartment, Collectors.counting())
But more on that — in the next lecture!
After partitioningBy, there will always be two keys: true and false. Even if one of the groups is empty.
After nested groupings, the structure becomes a “tree”: a Map inside a Map, and so on.
6. Typical mistakes in grouping and partitioning
Mistake No. 1: Incorrect result type. Beginners often expect the result of groupingBy to be just a List<T>, not a Map<K, List<T>>. As a result, they try to call list methods and get a compilation error. Remember: grouping always yields a Map!
Mistake No. 2: NullPointerException when accessing a non-existent group. If you try to get a list by a non-existent key, you will get null. Use getOrDefault(key, List.of()) or check the presence of a key via containsKey.
Mistake No. 3: Using partitioningBy for tasks with multiple groups. partitioningBy is only for two groups (true/false). If there are more groups — use groupingBy.
Mistake No. 4: Modifying collections inside a stream. Do not try to modify source collections or a Map inside a stream — this leads to unexpected bugs. Perform all processing via the Stream API and collectors.
Mistake No. 5: Non-obvious structure of nested groupings. After nested groupingBy the result is a Map inside a Map. Don’t forget to extract data correctly (for example, via getOrDefault), otherwise you will get a ClassCastException.
GO TO FULL VERSION