Back to Collectors
Q14Question 14 of 21

Collect the distinct set of cities per department using groupingBy with mapping to a Set

Key Concepts
  • groupingBy(classifier, downstream) groups elements by a key and applies a downstream collector per group
  • mapping(mapper, downstream) transforms each element before it reaches the downstream collector
  • groupingBy(Employee::getDepartment, mapping(Employee::getCity, toSet())) yields Map<String, Set<String>>
  • toSet as the innermost collector collapses duplicate cities so each appears once per department
  • Collectors compose like building blocks: classifier picks the key, mapping picks the value, inner collector picks how to gather
  • Swapping toSet for toList keeps duplicates and order, so the innermost collector encodes the business rule
  • mapping on its own is rarely useful; it shines nested inside groupingBy or partitioningBy

Definition

`groupingBy(dept, mapping(getCity, toSet()))` groups by department and keeps only the distinct cities per group.

Nested collectors let one pass express a multi-level rule.

  • groupingBy(classifier, downstream) – splits elements into groups by the classifier key and runs the downstream collector on each group's elements.
  • mapping(mapper, downstream) – an adapting collector that applies the mapper to each element, then feeds the result into its own downstream collector.
  • toSet() – gathers values into a Set, so duplicates collapse and each value appears once.
  • Composed together – groupingBy(Employee::getDepartment, mapping(Employee::getCity, toSet())) produces a Map> of distinct cities per department.
  • Building blocks – each collector decides one thing: the key, the per-element value, or how values are gathered.

Rajesh sorts employee files into department trays at the National Bank of India. He does not want to keep whole files in each tray; he only wants the list of distinct cities. mapping pulls out just the city from each file as it drops into the tray, and the Set makes sure each city is listed once even if ten colleagues share it.

Understanding the Concepts

What is the mapping collector?

mapping(mapper, downstream) is an adapting collector. It applies the mapper function to each element and feeds the transformed result into the downstream collector. On its own it is rarely used because you would usually just call map on the stream. Its real power appears when it is nested inside another collector such as groupingBy, where it changes what value gets collected per group.

JAVA
package com.bank.report;

import java.util.List;
import java.util.Set;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toSet;

public class MappingBasics {
    record Employee(String name, String department, String city) {}

    public static void main(String[] args) {
        List<Employee> staff = List.of(
            new Employee("Rajesh", "Trading", "Mumbai"),
            new Employee("Priya", "Trading", "Mumbai")
        );

        // mapping alone: extract city, collect into a Set
        Set<String> cities = staff.stream()
            .collect(mapping(Employee::city, toSet()));
        System.out.println(cities); // [Mumbai]
    }
}
TEXT
[Mumbai]

How do you collect the distinct set of cities per department?

Nest mapping inside groupingBy. The classifier Employee::getDepartment decides the map key, the mapping extracts the city from each employee in that group, and toSet() keeps only distinct cities.

JAVA
package com.bank.report;

import java.util.List;
import java.util.Map;
import java.util.Set;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toSet;

public class CitiesPerDepartment {
    record Employee(String name, String department, String city) {}

    public static void main(String[] args) {
        List<Employee> staff = List.of(
            new Employee("Rajesh", "Trading", "Mumbai"),
            new Employee("Priya", "Trading", "Bangalore"),
            new Employee("Amit", "Trading", "Mumbai"),
            new Employee("Sunita", "Risk", "Delhi")
        );

        Map<String, Set<String>> citiesByDept = staff.stream()
            .collect(groupingBy(
                Employee::department,
                mapping(Employee::city, toSet())));

        System.out.println(citiesByDept);
    }
}
TEXT
{Risk=[Delhi], Trading=[Mumbai, Bangalore]}

The two Trading employees in Mumbai collapse to a single Mumbai entry because the innermost collector is a Set.

How do nested collectors compose, level by level?

Think of three independent decisions. The classifier picks the key. The mapping picks what value each element contributes. The innermost collector picks how those values are gathered. Each is a swappable block.

JAVA
// key        = department   (classifier)
// value      = city         (mapping mapper)
// gather     = distinct Set  (innermost collector)
Map<String, Set<String>> distinctCities = staff.stream()
    .collect(groupingBy(Employee::department,
             mapping(Employee::city, toSet())));

// Swap only the innermost block to keep duplicates and order
Map<String, List<String>> allCities = staff.stream()
    .collect(groupingBy(Employee::department,
             mapping(Employee::city, java.util.stream.Collectors.toList())));

System.out.println(distinctCities); // {Risk=[Delhi], Trading=[Mumbai, Bangalore]}
System.out.println(allCities);      // {Risk=[Delhi], Trading=[Mumbai, Bangalore, Mumbai]}

Swapping toSet for toList changed the business meaning from distinct cities to every city including repeats, without touching the grouping logic.

How is toSet different from toList as the downstream?

The innermost collector encodes the rule. toSet means distinct and unordered; toList means every occurrence in encounter order.

| Downstream | Value type | Keeps duplicates? | Keeps order? | Business meaning |
|------------|-----------|-------------------|--------------|------------------|
| toSet() | Set | no | no (HashSet) | distinct cities per department |
| toList() | List | yes | yes | one entry per employee's city |
| counting() | Long | n/a | n/a | how many employees per department |
| joining(", ") | String | yes | yes | comma-joined city names |

JAVA
Map<String, Long> headcount = staff.stream()
    .collect(groupingBy(Employee::department, java.util.stream.Collectors.counting()));
System.out.println(headcount); // {Risk=1, Trading=3}

Why not just map on the stream before grouping?

If you map the stream to cities first, you lose the department key needed to group. mapping lives inside groupingBy so the element still carries its department when the classifier reads it, and only then is the value reduced to a city.

JAVA
// WRONG: mapping to city on the stream destroys the department information
// staff.stream().map(Employee::city).collect(groupingBy(???)) // no department left

// CORRECT: group by the full element, then map inside the group
Map<String, Set<String>> ok = staff.stream()
    .collect(groupingBy(Employee::department, mapping(Employee::city, toSet())));
System.out.println(ok);
TEXT
{Risk=[Delhi], Trading=[Mumbai, Bangalore]}

What are common mistakes with mapping and groupingBy?

A frequent mistake is forgetting the downstream collector inside mapping, or expecting mapping alone to group.

JAVA
// WRONG: mapping needs a downstream collector; this does not compile
// staff.stream().collect(groupingBy(Employee::department, mapping(Employee::city)));

// WRONG: using toMap when a key repeats, causing IllegalStateException
// staff.stream().collect(Collectors.toMap(Employee::department, Employee::city));
//   -> duplicate key Trading
JAVA
// CORRECT: mapping always takes (mapper, downstream)
Map<String, Set<String>> ok = staff.stream()
    .collect(groupingBy(Employee::department, mapping(Employee::city, toSet())));
System.out.println(ok);
TEXT
{Risk=[Delhi], Trading=[Mumbai, Bangalore]}

What are the edge cases: empty groups and null values?

groupingBy never creates empty groups: a department key appears only if at least one employee has it. A null city would be inserted into the Set as a null element, and a null classifier key throws a NullPointerException in the standard groupingBy.

JAVA
package com.bank.report;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.HashSet;
import java.util.Arrays;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toSet;

public class EdgeCases {
    record Employee(String name, String department, String city) {}

    public static void main(String[] args) {
        // null city is allowed by HashSet via toSet
        List<Employee> staff = Arrays.asList(
            new Employee("Rajesh", "Trading", "Mumbai"),
            new Employee("Priya", "Trading", null)
        );
        Map<String, Set<String>> result = staff.stream()
            .collect(groupingBy(Employee::department, mapping(Employee::city, toSet())));
        System.out.println(result); // {Trading=[null, Mumbai]}

        Set<String> expected = new HashSet<>(Arrays.asList("Mumbai", null));
        System.out.println(result.get("Trading").equals(expected)); // true
    }
}
TEXT
{Trading=[null, Mumbai]}
true

How do you control the map type and the set type?

The three-argument groupingBy lets you choose the map implementation, for example a TreeMap for sorted keys, and the downstream can use toCollection to choose a specific set such as a TreeSet for sorted, distinct cities.

JAVA
package com.bank.report;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toCollection;

public class ControlTypes {
    record Employee(String name, String department, String city) {}

    public static void main(String[] args) {
        List<Employee> staff = List.of(
            new Employee("Rajesh", "Trading", "Mumbai"),
            new Employee("Priya", "Trading", "Bangalore"),
            new Employee("Sunita", "Risk", "Delhi")
        );

        Map<String, Set<String>> sorted = staff.stream()
            .collect(groupingBy(
                Employee::department,
                TreeMap::new,
                mapping(Employee::city, toCollection(TreeSet::new))));

        System.out.println(sorted);
    }
}
TEXT
{Risk=[Delhi], Trading=[Bangalore, Mumbai]}

Now both the department keys and the cities within each group are sorted.

When should you use mapping-into-toSet versus other downstreams?

Use mapping-into-toSet when the business rule is distinct values per group: unique cities, unique product codes, unique customer ids. Use mapping-into-toList when every occurrence matters. Use counting for a tally and joining for a formatted string. Choose the innermost collector to match the rule, because changing it changes the meaning while the grouping stays the same.

JAVA
// distinct cities per department
groupingBy(Employee::department, mapping(Employee::city, toSet()));
// how many employees per department
groupingBy(Employee::department, java.util.stream.Collectors.counting());
// comma-joined city names per department
groupingBy(Employee::department, mapping(Employee::city, java.util.stream.Collectors.joining(", ")));

What are common interview follow-up questions?

Expect: What does mapping do and why nest it in groupingBy? Answer: it transforms each element before the downstream collector, and nesting keeps the grouping key intact while reducing the value. How do you get a Map>? Answer: groupingBy on department with mapping of city into toSet. How would you sort the keys and the sets? Answer: use the three-arg groupingBy with TreeMap and a toCollection of TreeSet downstream. What changes if you use toList instead of toSet? Answer: duplicates and encounter order are kept, changing the business meaning from distinct to all occurrences.

Visual Overview

DIAGRAM
NESTED COLLECTORS COMPOSE AS BUILDING BLOCKS

          groupingBy( classifier , mapping( mapper , downstream ) )
                          │                 │             │
                          ▼                 ▼             ▼
                   pick the KEY       pick the VALUE   pick HOW to
                   (department)       (city)           GATHER (toSet)


FLOW FOR ONE GROUP (Trading)

  employees in Trading           mapping(getCity)         toSet()
 ┌───────────────────────┐     ┌────────────────┐     ┌──────────────┐
 │ Rajesh  -> Mumbai      │────▶│ Mumbai         │────▶│              │
 │ Priya   -> Bangalore   │────▶│ Bangalore      │────▶│ {Mumbai,     │
 │ Amit    -> Mumbai      │────▶│ Mumbai         │────▶│  Bangalore}  │
 └───────────────────────┘     └────────────────┘     └──────────────┘
                                                        duplicate Mumbai
                                                        collapses to one


RESULT: Map<String, Set<String>>
 ┌──────────────────────────────────────────┐
 │ Trading  -> { Mumbai, Bangalore }          │
 │ Risk     -> { Delhi }                       │
 └──────────────────────────────────────────┘

   swap toSet -> toList  ==>  Trading -> [Mumbai, Bangalore, Mumbai]

Key Takeaway

The mapping collector transforms each element before it reaches a downstream collector, and it becomes powerful when nested inside groupingBy. By writing groupingBy of department with a downstream of mapping of city into toSet, you group employees by department and, within each group, keep only the distinct cities rather than the whole employee objects. The toSet collapses duplicate cities so each appears once per department.

In our work at the National Bank of India, Rajesh planned regional training and needed, for each department, the unique set of cities where its staff were based so the bank could book venues. If two Trading colleagues were both in Mumbai he did not want Mumbai listed twice, and the mapping-into-Set downstream gave him exactly the distinct cities per department in a single collect call.

The point to always make is that collectors compose like building blocks, and each block expresses one part of the business rule. The classifier chooses the key, mapping chooses what value each element contributes, and the innermost collector chooses how the values are gathered: toSet for distinctness, toList to keep duplicates and order, counting for a tally, joining for a string. Changing just the innermost collector changes the business meaning without touching the grouping logic.

When you also need sorted output, the three-argument groupingBy lets you pick a TreeMap for the keys and a toCollection of TreeSet for the values, so both the departments and the cities come out in order. That same compose-the-blocks thinking scales from a two-level rule to deeper ones.

Remember one sentence: the classifier picks the key, mapping picks the value, and the innermost collector picks how to gather, so distinct-cities-per-department is just groupingBy plus mapping plus toSet.

---

Share
Use ← → arrow keys to navigate