CodeGym /Courses /JAVA 25 SELF /Advanced aggregation: nested groupings

Advanced aggregation: nested groupings

JAVA 25 SELF
Level 31 , Lesson 3
Available

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
groupingBy(Employee::getDepartment)
Map<String, List<Employee>>
groupingBy(Employee::getDepartment, averagingInt(...))
Map<String, Double>
groupingBy(Employee::getDepartment, groupingBy(...))
Map<String, Map<String, List<Employee>>>
groupingBy(..., mapping(..., toList()))
Map<..., List<...>>
groupingBy(..., groupingBy(..., mapping(..., toList())))
Map<..., Map<..., List<...>>>

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.

1
Task
JAVA 25 SELF, level 31, lesson 3
Locked
Building the company's organizational structure 📊
Building the company's organizational structure 📊
1
Task
JAVA 25 SELF, level 31, lesson 3
Locked
Report on honors students at the University of Enlightened Minds ✨
Report on honors students at the University of Enlightened Minds ✨
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION