1. Nested groupingBy: syntax and how it works
In real life, it’s rarely enough to group data by a single attribute. For example, if you have a list of company employees, you often want to know not just how many people are in each department, but how many are in each department for each position. Or, if you have a database of students, how many students are in each course for each speciality.
Nested groupings let you build such “maps inside maps” — a structure like “department → position → list of employees,” or “course → speciality → list of students.”
Without the Stream API, such tasks would be solved with several nested loops and manually building a Map inside a Map. With streams and collectors, this can be done in one or two lines.
A nested grouping is when you pass another collector as the second argument to Collectors.groupingBy, for example, another groupingBy. As a result, you get a map where the value for each key is another map.
General pattern
Map<Key1, Map<Key2, List<T>>> result =
stream.collect(Collectors.groupingBy(
object -> key1,
Collectors.groupingBy(object -> key2)
));
Example: employees by department and position
Suppose we have a class:
class Employee {
private String name;
private String department;
private String position;
private int salary;
// ... constructors, getters, toString
}
Employee list:
List<Employee> employees = List.of(
new Employee("Ivan", "IT", "Developer", 120_000),
new Employee("Maria", "IT", "Tester", 90_000),
new Employee("Pyotr", "HR", "Manager", 80_000),
new Employee("Olga", "IT", "Developer", 130_000),
new Employee("Svetlana", "HR", "Recruiter", 70_000)
);
Group by department and position:
Map<String, Map<String, List<Employee>>> grouped = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)
));
What did we get?
A map where the key is the department and the value is a map (key is the position, value is the list of employees).
Structure visualization
IT:
Developer: [Ivan, Olga]
Tester: [Maria]
HR:
Manager: [Pyotr]
Recruiter: [Svetlana]
2. Grouping with aggregation: combine groupingBy and aggregators
Nested groupings are not limited to collecting lists! You can aggregate data within each subgroup right away.
Example: maximum salary by department
Map<String, Optional<Employee>> maxSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
));
Here, for each department we obtain the employee with the maximum salary (the result is wrapped in Optional because a department might end up empty).
Nested grouping + aggregation
Suppose we want to know the maximum salary for each position in each department:
Map<String, Map<String, Optional<Employee>>> maxSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
)
));
What does this mean?
- For each department — a map of positions.
- For each position — the employee with the maximum salary (or an empty Optional if there is no one).
3. Grouping with transformation: mapping inside groupingBy
Sometimes you don’t just need to group objects, but to extract only certain fields within the groups.
Example: employee names by department
Map<String, List<String>> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.mapping(Employee::getName, Collectors.toList())
));
Result:
IT: [Ivan, Maria, Olga]
HR: [Pyotr, Svetlana]
Nested mapping
You can combine mapping with nested groupingBy:
Map<String, Map<String, List<String>>> namesByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.mapping(Employee::getName, Collectors.toList())
)
));
Result:
IT:
Developer: [Ivan, Olga]
Tester: [Maria]
HR:
Manager: [Pyotr]
Recruiter: [Svetlana]
4. Grouping + aggregating numeric data
It’s often necessary not just to group, but to compute sums, averages, or counts per group.
Example: average salary by department
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(Employee::getSalary)
));
Nested aggregation
Average salary by position in each department:
Map<String, Map<String, Double>> avgSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.averagingInt(Employee::getSalary)
)
));
5. partitioningBy + aggregation
Sometimes it’s convenient to split a collection into two groups by a boolean predicate and aggregate inside as well.
Example: how many employees with a salary above 100_000 in each department
Map<String, Map<Boolean, Long>> countByDeptAndSalary = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.partitioningBy(
e -> e.getSalary() > 100_000,
Collectors.counting()
)
));
Result:
For each department — a map: true/false → employee count.
6. Practical tasks: applying nested groupings
Task 1. Students by course and speciality
class Student {
private String name;
private int course;
private String speciality;
private double grade;
// ... getters, constructor
}
List<Student> students = ... // assume it already exists
Map<Integer, Map<String, List<Student>>> byCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(Student::getSpeciality)
));
Task 2. Average grade by course
Map<Integer, Double> avgGradeByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.averagingDouble(Student::getGrade)
));
Task 3. Names only by groups
Map<Integer, Map<String, List<String>>> namesByCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(
Student::getSpeciality,
Collectors.mapping(Student::getName, Collectors.toList())
)
));
7. Useful nuances
How to read and extract data from nested Map
Working with nested maps can feel unusual at first. Here’s a basic example:
for (var deptEntry : grouped.entrySet()) {
String dept = deptEntry.getKey();
Map<String, List<Employee>> byPosition = deptEntry.getValue();
System.out.println("Department: " + dept);
for (var posEntry : byPosition.entrySet()) {
String pos = posEntry.getKey();
List<Employee> emps = posEntry.getValue();
System.out.println(" Position: " + pos + " -> " + emps);
}
}
Nested grouping diagram
Map<Department, Map<Position, List<Employee>>>
│
├── "IT"
│ ├── "Developer" → [Ivan, Olga]
│ └── "Tester" → [Maria]
└── "HR"
├── "Manager" → [Pyotr]
└── "Recruiter" → [Svetlana]
Table: what you get after different combinations
| Collector | Result |
|---|---|
|
|
|
|
|
|
|
|
|
|
8. Common mistakes when working with nested groupings
Error #1: Misunderstanding the structure of nested maps.
After nested groupings, it’s easy to get confused about what exactly lies in each map’s value. Always look at the result signature — your IDE will hint at the type. If in doubt, print the result to the console with System.out.println(grouped) or use a debugger.
Error #2: NullPointerException when extracting data.
If a key is missing (for example, the department has no employees for a given position), Map.get(key) returns null. Check key presence via containsKey or, starting with Java 8, use Map.getOrDefault, and with Java 9 — Map.ofNullable and the Optional methods.
Error #3: Nesting that’s too deep.
If the grouping gets too deep (3–4 levels), you might want to reconsider the data structure or split the task into smaller steps.
Error #4: Aggregating at the wrong level.
Sometimes an aggregating collector (averagingInt, counting) is mistakenly placed outside the needed groupingBy, leading to unexpected results. Always pay close attention to your parentheses!
Error #5: Attempting to mutate elements inside collect.
Do not mutate source collections or objects during grouping — this can lead to hard-to-catch bugs.
GO TO FULL VERSION