Grouping and Partitioning

Group records by a key with groupingBy, split them in two with partitioningBy, and use downstream collectors to count, sum and map each group.

SyntaxCollectors.groupingBy(keyFn) Collectors.groupingBy(keyFn, TreeMap::new, Collectors.counting()) Collectors.partitioningBy(predicate, downstream)

Collectors.groupingBy is the stream version of SQL's GROUP BY. It takes a function that computes a key for each element and returns a Map from each key to the elements that share it.

Map<String, List<Order>> byStatus = orders.stream()
    .collect(Collectors.groupingBy(Order::status));

Downstream collectors

A list per key is only the default. The last argument is a downstream collector that runs inside each group:

  • Collectors.counting() gives a Long per key.
  • Collectors.summingInt(Order::totalCents) gives a total per key.
  • Collectors.mapping(Order::id, Collectors.toList()) keeps only one field from each element.
  • Collectors.maxBy(comparator) gives an Optional per key; wrap it in collectingAndThen to unwrap.

Map type and ordering

The default result is a HashMap, so the key order you see in a log or a JSON response is not something you can rely on. The three-argument form takes a map factory: TreeMap::new for sorted keys, or an EnumMap when the keys are an enum and you want them in declaration order. Choose this deliberately for anything a client or a test will look at.

partitioningBy

partitioningBy(predicate) splits the elements into exactly two groups keyed by false and true. Unlike groupingBy, both keys are always present, even when one group is empty, so result.get(true) never returns null. Use it for passed and failed validation, slow and fast requests, or in-stock and out-of-stock items.

When to group in the database

Grouping in Java loads every row into memory first. For reports over large tables, GROUP BY in SQL returns a few summary rows instead of thousands of records, and the database can use indexes. Group in a stream when the data is already loaded for another reason, comes from several sources, or is small enough that one extra query is not worth it.

A common mistake is grouping with a key that is null for some elements: groupingBy throws NullPointerException with the message "element cannot be mapped to a null key". Map missing values to a placeholder such as "UNKNOWN" in the key function.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Count orders per status for an admin dashboard without a second database query.
  • Split imported rows into valid and invalid with partitioningBy and report both counts to the client.
  • Group support tickets by priority into an EnumMap so the API lists urgent ones first.

More examples

partitioningBy always has both keys

No request took over five seconds, yet the true key is still there with a count of 0. With groupingBy the empty group would simply be missing.

Example · java
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

void main() {
    List<Integer> latenciesMs = List.of(120, 950, 80, 1500, 300);

    Map<Boolean, List<Integer>> slow = latenciesMs.stream()
        .collect(Collectors.partitioningBy(ms -> ms > 500));
    IO.println(slow);

    Map<Boolean, Long> verySlow = latenciesMs.stream()
        .collect(Collectors.partitioningBy(ms -> ms > 5000, Collectors.counting()));
    IO.println(verySlow);
}
// Output:
// {false=[120, 80, 300], true=[950, 1500]}
// {false=5, true=0}

Grouping by an enum in declaration order

An EnumMap keeps keys in the order the enum declares them, so HIGH comes first regardless of the order tickets arrived in. Inside each group the ids keep their encounter order.

Example · java
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

enum Priority { HIGH, MEDIUM, LOW }

record Ticket(int id, Priority priority) {}

void main() {
    List<Ticket> tickets = List.of(
        new Ticket(1, Priority.LOW),
        new Ticket(2, Priority.HIGH),
        new Ticket(3, Priority.LOW),
        new Ticket(4, Priority.MEDIUM));

    Map<Priority, List<Integer>> byPriority = tickets.stream()
        .collect(Collectors.groupingBy(
            Ticket::priority,
            () -> new EnumMap<Priority, List<Integer>>(Priority.class),
            Collectors.mapping(Ticket::id, Collectors.toList())));

    IO.println(byPriority);
}
// Output:
// {HIGH=[2], MEDIUM=[4], LOW=[1, 3]}

The largest element per group

maxBy wraps each result in Optional because a collector cannot know a group is non-empty. Groups made by groupingBy always have at least one element, so collectingAndThen can unwrap it safely.

Example · java
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Collectors;

record Reading(String sensor, int value) {}

void main() {
    List<Reading> readings = List.of(
        new Reading("s1", 4), new Reading("s2", 7),
        new Reading("s1", 9), new Reading("s2", 3));

    Map<String, Optional<Reading>> raw = readings.stream()
        .collect(Collectors.groupingBy(Reading::sensor, TreeMap::new,
            Collectors.maxBy(Comparator.comparingInt(Reading::value))));
    IO.println(raw.get("s1"));

    Map<String, Integer> peak = readings.stream()
        .collect(Collectors.groupingBy(Reading::sensor, TreeMap::new,
            Collectors.collectingAndThen(
                Collectors.maxBy(Comparator.comparingInt(Reading::value)),
                r -> r.orElseThrow().value())));
    IO.println(peak);
}
// Output:
// Optional[Reading[sensor=s1, value=9]]
// {s1=9, s2=7}

Discussion

  • Be the first to comment on this lesson.