Definition
Multi-level grouping nests one groupingBy as the downstream of another.
- Outer groupingBy – partitions employees by department first.
- Inner groupingBy – partitions each department's employees again by gender.
- Result shape –
Map: department to a map of gender to employees.>> - Composable leaf – replace the innermost collector, for example
counting(), to change what sits at the bottom. - Same constraints – classifiers must not return null, and every level defaults to a
HashMap.
Priya at the National Bank of India first sorts the staff cards into department trays. Then, inside each department tray, she splits the cards again into a male slot and a female slot. The final filing cabinet has drawers (departments), inside each drawer two folders (genders), and inside each folder the people.
Understanding the Concepts
What is multi-level grouping?
Multi-level grouping means nesting one groupingBy as the downstream collector of another. The outer classifier partitions the stream into top-level groups, and the inner groupingBy partitions each of those groups again. The nesting can go as deep as you need.
Here department is the outer classifier and gender is the inner classifier.
package com.bank.report;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class DiversityReport {
public static Map<String, Map<String, List<Employee>>> byDeptThenGender(
List<Employee> employees) {
return employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(Employee::getGender)));
}
}
How do you read the result type?
The result type reads directly off the nesting. The outer map is keyed by department String, its values are inner maps keyed by gender String, and those values are List.
So result.get("IT").get("M") returns the list of male IT employees, and result.get("IT").get("F") returns the female IT employees.
Map<String, Map<String, List<Employee>>> report = DiversityReport.byDeptThenGender(employees);
List<Employee> maleIt = report.get("IT").get("M");
List<Employee> femaleFinance = report.get("Finance").get("F");
How does the nesting work internally?
Java first drops each employee into its department group using the outer classifier. The inner groupingBy is the downstream, so each department group is itself accumulated by another grouping collector that partitions by gender. The innermost values are collected into lists by default because groupingBy with no downstream implies toList().
package com.bank.report;
import java.math.BigDecimal;
public class Employee {
private final String name;
private final String department;
private final String gender;
private final BigDecimal salary;
public Employee(String name, String department, String gender, BigDecimal salary) {
this.name = name;
this.department = department;
this.gender = gender;
this.salary = salary;
}
public String getName() { return name; }
public String getDepartment() { return department; }
public String getGender() { return gender; }
public BigDecimal getSalary() { return salary; }
@Override public String toString() { return name; }
}
How do you run a full example?
package com.bank.report;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class DiversityReportDemo {
public static void main(String[] args) {
List<Employee> employees = List.of(
new Employee("Rajesh", "IT", "M", new BigDecimal("1200000")),
new Employee("Priya", "IT", "F", new BigDecimal("1100000")),
new Employee("Amit", "IT", "M", new BigDecimal("900000")),
new Employee("Pankaj", "Finance", "M", new BigDecimal("1500000")),
new Employee("Neha", "Finance", "F", new BigDecimal("1300000")));
Map<String, Map<String, List<Employee>>> report = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(Employee::getGender)));
report.forEach((dept, byGender) -> {
System.out.println(dept);
byGender.forEach((gender, people) ->
System.out.println(" " + gender + " -> " + people));
});
}
}
javac com/bank/report/Employee.java com/bank/report/DiversityReportDemo.java
java com.bank.report.DiversityReportDemo
Finance
F -> [Neha]
M -> [Pankaj]
IT
F -> [Priya]
M -> [Rajesh, Amit]
How do you change the innermost collector?
The innermost collector is free. Instead of lists, you can count per department-and-gender with counting(), giving Map, which is a common shape for summary dashboards.
Map<String, Map<String, Long>> counts = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(Employee::getGender,
Collectors.counting())));
{Finance={F=1, M=1}, IT={F=1, M=2}}
How is two-level grouping different from single-level grouping?
Single-level grouping gives one key per element value. Two-level grouping partitions each of those groups again, so the leaf collection holds only elements that match both classifiers.
| Aspect | Single-level groupingBy | Multi-level groupingBy |
|--------|-------------------------|------------------------|
| Collector form | groupingBy(dept) | groupingBy(dept, groupingBy(gender)) |
| Result type | Map | Map |
| Access | map.get("IT") | map.get("IT").get("M") |
| Keys | one classifier value per key | pair of classifier values |
// Single level: all IT employees together.
Map<String, List<Employee>> single = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
// Two levels: IT split further by gender.
Map<String, Map<String, List<Employee>>> nested = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getGender)));
What are common mistakes?
A common mistake is reaching for a nested access key that does not exist, because the inner map only contains gender keys that actually occurred in that department. Another is assuming the maps are ordered.
// WRONG: assumes every department has an "F" entry, may return null and NPE.
int count = report.get("IT").get("F").size(); // fine only if IT has a female employee
// CORRECT: guard against a missing inner key.
List<Employee> femaleIt = report
.getOrDefault("IT", Map.of())
.getOrDefault("F", List.of());
int safeCount = femaleIt.size();
What are the edge cases and null handling?
Any classifier returning null at any level throws a NullPointerException. An empty stream yields an empty outer map. A department with employees of only one gender produces an inner map with a single key, not two.
// Edge case: null gender crashes the inner grouping.
// Guard both classifiers when data may be dirty.
Map<String, Map<String, List<Employee>>> safe = employees.stream()
.collect(Collectors.groupingBy(
e -> e.getDepartment() == null ? "UNKNOWN_DEPT" : e.getDepartment(),
Collectors.groupingBy(
e -> e.getGender() == null ? "U" : e.getGender())));
How do you get ordered output with TreeMap?
Every level defaults to a HashMap, so iteration order is unspecified. Supply a TreeMap factory at each level with the four-argument groupingBy to get sorted departments and sorted genders.
import java.util.TreeMap;
Map<String, Map<String, List<Employee>>> ordered = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
TreeMap::new,
Collectors.groupingBy(
Employee::getGender,
TreeMap::new,
Collectors.toList())));
{Finance={F=[Neha], M=[Pankaj]}, IT={F=[Priya], M=[Rajesh, Amit]}}
When should you use multi-level grouping, and when not?
Use multi-level grouping when a report genuinely has a hierarchy, like department then gender, or region then branch then product. Do not over-nest: if you only ever read by a combined key, a single groupingBy on a composite key record can be simpler to consume.
// Alternative: group by a composite key record instead of nesting.
record DeptGender(String department, String gender) {}
Map<DeptGender, List<Employee>> flat = employees.stream()
.collect(Collectors.groupingBy(
e -> new DeptGender(e.getDepartment(), e.getGender())));
What are common interview follow-up questions?
Interviewers ask how the downstream collector enables nesting, how deep you can nest, how to count at the leaf, how to order the maps, and when a composite key is preferable to a nested map. A senior answer notes that each level is an independent collector, so you can mix levels, for instance group by department then reduce to the average salary by gender inside.
import java.util.DoubleSummaryStatistics;
// Follow-up: department -> gender -> average salary (non-money demo value).
Map<String, Map<String, Double>> avgByDeptGender = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getGender,
Collectors.averagingDouble(e -> e.getSalary().doubleValue()))));
Visual Overview
List<Employee>
│
│ groupingBy(dept, groupingBy(gender))
▼
┌──────────────────────────────────────────────┐
│ outer map: department -> inner map │
│ │
│ "IT" ───────────────► ┌────────────────────┐ │
│ │ inner: gender->list │ │
│ │ "M" -> [Rajesh,Amit]│ │
│ │ "F" -> [Priya] │ │
│ └────────────────────┘ │
│ │
│ "Finance" ──────────► ┌────────────────────┐ │
│ │ "M" -> [Pankaj] │ │
│ │ "F" -> [Neha] │ │
│ └────────────────────┘ │
└──────────────────────────────────────────────┘
│
▼
Map<String, Map<String, List<Employee>>>
Nesting as boxes inside boxes: ┌──────────────────────────────────────────┐ │ Map<String, ...> (by department)│ │ ┌──────────────────────────────────┐ │ │ │ Map<String, ...> (by gender) │ │ │ │ ┌──────────────────────────┐ │ │ │ │ │ List<Employee> (leaf) │ │ │ │ │ └──────────────────────────┘ │ │ │ └──────────────────────────────────┘ │ └──────────────────────────────────────────┘
Key Takeaway
Multi-level grouping is nothing more than nesting one groupingBy as the downstream of another. When Priya at the National Bank of India builds the diversity report, the outer classifier is the department and the inner classifier is the gender, so Java first drops each employee into its department group and then the inner groupingBy partitions each department again by gender, with the innermost values collected into lists by default.
The result type reads straight off the nesting: a map from department to a map from gender to the list of employees. To access male IT staff you write report.get("IT").get("M"), matching the two-level structure exactly, with department sections and gender sub-sections.
The flexibility worth highlighting is that the innermost collector is free to change. Swap the implied toList() for counting() and you get Map, the summary-dashboard shape of counts per department and gender. Swap it for an averaging collector and you get averages per sub-group.
The senior points are the same as for single-level grouping. No classifier at any level may return null, or grouping throws. Every level defaults to a HashMap, so when you need stable, readable order you supply a TreeMap factory at each level using the four-argument groupingBy. When a hierarchy is not truly needed, a single grouping on a composite key record can be easier to consume than a deep nested map.
Remember one sentence: to group by two things, make the downstream of the first groupingBy be a second groupingBy.
---