Back to Stream: Grouping & Partitioning
Q6Question 6 of 22

Multi-level grouping: group by department, then by gender into Map<String, Map<String, List<Employee>>>

Key Concepts
  • The downstream of groupingBy can be another groupingBy, producing a nested map.
  • groupingBy(dept, groupingBy(gender)) builds Map<String, Map<String, List<Employee>>>.
  • The outer classifier runs first, then the inner classifier partitions each group again.
  • The result type reads directly off the nesting: outer key, inner key, then the list.
  • Change the innermost collector to change the leaf shape, for example counting() for counts.
  • No classifier at any level may return null, or groupingBy throws.
  • Default maps are HashMap at every level; supply TreeMap factories for ordered output.

Definition

`groupingBy(dept, groupingBy(gender))` builds a two-level map: department, then gender, then the list of people.

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.

JAVA
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.

JAVA
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().

JAVA
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?

JAVA
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));
        });
    }
}
BASH
javac com/bank/report/Employee.java com/bank/report/DiversityReportDemo.java
java com.bank.report.DiversityReportDemo
TEXT
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.

JAVA
Map<String, Map<String, Long>> counts = employees.stream()
        .collect(Collectors.groupingBy(
                Employee::getDepartment,
                Collectors.groupingBy(Employee::getGender,
                        Collectors.counting())));
TEXT
{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 |

JAVA
// 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.

JAVA
// 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.

JAVA
// 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.

JAVA
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())));
TEXT
{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.

JAVA
// 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.

JAVA
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

DIAGRAM
  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>>>
DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate