Collectors.groupingBy and partitioningBy Through Examples

1. Overview

When working with Stream, you often need to group elements by a certain criterion. For example: orders by city, total amount by customer, or separating large and small orders. Collectors.groupingBy and Collectors.partitioningBy were created for exactly this purpose.

This article goes through each common usage pattern with specific output. You’ll learn how to combine downstream collectors, choose the Map type to return, and avoid a few common mistakes.

2. Sample Data

All examples in this article use the same set of orders:

import java.util.List;

enum OrderStatus { PENDING, PAID, CANCELLED }

record Order(String id, String customer, String city, OrderStatus status, long amount) {}

class OrderFixtures {
    static List<Order> sample() {
        return List.of(
            new Order("O1", "An",   "Hà Nội",      OrderStatus.PAID,      500_000),
            new Order("O2", "Bình", "Đà Nẵng",     OrderStatus.PAID,      1_200_000),
            new Order("O3", "An",   "Hà Nội",      OrderStatus.CANCELLED, 300_000),
            new Order("O4", "Chi",  "Hồ Chí Minh", OrderStatus.PAID,      2_000_000),
            new Order("O5", "Bình", "Đà Nẵng",     OrderStatus.PENDING,   150_000),
            new Order("O6", "Chi",  "Hồ Chí Minh", OrderStatus.PAID,      800_000)
        );
    }
}

amount is in VND and uses type long for brevity. In a real system, you should use BigDecimal for currency.

The examples below assume you already have import static java.util.stream.Collectors.*; and List<Order> orders = OrderFixtures.sample();.

3. Basic groupingBy

The simplest form takes only a classifier function:

Map<String, List<Order>> byCity = orders.stream()
        .collect(groupingBy(Order::city));

byCity.forEach((city, list) ->
        System.out.println(city + " -> " + list.stream().map(Order::id).toList()));
Hà Nội -> [O1, O3]
Đà Nẵng -> [O2, O5]
Hồ Chí Minh -> [O4, O6]

Order::city determines the key. Each key maps to a List containing orders with the same value. Internally, this version is equivalent to groupingBy(Order::city, HashMap::new, toList()).

Note: The returned Map is a HashMap, so the key order is not guaranteed. Output on your machine may differ. If you need order, see section 5.

4. Downstream collector

The second parameter of groupingBy is another Collector, called a downstream collector. It determines how to accumulate elements within the same group. This is what makes groupingBy truly powerful.

4.1. Counting and Summing

Map<OrderStatus, Long> countByStatus = orders.stream()
        .collect(groupingBy(Order::status, counting()));

Map<String, Long> totalByCustomer = orders.stream()
        .collect(groupingBy(Order::customer, summingLong(Order::amount)));

System.out.println(countByStatus);
System.out.println(totalByCustomer);
{PAID=4, PENDING=1, CANCELLED=1}
{An=800000, Bình=1350000, Chi=2800000}

counting() always returns Long, not Integer. Remember this when declaring variable types. Besides summingLong, there are also summingInt, summingDouble, averagingLong, and summarizingLong. The last collector returns LongSummaryStatistics, which contains count, sum, min, max, and average all at once.

4.2. Transforming Elements with mapping

If you only need a single property instead of the entire object, use mapping:

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

System.out.println(customersByCity);
{Hà Nội=[An], Đà Nẵng=[Bình], Hồ Chí Minh=[Chi]}

mapping(Order::customer, toSet()) extracts the customer name from each order, then gathers them into a Set to eliminate duplicates. An has two orders in Hà Nội but appears only once.

4.3. Finding the Maximum Element in a Group

maxBy returns Optional<Order> because theoretically a group could be empty. In groupingBy, a group only exists when it has at least one element. Therefore, you can “unwrap” the Optional using collectingAndThen:

Map<String, Order> biggestByCustomer = orders.stream()
        .collect(groupingBy(
                Order::customer,
                collectingAndThen(maxBy(Comparator.comparingLong(Order::amount)), Optional::get)
        ));

biggestByCustomer.forEach((customer, order) ->
        System.out.println(customer + " -> " + order.id() + " (" + order.amount() + ")"));
An -> O1 (500000)
Bình -> O2 (1200000)
Chi -> O4 (2000000)

collectingAndThen runs the inner collector first, then applies a finisher function (Optional::get) to the result.

There’s another commonly used approach: toMap(Order::customer, Function.identity(), BinaryOperator.maxBy(...)). This gives similar results without needing to handle Optional.

4.4. Filtering Within Groups with filtering

Collectors.filtering has been available since Java 9. It differs from calling .filter() before grouping:

Map<String, Long> cancelledPerCustomer = orders.stream()
        .collect(groupingBy(Order::customer,
                filtering(o -> o.status() == OrderStatus.CANCELLED, counting())));

Map<String, Long> filteredFirst = orders.stream()
        .filter(o -> o.status() == OrderStatus.CANCELLED)
        .collect(groupingBy(Order::customer, counting()));

System.out.println(cancelledPerCustomer);
System.out.println(filteredFirst);
{An=1, Bình=0, Chi=0}
{An=1}

With filtering, every customer still appears in the result, even when the count is 0. When you .filter() first, Bình and Chi disappear completely. Choose based on your needs: use filtering if your report needs the complete list.

Similarly, flatMapping (Java 9) helps flatten a collection within each group.

5. Choosing the Returned Map Type

The three-parameter version allows you to pass in a Supplier that creates the Map:

Map<String, Long> sortedTotals = orders.stream()
        .collect(groupingBy(Order::customer, TreeMap::new, summingLong(Order::amount)));

Map<OrderStatus, Long> statusCounts = orders.stream()
        .collect(groupingBy(Order::status,
                () -> new EnumMap<>(OrderStatus.class), counting()));

System.out.println(sortedTotals);
System.out.println(statusCounts);
{An=800000, Bình=1350000, Chi=2800000}
{PENDING=1, PAID=4, CANCELLED=1}

TreeMap sorts keys in natural order. EnumMap keeps keys in the order they’re declared in the enum and is faster than HashMap when keys are enums. If you want to preserve the order of first appearance in the stream, use LinkedHashMap::new.

Note: TreeMap with String sorts by Unicode code points, not Vietnamese collation rules. For example, “Đà Nẵng” would come after “Hồ Chí Minh”. When you need proper Vietnamese sorting, pass () -> new TreeMap<>(Collator.getInstance(Locale.of("vi"))).

6. Nested Grouping

Since a downstream can be any collector, you can nest another groupingBy inside it:

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

System.out.println(cityThenStatus);
{Hà Nội={PAID=1, CANCELLED=1}, Hồ Chí Minh={PAID=2}, Đà Nẵng={PAID=1, PENDING=1}}

The result is a two-level Map. This is very convenient for pivot-style reports. However, beyond two levels, the data type becomes hard to read. In that case, consider using a composite key, for example record CityStatus(String city, OrderStatus status), then groupingBy(o -> new CityStatus(o.city(), o.status())).

7. partitioningBy

partitioningBy is a special case of grouping. The classifier is a Predicate, and the key is always Boolean:

Map<Boolean, List<String>> bigOrders = orders.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000,
                mapping(Order::id, toList())));

Map<Boolean, Long> bigOrderCount = orders.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000, counting()));

System.out.println(bigOrders);
System.out.println(bigOrderCount);
System.out.println("Big orders: " + bigOrders.get(true));
{false=[O1, O3, O5, O6], true=[O2, O4]}
{false=4, true=2}
Big orders: [O2, O4]

partitioningBy also accepts a downstream collector just like groupingBy. However, it doesn’t have a version that accepts a Supplier for the Map, because the returned Map is an internal implementation with exactly two keys.

7.1. Important Difference: Keys Always Exist

This is the point that causes many people to choose between the two collectors incorrectly:

List<Order> smallOnly = orders.stream().filter(o -> o.amount() < 1_000_000).toList();

Map<Boolean, List<Order>> partitioned = smallOnly.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000));

Map<Boolean, List<Order>> grouped = smallOnly.stream()
        .collect(groupingBy(o -> o.amount() >= 1_000_000));

System.out.println(partitioned.get(true));
System.out.println(grouped.get(true));
[]
null

partitioningBy always has both true and false, even when one side is empty. groupingBy only creates a key when there’s at least one element. Calling grouped.get(true).size() here would throw a NullPointerException.

Therefore, when the criterion is true/false, use partitioningBy. The intent of your code is clearer, and you don’t have to worry about missing keys.

8. Writing Tests for Grouping Logic

Add JUnit 5 and AssertJ to your pom.xml. If your project uses Spring Boot 3.x, spring-boot-starter-test already includes both:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Put your reporting logic into a separate service to make it easier to test:

class OrderReportService {

    Map<String, Long> paidTotalByCustomer(List<Order> orders) {
        return orders.stream()
                .collect(groupingBy(Order::customer, TreeMap::new,
                        filtering(o -> o.status() == OrderStatus.PAID,
                                summingLong(Order::amount))));
    }

    Map<Boolean, Long> countBigOrders(List<Order> orders, long threshold) {
        return orders.stream()
                .collect(partitioningBy(o -> o.amount() >= threshold, counting()));
    }
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class OrderReportServiceTest {

    private final OrderReportService service = new OrderReportService();

    @Test
    void shouldSumOnlyPaidOrdersPerCustomer() {
        var result = service.paidTotalByCustomer(OrderFixtures.sample());

        assertThat(result)
                .containsExactly(
                        entry("An", 500_000L),
                        entry("Bình", 1_200_000L),
                        entry("Chi", 2_800_000L));
    }

    @Test
    void shouldAlwaysContainBothPartitions() {
        var result = service.countBigOrders(OrderFixtures.sample(), 10_000_000);

        assertThat(result)
                .containsEntry(true, 0L)
                .containsEntry(false, 6L);
    }
}

The first test can use containsExactly because the result is a TreeMap, so the order is fixed. An only gets 500,000 summed, because order O3 was cancelled. Bình only gets 1,200,000 summed, because O5 is still pending.

The second test confirms that even when no orders exceed the threshold, the true key still exists with a value of 0.

9. Common Mistakes

Null key. groupingBy doesn’t accept a classifier that returns null:

var orders = List.of(new Order("O9", "Dung", null, OrderStatus.PAID, 100_000));
orders.stream().collect(groupingBy(Order::city));
java.lang.NullPointerException: element cannot be mapped to a null key

You have two ways to handle this. The first is to replace null with a default value: groupingBy(o -> Objects.requireNonNullElse(o.city(), "Unknown")). The second is to filter out orders without a city before grouping.

Wrong return type. counting() returns Long. averagingLong returns Double. maxBy returns Optional. If you declare the wrong type, you’ll get hard-to-read compiler errors. When in doubt, use var or let the IDE infer the type.

Using groupingBy when each key has only one value. If id is unique, groupingBy(Order::id) creates a Map<String, List<Order>> with all single-element lists. In this case, use toMap(Order::id, Function.identity()).

Modifying the result Map. Javadoc doesn’t guarantee the type, mutability, or thread-safety of the Map returned from the default version. If you intend to modify the Map afterwards, pass an explicit Supplier, for example HashMap::new.

Note: With parallel streams, groupingBy must merge multiple partial Maps, so it can be slow. groupingByConcurrent writes directly to a ConcurrentHashMap and is faster. In return, it doesn’t preserve element order within each group. With sequential streams, just use groupingBy.

10. Conclusion

groupingBy groups elements by any key. partitioningBy partitions into exactly two sides by a Predicate and always returns both true and false. The real power lies in the downstream collector: counting, summingLong, mapping, filtering, collectingAndThen, or a nested groupingBy.

You should remember three points when using these two collectors:

  • Choose the appropriate Map type (TreeMap, EnumMap, LinkedHashMap) when you need order.
  • Handle null keys before grouping.
  • Prefer partitioningBy for true/false criteria.

Master these points, and most of your reporting logic will be just a few lines of easy-to-read and easy-to-test Stream code.

javastreamcollectorsgroupingByjava 21