Stream groupingBy and partitioningBy

Collectors.groupingBy() partitions a stream into a Map whose keys are the result of applying a classifier function and whose values are lists of matching elements. A downstream collector can replace the default List accumulator with any other reduction, counting, summing, joining strings, or even another groupingBy for nested maps. partitioningBy() is a specialised form that always produces a Map by splitting the stream at a predicate boundary.

Contents

Collectors.groupingBy() Basics

Collectors.groupingBy(classifier) partitions a stream into a Map<K, List<T>> where each key is the result of applying the classifier function. By default the result is a HashMap with ArrayList values; both can be overridden with the three-argument overload:

Java
import java.util.*;
import java.util.stream.*;

record Employee(String name, String department, String city, int salary, boolean active) {}

List<Employee> employees = List.of(
    new Employee("Alice",   "Engineering", "London",    90_000, true),
    new Employee("Bob",     "Marketing",   "New York",  65_000, true),
    new Employee("Carol",   "Engineering", "London",    80_000, false),
    new Employee("Diana",   "HR",          "New York",  55_000, true),
    new Employee("Ethan",   "Marketing",   "London",    70_000, true),
    new Employee("Fiona",   "Engineering", "Berlin",    95_000, true),
    new Employee("George",  "HR",          "Berlin",    50_000, false)
);

// groupingBy(classifier) → Map<K, List<T>>
Map<String, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::department));

// Keys: "Engineering", "Marketing", "HR"
byDept.forEach((dept, emps) -> {
    System.out.println(dept + ": " +
        emps.stream().map(Employee::name).collect(Collectors.joining(", ")));
});
// Engineering: Alice, Carol, Fiona
// Marketing: Bob, Ethan
// HR: Diana, George

// Grouping by a derived value — first letter of city
Map<Character, List<Employee>> byCity = employees.stream()
    .collect(Collectors.groupingBy(e -> e.city().charAt(0)));
// 'L' -> [Alice, Carol, Ethan], 'N' -> [Bob, Diana], 'B' -> [Fiona, George]

// TreeMap to guarantee sorted key order
Map<String, List<Employee>> sortedByDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::department, TreeMap::new, Collectors.toList()));
sortedByDept.keySet().forEach(System.out::println); // Engineering, HR, Marketing
The three-argument overload groupingBy(classifier, mapFactory, downstream) lets you control both the Map implementation (e.g. TreeMap::new for sorted keys) and the accumulation strategy in one call.

groupingBy with Downstream Collector

The two-argument overload groupingBy(classifier, downstream) replaces the default toList() accumulator with any collector, counting(), summingInt(), mapping(), joining(), maxBy(), or even another groupingBy():

Java
import java.util.stream.*;

// counting() — how many employees per department
Map<String, Long> countByDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
// {Engineering=3, Marketing=2, HR=2}

// summingInt() — total salary per department
Map<String, Integer> totalSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.summingInt(Employee::salary)));
// {Engineering=265000, Marketing=135000, HR=105000}

// averagingInt() — average salary per department
Map<String, Double> avgSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.averagingInt(Employee::salary)));
// {Engineering=88333.33, Marketing=67500.0, HR=52500.0}

// mapping() — extract a field after grouping
Map<String, List<String>> namesByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.mapping(Employee::name, Collectors.toList())));
// {Engineering=[Alice, Carol, Fiona], ...}

// joining() — names as a single comma-separated string per department
Map<String, String> joinedNamesByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.mapping(Employee::name, Collectors.joining(", "))));
// {Engineering="Alice, Carol, Fiona", Marketing="Bob, Ethan", HR="Diana, George"}

// toSet() — deduplicate cities per department
Map<String, Set<String>> citiesByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.mapping(Employee::city, Collectors.toSet())));
// {Engineering=[Berlin, London], Marketing=[London, New York], HR=[Berlin, New York]}

// maxBy() — highest earner per department
Map<String, Optional<Employee>> topEarnerByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.maxBy(Comparator.comparingInt(Employee::salary))));
topEarnerByDept.forEach((dept, emp) ->
    emp.ifPresent(e -> System.out.println(dept + " top earner: " + e.name())));
// Engineering top earner: Fiona  (95000)
// Marketing top earner: Ethan   (70000)
// HR top earner: Diana          (55000)
Collectors.collectingAndThen(downstream, finisher) wraps any downstream collector and applies a final transformation. Use it to convert a mutable list to an unmodifiable one, or to extract a value from an Optional returned by maxBy / minBy.

Multi-level groupingBy

Passing a second groupingBy() as the downstream collector builds a nested Map<K1, Map<K2, List<T>>>. Each subsequent grouping level can itself carry a downstream collector for further aggregation:

Java
// Nested groupingBy — outer key: department, inner key: city
Map<String, Map<String, List<Employee>>> byDeptAndCity = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.groupingBy(Employee::city)));

byDeptAndCity.forEach((dept, cityMap) -> {
    System.out.println("--- " + dept + " ---");
    cityMap.forEach((city, emps) ->
        System.out.println("  " + city + ": " +
            emps.stream().map(Employee::name).collect(Collectors.joining(", "))));
});
// --- Engineering ---
//   London: Alice, Carol
//   Berlin: Fiona
// --- Marketing ---
//   New York: Bob
//   London: Ethan
// --- HR ---
//   New York: Diana
//   Berlin: George

// Two-level grouping with a downstream count
Map<String, Map<String, Long>> countByDeptAndCity = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.groupingBy(Employee::city, Collectors.counting())));
// {Engineering={London=2, Berlin=1}, Marketing={New York=1, London=1}, HR={New York=1, Berlin=1}}

// Two-level grouping, inner level joins names
Map<String, Map<String, String>> namesByDeptCity = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.groupingBy(
            Employee::city,
            Collectors.mapping(Employee::name, Collectors.joining(", ")))));

// Flatten to a summary line per dept+city
employees.stream()
    .collect(Collectors.groupingBy(
        e -> e.department() + " / " + e.city(),
        Collectors.counting()))
    .forEach((key, count) -> System.out.println(key + " → " + count));
Multi-level groupings produce deeply nested Map types. Consider extracting each level into a named variable, or flattening into a single string key (e.g. "dept/city"), when readability matters more than the nested structure.

partitioningBy: Split into True / False

Collectors.partitioningBy(predicate) is a specialised groupingBy that always produces a Map<Boolean, List<T>>. Both true and false keys are always present (even if one list is empty), eliminating the need for null checks:

Java
// partitioningBy(predicate) → always Map<Boolean, List<T>>
// Guaranteed two keys: true and false

Map<Boolean, List<Employee>> activePartition = employees.stream()
    .collect(Collectors.partitioningBy(Employee::active));

List<Employee> active   = activePartition.get(true);
List<Employee> inactive = activePartition.get(false);

System.out.println("Active:   " + active.stream().map(Employee::name).toList());
System.out.println("Inactive: " + inactive.stream().map(Employee::name).toList());
// Active:   [Alice, Bob, Diana, Ethan, Fiona]
// Inactive: [Carol, George]

// Partition by salary threshold
Map<Boolean, List<Employee>> highEarners = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.salary() >= 75_000));
System.out.println("High earners: " +
    highEarners.get(true).stream().map(Employee::name).toList());
// [Alice, Carol, Fiona]

// Partition a list of integers into even / odd
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Map<Boolean, List<Integer>> evenOdd = numbers.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println("Even: " + evenOdd.get(true));  // [2, 4, 6, 8, 10]
System.out.println("Odd:  " + evenOdd.get(false)); // [1, 3, 5, 7, 9]

// Partition strings by length
List<String> words = List.of("cat", "elephant", "ox", "hippopotamus", "dog", "ant");
Map<Boolean, List<String>> longShort = words.stream()
    .collect(Collectors.partitioningBy(w -> w.length() > 4));
System.out.println("Long:  " + longShort.get(true));  // [elephant, hippopotamus]
System.out.println("Short: " + longShort.get(false)); // [cat, ox, dog, ant]
partitioningBy always returns a map with both keys present (even if one list is empty), making get(true) and get(false) safe without null checks. This distinguishes it from groupingBy with a boolean classifier, which only populates keys that actually appear.

partitioningBy with Downstream Collector

Like groupingBy, partitioningBy accepts a downstream collector as its second argument, applying a further reduction to each of the two partitions: useful for counting, averaging, or joining within each partition:

Java
// partitioningBy(predicate, downstream) — apply a second collector to each partition

// Count active vs inactive
Map<Boolean, Long> countActiveInactive = employees.stream()
    .collect(Collectors.partitioningBy(Employee::active, Collectors.counting()));
System.out.println(countActiveInactive);
// {false=2, true=5}

// Average salary per partition
Map<Boolean, Double> avgSalaryPartition = employees.stream()
    .collect(Collectors.partitioningBy(
        e -> e.salary() >= 75_000,
        Collectors.averagingInt(Employee::salary)));
System.out.println("High earner avg: " + avgSalaryPartition.get(true));
System.out.println("Others avg:      " + avgSalaryPartition.get(false));

// Names per partition as a joined string
Map<Boolean, String> namesByActive = employees.stream()
    .collect(Collectors.partitioningBy(
        Employee::active,
        Collectors.mapping(Employee::name, Collectors.joining(", "))));
System.out.println("Active:   " + namesByActive.get(true));
// Active:   Alice, Bob, Diana, Ethan, Fiona
System.out.println("Inactive: " + namesByActive.get(false));
// Inactive: Carol, George

// Partition primes vs composites (1..20)
Map<Boolean, List<Integer>> primesComposites = IntStream.rangeClosed(2, 20)
    .boxed()
    .collect(Collectors.partitioningBy(n -> {
        if (n < 2) return false;
        for (int i = 2; i * i <= n; i++) {
            if (n % i == 0) return false;
        }
        return true;
    }));
System.out.println("Primes:     " + primesComposites.get(true));
// Primes:     [2, 3, 5, 7, 11, 13, 17, 19]
System.out.println("Composites: " + primesComposites.get(false));
// Composites: [4, 6, 8, 9, 10, 12, 14, 15, 16, 18, 20]

toUnmodifiableMap Alternative

When keys are unique and you need a simple Map<K, V> rather than a Map<K, List<V>>, use Collectors.toMap() or Collectors.toUnmodifiableMap(). Supply a merge function to handle duplicate keys gracefully instead of throwing an exception:

Java
import java.util.stream.*;

// When you need a Map<K, V> (not Map<K, List<V>>) and keys are unique
// Use Collectors.toMap() or toUnmodifiableMap()

List<Employee> employees = List.of(
    new Employee("Alice",  "Engineering", "London",   90_000, true),
    new Employee("Bob",    "Marketing",   "New York", 65_000, true),
    new Employee("Fiona",  "Engineering", "Berlin",   95_000, true)
);

// toMap — name → salary (keys must be unique or provide merge function)
Map<String, Integer> salaryMap = employees.stream()
    .collect(Collectors.toMap(Employee::name, Employee::salary));
// {Alice=90000, Bob=65000, Fiona=95000}

// toUnmodifiableMap — same but result is unmodifiable (Java 10+)
Map<String, Employee> byName = employees.stream()
    .collect(Collectors.toUnmodifiableMap(Employee::name, e -> e));

// With merge function — handle duplicate keys (take higher salary)
List<Employee> withDups = List.of(
    new Employee("Alice", "Engineering", "London",  90_000, true),
    new Employee("Alice", "Engineering", "Berlin",  95_000, true),  // duplicate name
    new Employee("Bob",   "Marketing",   "New York", 65_000, true)
);
Map<String, Integer> maxSalaryByName = withDups.stream()
    .collect(Collectors.toMap(
        Employee::name,
        Employee::salary,
        Integer::max));  // merge: keep higher salary on duplicate key
// {Alice=95000, Bob=65000}

// groupingBy vs toMap — pick the right one:
//   groupingBy  → Map<K, List<T>>  (multiple values per key, safe on duplicates)
//   toMap       → Map<K, V>         (one value per key, requires merge on duplicates)

// collectingAndThen to wrap result as unmodifiable after groupingBy
Map<String, List<Employee>> immutableGroups = employees.stream()
    .collect(Collectors.collectingAndThen(
        Collectors.groupingBy(Employee::department),
        Collections::unmodifiableMap));
Prefer Collectors.toUnmodifiableMap() over wrapping with Collections.unmodifiableMap() when building lookup maps from streams: it is more concise and signals intent clearly at the collection site.

Real-world Example: Group Employees by Department

This end-to-end example applies multiple grouping strategies to a realistic employee dataset (headcount, average salary, top earner per department, two-level grouping, and summary statistics) all in a single stream pass per query:

Java
import java.util.*;
import java.util.stream.*;

// Realistic domain model
record Department(String name, String location) {}
record Employee(String name, Department dept, int salary, int yearsExp, boolean active) {}

List<Employee> staff = List.of(
    new Employee("Alice",   new Department("Engineering", "London"),   90_000, 8,  true),
    new Employee("Bob",     new Department("Marketing",   "New York"), 65_000, 4,  true),
    new Employee("Carol",   new Department("Engineering", "London"),   80_000, 5,  false),
    new Employee("Diana",   new Department("HR",          "New York"), 55_000, 3,  true),
    new Employee("Ethan",   new Department("Marketing",   "London"),   70_000, 6,  true),
    new Employee("Fiona",   new Department("Engineering", "Berlin"),   95_000, 10, true),
    new Employee("George",  new Department("HR",          "Berlin"),   50_000, 2,  false),
    new Employee("Hannah",  new Department("Engineering", "Berlin"),   88_000, 7,  true),
    new Employee("Ivan",    new Department("Marketing",   "New York"), 72_000, 5,  true)
);

// 1. Active headcount by department
Map<String, Long> headcount = staff.stream()
    .filter(Employee::active)
    .collect(Collectors.groupingBy(e -> e.dept().name(), Collectors.counting()));
System.out.println("Headcount: " + headcount);
// {Engineering=3, Marketing=3, HR=1}

// 2. Average salary by department, sorted descending
staff.stream()
    .collect(Collectors.groupingBy(
        e -> e.dept().name(),
        Collectors.averagingInt(Employee::salary)))
    .entrySet().stream()
    .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
    .forEach(e -> System.out.printf("%-15s %.0f%n", e.getKey(), e.getValue()));
// Engineering     88333
// Marketing       69000
// HR              52500

// 3. Top earner per department
Map<String, Optional<Employee>> topEarner = staff.stream()
    .collect(Collectors.groupingBy(
        e -> e.dept().name(),
        Collectors.maxBy(Comparator.comparingInt(Employee::salary))));
topEarner.forEach((dept, emp) ->
    emp.ifPresent(e -> System.out.println(dept + " → " + e.name() + " (" + e.salary() + ")")));
// Engineering → Fiona (95000)
// Marketing   → Ivan  (72000)
// HR          → Diana (55000)

// 4. Employees grouped by department, then by location
Map<String, Map<String, List<String>>> deptLocationNames = staff.stream()
    .collect(Collectors.groupingBy(
        e -> e.dept().name(),
        Collectors.groupingBy(
            e -> e.dept().location(),
            Collectors.mapping(Employee::name, Collectors.toList()))));
deptLocationNames.forEach((dept, locMap) -> {
    System.out.println(dept + ":");
    locMap.forEach((loc, names) ->
        System.out.println("  " + loc + ": " + names));
});

// 5. Salary statistics per department using summarizingInt
Map<String, IntSummaryStatistics> salaryStats = staff.stream()
    .collect(Collectors.groupingBy(
        e -> e.dept().name(),
        Collectors.summarizingInt(Employee::salary)));
salaryStats.forEach((dept, stats) ->
    System.out.printf("%s — min: %d  max: %d  avg: %.0f%n",
        dept, stats.getMin(), stats.getMax(), stats.getAverage()));
// Engineering — min: 80000  max: 95000  avg: 88333
// Marketing   — min: 65000  max: 72000  avg: 69000
// HR          — min: 50000  max: 55000  avg: 52500
Collectors.summarizingInt() computes count, sum, min, max, and average in a single pass, which is more efficient than chaining multiple collectors when you need multiple aggregate values for the same grouping key.