Chaturmind
LearnDSASystem DesignDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

Built for engineers who want to go deep.


← Java Streams & Functional Programming

Lambdas & Functional Interfaces

  • Lambda Expressions
  • Method References

Streams API

  • Streams API
  • Collectors and groupingBy
  • Optional
Chaturmind
← Java Streams & Functional Programming

Lambdas & Functional Interfaces

  • Lambda Expressions
  • Method References

Streams API

  • Streams API
  • Collectors and groupingBy
  • Optional
HomeLearnJavaJava Streams & Functional ProgrammingStreams API
✓ FreeIntermediate· 6 min read

Collectors and groupingBy

Group, partition, join, and aggregate with Collectors — the power tools of streams.

Published September 21, 2026


Collectors and groupingBy

A stream's terminal operation collect(...) needs to know what to build from the elements: a list, a set, a map, a string, a number. That recipe is a Collector, and the Collectors class provides ready-made ones. Most of the time you need toList(). The real power is in the grouping collectors, which do in one line what used to take a loop, a map and several if statements.

// Before streams: count orders per status by hand
Map<Status, Integer> counts = new HashMap<>();
for (Order o : orders) {
    counts.merge(o.status(), 1, Integer::sum);
}

// With a collector
Map<Status, Long> counts = orders.stream()
        .collect(Collectors.groupingBy(Order::status, Collectors.counting()));

The examples below use this record:

record Order(String id, String customer, String city, Status status, BigDecimal total, int items) {}

Simple collectors

List<String> ids   = orders.stream().map(Order::id).collect(Collectors.toList());
Set<String> cities = orders.stream().map(Order::city).collect(Collectors.toSet());
String csv         = orders.stream().map(Order::id).collect(Collectors.joining(", ", "[", "]"));
long count         = orders.stream().collect(Collectors.counting());   // same as .count()

Collectors.toList() makes no promise about mutability; today it returns an ArrayList. Stream.toList() (Java 16) and Collectors.toUnmodifiableList() return unmodifiable lists.

groupingBy: split into buckets by a key

Map<String, List<Order>> byCity = orders.stream()
        .collect(Collectors.groupingBy(Order::city));
// { "Pune" = [o1, o4], "Delhi" = [o2], "Mumbai" = [o3, o5] }

groupingBy(classifier) applies the classifier to each element to get a key, and collects the elements with the same key into a list. Only keys that actually occur appear in the map.

Downstream collectors: what to do with each bucket

The second argument decides what each group becomes, instead of a list. This is where most interview questions live:

// How many orders per city?
Map<String, Long> countByCity =
        orders.stream().collect(groupingBy(Order::city, counting()));

// Total revenue per city
Map<String, BigDecimal> revenueByCity =
        orders.stream().collect(groupingBy(Order::city,
                reducing(BigDecimal.ZERO, Order::total, BigDecimal::add)));

// Average items per order, per status
Map<Status, Double> avgItems =
        orders.stream().collect(groupingBy(Order::status, averagingInt(Order::items)));

// Distinct customers per city
Map<String, Set<String>> customersByCity =
        orders.stream().collect(groupingBy(Order::city, mapping(Order::customer, toSet())));

// Largest order per city
Map<String, Optional<Order>> biggest =
        orders.stream().collect(groupingBy(Order::city, maxBy(comparing(Order::total))));

// Two levels: city → status → count
Map<String, Map<Status, Long>> cityStatus =
        orders.stream().collect(groupingBy(Order::city, groupingBy(Order::status, counting())));

(These examples assume import static java.util.stream.Collectors.*; and import static java.util.Comparator.comparing;, which is common in stream-heavy code.)

Useful downstream collectors: counting(), summingInt/Long/Double, averagingInt/…, mapping(f, downstream), filtering(pred, downstream) (Java 9), flatMapping (Java 9), minBy/maxBy, reducing, collectingAndThen(downstream, finisher), and another groupingBy.

Choosing the map type

By default you get a HashMap, with no key order. Pass a map factory as the middle argument when order matters:

TreeMap<String, Long> sortedByCity = orders.stream()
        .collect(groupingBy(Order::city, TreeMap::new, counting()));

maxBy returns an Optional, and collectingAndThen unwraps it

maxBy gives Optional<Order> because in general a group could be empty. It can't be in groupingBy, where every group has at least one element, so unwrap it:

Map<String, Order> biggest = orders.stream().collect(groupingBy(Order::city,
        collectingAndThen(maxBy(comparing(Order::total)), Optional::get)));

partitioningBy: exactly two buckets

Map<Boolean, List<Order>> bigAndSmall = orders.stream()
        .collect(partitioningBy(o -> o.total().compareTo(new BigDecimal("1000")) >= 0));

bigAndSmall.get(true);    // orders ≥ 1000
bigAndSmall.get(false);   // the rest

Unlike groupingBy with a boolean key, partitioningBy always contains both true and false keys, even when one list is empty, which saves null checks. It also accepts a downstream collector (partitioningBy(pred, counting())).

toMap: one entry per element

Map<String, Order> byId = orders.stream().collect(toMap(Order::id, Function.identity()));

Two traps:

  1. Duplicate keys throw. If two elements map to the same key, toMap throws IllegalStateException: Duplicate key. When duplicates are possible, pass a merge function that says which value wins, or how to combine them:
    Map<String, BigDecimal> spendByCustomer = orders.stream()
            .collect(toMap(Order::customer, Order::total, BigDecimal::add));   // sum on collision
    
  2. Null values throw a NullPointerException (it uses Map.merge internally). Filter out nulls first, or use groupingBy.

Rule of thumb: one value per key → toMap (with a merge function if keys can repeat); many values per key → groupingBy.

Statistics in one pass

IntSummaryStatistics stats = orders.stream().collect(summarizingInt(Order::items));
stats.getMin(); stats.getMax(); stats.getAverage(); stats.getSum(); stats.getCount();

When you need two different results from one pass, teeing (Java 12) combines two collectors:

record Range(BigDecimal min, BigDecimal max) {}
Range range = orders.stream().collect(teeing(
        minBy(comparing(Order::total)), maxBy(comparing(Order::total)),
        (min, max) -> new Range(min.orElseThrow().total(), max.orElseThrow().total())));

Performance and readability notes

  • A groupingBy with a downstream collector is one pass over the data. That's more efficient than grouping into lists and then post-processing each list.
  • Deeply nested collectors get hard to read quickly. When a pipeline needs a comment to be understood, extract the collector into a well-named variable or method, or use a loop.
  • For very large data, grouping in the database (GROUP BY) is usually better than loading every row into memory to group in Java.

Follow-up questions this topic invites — and their answers

Q: What happens with toMap if two elements have the same key? A: It throws IllegalStateException ("Duplicate key"). Supply a third argument, a merge function such as (a, b) -> a to keep the first or BigDecimal::add to combine, to resolve collisions deliberately.

Q: What's the difference between groupingBy(x -> x > 0) and partitioningBy(x -> x > 0)? A: Both produce a Map<Boolean, ...>. partitioningBy always contains both keys, even if a partition is empty, and uses a specialized, slightly more efficient map. groupingBy only contains keys that occurred, so get(false) may return null.

Q: How do you get the groups sorted by key? A: Pass a map factory: groupingBy(Order::city, TreeMap::new, toList()). For a sort by value (e.g. cities by order count), collect first, then stream the entries: map.entrySet().stream().sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())).

Q: Is groupingBy thread-safe in a parallel stream? A: Yes. The collector framework gives each thread its own partial map and merges them. groupingByConcurrent instead writes into one shared ConcurrentMap, which can be faster for parallel streams when the order of elements within each group doesn't matter.

Q: How would you find the most common city? A: Count, then take the maximum entry: orders.stream().collect(groupingBy(Order::city, counting())).entrySet().stream().max(Map.Entry.comparingByValue()).map(Map.Entry::getKey). It's two passes, one over the orders and one over the much smaller map, which is fine.

Previous

Streams API

Next

Optional

AI Tutor

Lesson: Collectors and groupingBy

Quick actions

AI responses can be inaccurate. Verify critical information.