Back to Stream: Grouping & Partitioning
Q5Question 5 of 22

Group employees by department, then collect only employee names per department using Collectors.mapping

Key Concepts
  • Collectors.mapping(mapper, downstream) transforms each element before the downstream collector stores it.
  • As a groupingBy downstream, mapping(Employee::getName, toList()) keeps the grouping on the full object but stores only the name.
  • The result type is Map<String, List<String>>: department key to a list of names.
  • Mapping the stream to names before grouping is wrong because it discards the department you still need to group by.
  • Swap the inner collector to change the shape: toCollection(TreeSet::new) gives sorted, de-duplicated names per department.
  • The classifier must never return null, or groupingBy throws a NullPointerException.
  • mapping does for a collector what Stream.map does for a stream, but it runs inside each group.

Definition

`groupingBy(dept, mapping(Employee::getName, toList()))` groups by department but stores only the names, giving a `Map<String, List<String>>`.

Collectors.mapping adapts one collector so that each element is transformed before it is stored.

  • Collectors.mapping(mapper, downstream) – applies mapper to each element and feeds the result to downstream.
  • As a groupingBy downstream – mapping(Employee::getName, toList()) converts every grouped Employee to its name and gathers the names into a list.
  • Result shape – the outer map is keyed by department String, and each value is a List of names.
  • Why it is needed – it keeps the grouping key (department) on the full object while reducing the stored value to just the name.
  • Composable – the inner collector is free, so toCollection(TreeSet::new) yields a sorted, unique set of names per group.

Priya at the National Bank of India wants, per department, only the list of staff names, not the full employee cards. As each card drops into a department tray, she writes down just the person's name on a slip and keeps one slip of names per tray. The mapping downstream is her rule: before you store it, convert the card into just a name.

Understanding the Concepts

What is Collectors.mapping?

Collectors.mapping is an adapting collector. It takes a mapping function and a downstream collector. It applies the mapper to each incoming element and then hands the mapped value to the downstream collector. It is the collector-level equivalent of a Stream.map followed by a collect, except it runs inside another collector, once per group.

The signature is Collectors.mapping(Function mapper, Collector downstream).

JAVA
package com.bank.report;

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

public class NameRoster {
    public static Map<String, List<String>> namesByDepartment(List<Employee> employees) {
        return employees.stream()
                .collect(Collectors.groupingBy(
                        Employee::getDepartment,
                        Collectors.mapping(Employee::getName, Collectors.toList())));
    }
}

Why do we need mapping instead of mapping the stream first?

If you map the stream to names before grouping, you throw away the Employee object and with it the department, so you can no longer group by department. Collectors.mapping solves this by keeping the grouping on the full Employee and only reducing to the name inside each group.

JAVA
// WRONG: once mapped to names, the department is gone, so you cannot group by it.
// This does not even compile as a one-liner, and the intent is lost.
List<String> names = employees.stream()
        .map(Employee::getName)   // Employee -> String, department discarded
        .collect(Collectors.toList());
// names is a flat list with no department information at all.
JAVA
// CORRECT: group on the full Employee, reduce to name only inside each group.
Map<String, List<String>> result = employees.stream()
        .collect(Collectors.groupingBy(
                Employee::getDepartment,
                Collectors.mapping(Employee::getName, Collectors.toList())));

How does mapping work internally?

Collectors.mapping wraps the downstream collector's accumulator. When the stream offers an element, mapping first applies the mapper, then calls the downstream accumulator with the mapped value. The identity, combiner, and finisher all come from the downstream collector unchanged.

Conceptually the accumulator is (container, element) -> downstream.accumulator().accept(container, mapper.apply(element)). Nothing about the grouping changes: each department still gets its own downstream container, and mapping simply transforms each element on the way in.

JAVA
package com.bank.report;

import java.math.BigDecimal;

public class Employee {
    private final String name;
    private final String department;
    private final String gender;
    private final BigDecimal salary;

    public Employee(String name, String department, String gender, BigDecimal salary) {
        this.name = name;
        this.department = department;
        this.gender = gender;
        this.salary = salary;
    }

    public String getName() { return name; }
    public String getDepartment() { return department; }
    public String getGender() { return gender; }
    public BigDecimal getSalary() { return salary; }
}

How do you run a full example?

Here is a complete, runnable program at the National Bank of India that builds the department-to-names roster.

JAVA
package com.bank.report;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class NameRosterDemo {
    public static void main(String[] args) {
        List<Employee> employees = List.of(
                new Employee("Rajesh", "IT", "M", new BigDecimal("1200000")),
                new Employee("Amit", "IT", "M", new BigDecimal("900000")),
                new Employee("Priya", "Finance", "F", new BigDecimal("1100000")),
                new Employee("Pankaj", "Finance", "M", new BigDecimal("1500000")));

        Map<String, List<String>> namesByDept = employees.stream()
                .collect(Collectors.groupingBy(
                        Employee::getDepartment,
                        Collectors.mapping(Employee::getName, Collectors.toList())));

        namesByDept.forEach((dept, names) ->
                System.out.println(dept + " -> " + names));
    }
}
BASH
javac com/bank/report/Employee.java com/bank/report/NameRosterDemo.java
java com.bank.report.NameRosterDemo
TEXT
Finance -> [Priya, Pankaj]
IT -> [Rajesh, Amit]

How is mapping different from Stream.map?

Both transform elements, but Stream.map runs over the whole stream before anything is grouped, while Collectors.mapping runs inside each group as part of a collector.

| Aspect | Stream.map | Collectors.mapping |
|--------|-----------|--------------------|
| Where it runs | On the stream pipeline, before collect | Inside a downstream collector, per group |
| Keeps grouping key | No, it replaces the element entirely | Yes, grouping happens on the original element |
| Typical use | Transform then collect a flat result | Transform the stored value within a group |
| Composes with groupingBy | Only before grouping (loses the key) | As the downstream of groupingBy |

JAVA
// Stream.map: flat transform, no grouping possible afterwards.
List<String> allNames = employees.stream()
        .map(Employee::getName)
        .collect(Collectors.toList());

// Collectors.mapping: transform inside each department group.
Map<String, List<String>> namesByDept = employees.stream()
        .collect(Collectors.groupingBy(
                Employee::getDepartment,
                Collectors.mapping(Employee::getName, Collectors.toList())));

How is mapping different from flatMapping?

Collectors.mapping maps one element to one value. Collectors.flatMapping (added in Java 9) maps one element to a stream of values and flattens them into the downstream, which is useful when each employee has multiple accounts or roles to collect.

JAVA
// mapping: one employee -> one name.
Map<String, List<String>> names = employees.stream()
        .collect(Collectors.groupingBy(
                Employee::getDepartment,
                Collectors.mapping(Employee::getName, Collectors.toList())));

// flatMapping: one employee -> many account numbers, flattened per department.
Map<String, List<String>> accountsByDept = employees.stream()
        .collect(Collectors.groupingBy(
                Employee::getDepartment,
                Collectors.flatMapping(e -> e.getAccountNumbers().stream(),
                        Collectors.toList())));

What are common mistakes?

The most frequent mistake is mapping before grouping and then being surprised the department is gone. A second mistake is forgetting the downstream collector argument, since mapping always needs both a mapper and a downstream.

JAVA
// WRONG: mapping needs a downstream collector, this does not compile.
// Collectors.mapping(Employee::getName)

// WRONG: grouping the names instead of the employees loses the department.
Map<String, List<String>> broken = employees.stream()
        .map(Employee::getName)
        .collect(Collectors.groupingBy(name -> name)); // keyed by name, not department

// CORRECT: both arguments present, grouping on the Employee.
Map<String, List<String>> correct = employees.stream()
        .collect(Collectors.groupingBy(
                Employee::getDepartment,
                Collectors.mapping(Employee::getName, Collectors.toList())));

What are the edge cases?

A classifier that returns null throws a NullPointerException from groupingBy. An empty input stream produces an empty outer map, not an error. If the mapper itself returns null, the null is stored in the list, which may surprise downstream readers.

JAVA
// Edge case: null department crashes grouping.
List<Employee> withNullDept = List.of(
        new Employee("Rajesh", null, "M", new BigDecimal("1200000")));
// employees.stream().collect(groupingBy(Employee::getDepartment, ...))
// throws NullPointerException because the key is null.

// Safe fix: default the key before grouping.
Map<String, List<String>> safe = withNullDept.stream()
        .collect(Collectors.groupingBy(
                e -> e.getDepartment() == null ? "UNKNOWN" : e.getDepartment(),
                Collectors.mapping(Employee::getName, Collectors.toList())));

How do you get sorted, de-duplicated names per department?

Because the inner collector is free, swap toList() for toCollection(TreeSet::new) to get names sorted alphabetically and with duplicates removed per department.

JAVA
import java.util.Set;
import java.util.TreeSet;

Map<String, Set<String>> sortedUniqueNames = employees.stream()
        .collect(Collectors.groupingBy(
                Employee::getDepartment,
                Collectors.mapping(Employee::getName,
                        Collectors.toCollection(TreeSet::new))));
TEXT
Finance -> [Pankaj, Priya]
IT -> [Amit, Rajesh]

When should you use mapping, and when not?

Use Collectors.mapping whenever you must group by one property but store a different, derived value per group. Do not use it when you actually need the whole element in each group, since plain groupingBy already gives Map>. Do not use it to transform the entire stream when grouping is irrelevant, because Stream.map is clearer there.

JAVA
// Use mapping: group by department, store only names.
Map<String, List<String>> names = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                Collectors.mapping(Employee::getName, Collectors.toList())));

// Do NOT use mapping: you want the whole Employee per group.
Map<String, List<Employee>> fullGroups = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment));

What are common interview follow-up questions?

Interviewers often ask how mapping differs from map, why mapping before grouping loses the key, what happens on a null classifier, and how to change the per-group collection type. A strong answer mentions that mapping composes with any downstream, including nested groupingBy, counting, or joining, and that the finisher of the downstream still runs, so mapping(Employee::getName, Collectors.joining(", ")) would give a comma-joined name string per department.

JAVA
// Follow-up: a joined name string per department.
Map<String, String> joinedNames = employees.stream()
        .collect(Collectors.groupingBy(
                Employee::getDepartment,
                Collectors.mapping(Employee::getName,
                        Collectors.joining(", "))));
TEXT
Finance -> Priya, Pankaj
IT -> Rajesh, Amit

Visual Overview

DIAGRAM
  List<Employee>
       │
       │  groupingBy(Employee::getDepartment, ...)
       ▼
  ┌─────────────────────────────────────────────┐
  │  outer map keyed by department String         │
  │                                               │
  │   "IT" ───────────┐        "Finance" ──────┐  │
  │                   ▼                         ▼  │
  │   mapping(getName, toList())  mapping(getName, toList())
  │        │                          │           │
  │        ▼                          ▼           │
  │  ["Rajesh", "Amit"]      ["Priya", "Pankaj"]  │
  └─────────────────────────────────────────────┘
       │
       ▼
  Map<String, List<String>>
DIAGRAM
  How one element flows through mapping:

  Employee(Rajesh, IT)
       │
       │  classifier -> key "IT"
       ▼
  select the "IT" downstream container
       │
       │  mapper: Employee -> "Rajesh"
       ▼
  downstream.accumulate(container, "Rajesh")
       │
       ▼
  "IT" list now contains "Rajesh"

Key Takeaway

When Priya at the National Bank of India needs a department roster that prints only staff names under each department heading, she reaches for groupingBy with a Collectors.mapping(Employee::getName, Collectors.toList()) downstream. mapping is an adapting collector: it applies a mapper to each element before handing it to the downstream, so inside each department group every Employee is converted to its name and the names are gathered into a list, producing a Map> of department to a list of names.

The insight to always explain is why you do not simply map the stream to names before grouping. If you map Employees to names first, you throw away the department, and then you cannot group by it. Collectors.mapping solves exactly this: it keeps the grouping on the full Employee object and only reduces to the name inside each group.

The pattern is composable, which is its real strength. Swap toList() for toCollection(TreeSet::new) and you get sorted, de-duplicated names per department. Swap it for joining(", ") and you get a single comma-joined name string per department. The grouping stays the same while the stored shape changes.

The senior points mirror those of plain grouping: the classifier must never return null or grouping throws, and an empty stream gives an empty map rather than an error. Guard a possibly-null department by defaulting it to a placeholder like "UNKNOWN" before grouping.

Remember one sentence: Collectors.mapping lets you group by one thing while storing another, by transforming each element inside the group instead of before it.

---

Share
Use ← → arrow keys to navigate