> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Use flatMap in Java to Flatten Nested Lists
- URL: https://www.ggorantala.dev/how-to-use-flatmap-java/
- Published: 2026-06-01T05:15:00.000Z
- Updated: 2026-10-31T06:33:10.000Z
- Description: Flatten students, courses and assignments with flatMap, flatMapToInt, Optional.stream and mapMulti on Java 21. 22 checks, forced errors and the fixes included.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, java-streams, java-collections, java-tutorials

## Ten lines that do the whole job

Here's the pipeline this article builds up to. It takes students who each have courses who each have graded assignments, and turns the three levels into one flat list of rows:

```java
List<Row> rows = students.stream()
    .flatMap(s -> s.courses().stream()
        .flatMap(c -> c.assignments().stream()
            .map(a -> new Row(s.name(), c.code(), a.title(), a.score()))))
    .toList();

```

And this is what it prints for four sample students:

```log
row            : Row[student=Asha, course=MATH101, title=quiz1, score=80]
row            : Row[student=Asha, course=MATH101, title=midterm, score=92]
row            : Row[student=Asha, course=CS101, title=lab1, score=95]
row            : Row[student=Ben, course=CS101, title=lab1, score=70]
row            : Row[student=Ben, course=CS101, title=lab2, score=75]
row            : Row[student=Chen, course=MATH101, title=quiz1, score=60]

```

If you've ever written a stream, seen `Stream<List<Course>>` come out of it and thought "I wanted the courses, not the lists", this is how to use flatMap in Java to fix that. You'll use it on plain lists, on arrays, on `Optional`, on strings split into words, and inside a `groupingBy`. You'll also see the places where it behaves in ways the tutorials skip.

I'm assuming you can already filter and map a stream. If not, start with [How to filter and transform a list with Java streams](https://ggorantala.dev/how-to-filter-transform-list-java-streams/?ref=ggorantala.dev). If you want the next step after this one, [How to collect stream results in Java](https://ggorantala.dev/how-to-collect-stream-results-java/?ref=ggorantala.dev) covers `groupingBy` and friends in depth; I only use them lightly here.

The data flows like this:

```log
Stream<Student> ──flatMap──▶ Stream<Course> ──flatMap──▶ Stream<Assignment> ──map──▶ Stream<Row>
   4 elements                  5 elements                    6 elements               6 elements

```

I built this on JDK 21 from a terminal. The snag that cost me the most was `Stream<int[]>`: the obvious method reference doesn't compile, and the error message points at the wrong thing. That's in step 9 and again in the troubleshooting section.

## Check your setup

| Tool          | Version I used                                              | Check it with            |
| ------------- | ----------------------------------------------------------- | ------------------------ |
| JDK           | OpenJDK 21.0.12.1                                           | java -version            |
| javac         | 21.0.12.1                                                   | javac -version           |
| IntelliJ IDEA | 2026.2.x (menu paths from JetBrains docs; I didn't open it) | Help → About             |
| Eclipse IDE   | 2026-06 (menu paths from Eclipse docs; not opened)          | Help → About Eclipse IDE |

No Maven, no Gradle, no libraries. Everything here is in the JDK. Maven Central refused my connection from the build environment again, so the checks are plain `main` methods that print `PASS` or `FAIL` rather than JUnit tests. They assert the same things. When you have JUnit 5 on your classpath, each `check(name, condition)` becomes an `@Test` with `assertTrue`; I'll write that up in the [JUnit 5 article](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) (not published yet).

You need Java 16 or newer for the records and for `mapMulti`. Check what you have:

```bash
java -version

```

## Scaffold the gradebook in a terminal, IntelliJ IDEA or Eclipse

On macOS and Linux:

```bash
mkdir -p grades/src/main/java/dev/ggorantala/grades
mkdir -p grades/src/test/java/dev/ggorantala/grades
cd grades

```

On Windows PowerShell, use `New-Item -ItemType Directory -Force` with the same paths.

```log
grades/
├── src/
│   ├── main/java/dev/ggorantala/grades/
│   │   ├── Assignment.java
│   │   ├── Course.java
│   │   ├── GradeReports.java
│   │   ├── Main.java
│   │   ├── SampleData.java
│   │   └── Student.java
│   └── test/java/dev/ggorantala/grades/
│       └── Checks.java
└── out/

```

### In IntelliJ IDEA

File → New → Project, choose **Java**, build system **IntelliJ**, JDK 21, name it `grades`. Then right-click `src` and create the package `dev.ggorantala.grades`. This is from JetBrains' docs; I didn't click through the wizard, so trust your screen if a label differs.

### In Eclipse

File → New → Java Project, name `grades`, execution environment JavaSE-21, then File → New → Package. Same caveat: from the docs, not executed.

### With Maven or Gradle

There's nothing to add because there are no dependencies. Set `maven.compiler.release` to 21 in a `pom.xml`, or use a Gradle Java toolchain of 21\. I didn't run either one for this article, so I'm not showing output for them.

## How to use flatMap in Java: ten steps from one level to three

Each step adds one method to `GradeReports`. Compile and run whenever you like; the full output is in the "See it working" section.

### Step 1: Define students, courses and assignments

Goal: three small records, nested the way real data is.

`src/main/java/dev/ggorantala/grades/Assignment.java`

```java
package dev.ggorantala.grades;

/** One graded piece of work. */
public record Assignment(String title, int score) {
}

```

`src/main/java/dev/ggorantala/grades/Course.java`

```java
package dev.ggorantala.grades;

import java.util.List;

/** A course a student is enrolled in, with the work graded so far. */
public record Course(String code, List<Assignment> assignments) {
}

```

`src/main/java/dev/ggorantala/grades/Student.java`

```java
package dev.ggorantala.grades;

import java.util.List;

public record Student(String name, List<Course> courses) {
}

```

Records give you accessors like `courses()` and `equals` for free; [How to use Java records](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev) explains why that matters, and the equality half is in [How to implement equals() and hashCode()](https://ggorantala.dev/how-to-implement-equals-hashcode-java/?ref=ggorantala.dev).

Now the data. Dev has no courses, and Ben's ART200 has no assignments yet. I put those in on purpose, because empty inner lists are where flattening gets interesting. The seeded generator is for the checks later.

`src/main/java/dev/ggorantala/grades/SampleData.java`

```java
package dev.ggorantala.grades;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;

public final class SampleData {

    private SampleData() { }

    /** Four students, small enough to check by eye. Dev has no courses yet. */
    public static List<Student> small() {
        return List.of(
            new Student("Asha", List.of(
                new Course("MATH101", List.of(new Assignment("quiz1", 80), new Assignment("midterm", 92))),
                new Course("CS101", List.of(new Assignment("lab1", 95))))),
            new Student("Ben", List.of(
                new Course("CS101", List.of(new Assignment("lab1", 70), new Assignment("lab2", 75))),
                new Course("ART200", List.of()))),
            new Student("Chen", List.of(
                new Course("MATH101", List.of(new Assignment("quiz1", 60))))),
            new Student("Dev", List.of()));
    }

    /** Seeded random students for comparing flatMap pipelines with plain loops. */
    public static List<Student> generated(int n, long seed) {
        String[] codes = {"MATH101", "CS101", "ART200", "BIO150", "HIST110"};
        Random r = new Random(seed);
        List<Student> out = new ArrayList<>(n);
        for (int i = 0; i < n; i++) {
            List<Course> courses = new ArrayList<>();
            int cc = r.nextInt(4);                      // 0..3 courses, so empty lists happen
            for (int c = 0; c < cc; c++) {
                List<Assignment> as = new ArrayList<>();
                int ac = r.nextInt(4);                  // 0..3 assignments
                for (int a = 0; a < ac; a++) {
                    as.add(new Assignment("a" + a, r.nextInt(101)));
                }
                courses.add(new Course(codes[r.nextInt(codes.length)], as));
            }
            out.add(new Student("S" + i, courses));
        }
        return out;
    }

    /** A pretend student directory: find() returns an Optional. */
    public static Optional<Student> find(List<Student> all, String name) {
        return all.stream().filter(s -> s.name().equals(name)).findFirst();
    }

}

```

Checkpoint: nothing to run yet.

### Step 2: See what map() gives you

Goal: understand the problem before fixing it.

`src/main/java/dev/ggorantala/grades/GradeReports.java` (start of the file; the rest arrives step by step)

```java
package dev.ggorantala.grades;

import java.util.Arrays;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Collectors;

public final class GradeReports {

    private GradeReports() { }

    /** Step 2: what map() gives you. A stream of lists, not of courses. */
    public static List<List<Course>> nestedWithMap(List<Student> students) {
        return students.stream().map(Student::courses).toList();
    }

```

`map` turns each student into exactly one thing. Here that thing is a `List<Course>`, so you get a `List<List<Course>>`. In the run below the sizes of those inner lists come out as `[2, 2, 1, 0]`: four students, four lists, one of them empty. That's the shape `flatMap` exists to remove.

### Step 3: Flatten one level

Goal: a plain list of every course across all students.

```java
    /** Step 3: flatMap flattens one level. */
    public static List<Course> allCourses(List<Student> students) {
        return students.stream().flatMap(s -> s.courses().stream()).toList();
    }

```

The function you give `flatMap` must return a **stream**, not a list. For each student it returns that student's courses as a stream, and `flatMap` pours all those streams into one. Dev's empty stream contributes nothing and doesn't need special handling. That's five courses where `map` left you with four lists.

Checkpoint: expected `flatMap count : 5 courses`.

### Step 4: Go two levels deep, then clean up

Goal: the distinct course codes, sorted.

```java
    /** Step 4: two levels, then distinct and sorted. */
    public static List<String> courseCodes(List<Student> students) {
        return students.stream()
            .flatMap(s -> s.courses().stream())
            .map(Course::code)
            .distinct()
            .sorted()
            .toList();
    }

```

Once the stream is flat you're back to ordinary operations. `map` picks the code, `distinct` removes repeats, `sorted` orders them. If the sorting here feels thin and you want to chain several keys, [How to implement Comparable and Comparator](https://ggorantala.dev/how-to-implement-comparable-comparator-java/?ref=ggorantala.dev) is the place.

Checkpoint: `course codes : [ART200, CS101, MATH101]`. ART200 is in the list even though it has no assignments, because we only flattened as far as courses.

### Step 5: Keep the outer value in scope

Goal: one row per assignment, but each row still knows its student and course.

This is the step that trips people. Flatten everything first and the student is gone; you can't write `s.name()` later because `s` doesn't exist outside the lambda that created it. The fix is to nest: put the inner `map` inside the `flatMap` lambda, where `s` and `c` are still visible.

```java
    /** One flat row per graded assignment, keeping the student and course it came from. */
    public record Row(String student, String course, String title, int score) { }

    /** Step 5: flatMap keeps the outer value in scope by nesting the inner map(). */
    public static List<Row> rows(List<Student> students) {
        return students.stream()
            .flatMap(s -> s.courses().stream()
                .flatMap(c -> c.assignments().stream()
                    .map(a -> new Row(s.name(), c.code(), a.title(), a.score()))))
            .toList();
    }

```

Read it from the inside out. The innermost `map` builds a `Row` from an assignment, with `s` and `c` captured from the lambdas around it. The middle `flatMap` pours a course's rows into one stream. The outer one does the same for a student. Three levels, three parentheses at the end, one `.toList()`.

Checkpoint: six `row` lines. Ben's ART200 and the whole of Dev are absent, since they have nothing to turn into rows.

### Step 6: Write the same thing with mapMulti

Goal: see the Java 16 alternative, and when it's worth having.

```java
    /** Step 6: the same rows with mapMulti (Java 16). */
    public static List<Row> rowsMapMulti(List<Student> students) {
        return students.stream().<Row>mapMulti((s, sink) -> {
            for (Course c : s.courses()) {
                for (Assignment a : c.assignments()) {
                    sink.accept(new Row(s.name(), c.code(), a.title(), a.score()));
                }
            }
        }).toList();
    }

```

`mapMulti` hands you each element and a `sink`. You call `sink.accept(...)` zero or more times. No inner streams are created, which is the whole point. The `<Row>` in front of `mapMulti` is the type witness. Leave it off and the code compiles, but the result is `List<Object>`. I checked: assigning it to `List<String>` gives `incompatible types: List<Object> cannot be converted to List<String>`.

Checkpoint: `mapMulti same : true`.

### Step 7: Summarise scores with flatMapToInt

Goal: min, average and max across every assignment.

```java
    /** Step 7: flatMapToInt gives an IntStream, so summaryStatistics() is free. */
    public static IntSummaryStatistics scoreStats(List<Student> students) {
        return students.stream()
            .flatMap(s -> s.courses().stream())
            .flatMapToInt(c -> c.assignments().stream().mapToInt(Assignment::score))
            .summaryStatistics();
    }

```

`flatMap` has primitive cousins: `flatMapToInt`, `flatMapToLong`, `flatMapToDouble`. Your function returns an `IntStream` (note `mapToInt` inside), and you get an `IntStream` out, with no boxing. That's what gives you `summaryStatistics()`.

Checkpoint: `score stats : n=6 min=60 avg=78.67 max=95`.

### Step 8: Flatten Optionals, and chain them

Goal: look up students by name and drop the ones that don't exist.

```java
    /** Step 8a: names to students; unknown names disappear. */
    public static List<Student> lookup(List<Student> all, List<String> names) {
        return names.stream()
            .map(n -> SampleData.find(all, n))
            .flatMap(Optional::stream)
            .toList();
    }

    /** Step 8b: Optional.flatMap, for when the next step also returns an Optional. */
    public static Optional<Integer> topScore(List<Student> all, String name) {
        return SampleData.find(all, name).flatMap(s -> s.courses().stream()
            .flatMap(c -> c.assignments().stream())
            .map(Assignment::score)
            .max(Integer::compare));
    }

```

Two different `flatMap`s live here. In `lookup`, `map` produces a `Stream<Optional<Student>>`, and `flatMap(Optional::stream)` turns each `Optional` into a stream of zero or one element, so the empty ones vanish. `Optional.stream()` is Java 9\. In `topScore`, it's `Optional.flatMap`, a method on `Optional` itself. You use it when the function you're applying already returns an `Optional` (`max` does) and you don't want an `Optional<Optional<Integer>>`. If `Optional` is new to you, read [How to use Optional correctly](https://ggorantala.dev/how-to-use-optional-java/?ref=ggorantala.dev) first.

Checkpoint:

```log
lookup         : [Ben, Asha]
top Asha       : Optional[95]
top Dev        : Optional.empty
top Zed        : Optional.empty

```

Dev exists but has no scores. Zed doesn't exist. Both give an empty `Optional`, and you can't tell them apart from the result alone. If the caller needs to know why, return something richer.

### Step 9: Flatten inside a collector, and split strings

Goal: the average score per student, and a word count over feedback comments.

```java
    /** Step 9a: Collectors.flatMapping inside a groupingBy. */
    public static Map<String, Double> averagePerStudent(List<Student> students) {
        return students.stream().collect(Collectors.groupingBy(
            Student::name, TreeMap::new,
            Collectors.flatMapping(
                s -> s.courses().stream().flatMap(c -> c.assignments().stream()),
                Collectors.averagingInt(Assignment::score))));
    }

    /** Step 9b: splitting strings into words. */
    public static Map<String, Long> wordCounts(List<String> comments) {
        return comments.stream()
            .flatMap(c -> Arrays.stream(c.toLowerCase().split("\\s+")))
            .filter(w -> !w.isBlank())
            .collect(Collectors.groupingBy(w -> w, TreeMap::new, Collectors.counting()));
    }

    /** Used by the checks: distinct codes as a sorted set, built with a loop. */
    static TreeSet<String> codesByLoop(List<Student> students) {
        TreeSet<String> out = new TreeSet<>();
        for (Student s : students) for (Course c : s.courses()) out.add(c.code());
        return out;
    }
}

```

`Collectors.flatMapping` (Java 9) is `flatMap` for a downstream collector. The `groupingBy` still has one bucket per student, so each student stays one entry in the map. Inside each bucket, `flatMapping` flattens that student's courses into assignments before `averagingInt` sees them. Had you used a plain `flatMap` before collecting, you'd have lost which assignment belonged to whom.

For strings, `String.split` returns an array, `Arrays.stream` turns it into a stream, and `flatMap` merges the lot. The `isBlank` filter is there because an empty comment splits into one empty string, not zero strings. The checks below prove it.

One thing I only noticed in the output: Dev's average is `0.0`, not missing. `averagingInt` returns 0 for no input. If "no scores yet" and "scored zero" mean different things in your report, don't use it as it stands.

### Step 10: Print everything

`src/main/java/dev/ggorantala/grades/Main.java`

```java
package dev.ggorantala.grades;

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Student> students = SampleData.small();

        System.out.println("map()          : " + GradeReports.nestedWithMap(students).stream().map(List::size).toList() + " (sizes of the lists)");
        System.out.println("flatMap count  : " + GradeReports.allCourses(students).size() + " courses");
        System.out.println("course codes   : " + GradeReports.courseCodes(students));
        GradeReports.rows(students).forEach(r -> System.out.println("row            : " + r));
        System.out.println("mapMulti same  : " + GradeReports.rowsMapMulti(students).equals(GradeReports.rows(students)));
        var st = GradeReports.scoreStats(students);
        System.out.printf("score stats    : n=%d min=%d avg=%.2f max=%d%n", st.getCount(), st.getMin(), st.getAverage(), st.getMax());
        System.out.println("lookup         : " + GradeReports.lookup(students, List.of("Ben", "Zed", "Asha")).stream().map(Student::name).toList());
        System.out.println("top Asha       : " + GradeReports.topScore(students, "Asha"));
        System.out.println("top Dev        : " + GradeReports.topScore(students, "Dev"));
        System.out.println("top Zed        : " + GradeReports.topScore(students, "Zed"));
        System.out.println("average/student: " + GradeReports.averagePerStudent(students));
        System.out.println("word counts    : " + GradeReports.wordCounts(List.of("Great lab work", "great  effort", "")));
    }
}

```

## See it working

From the `grades` folder:

```bash
mkdir -p out
javac --release 21 -d out $(find src/main -name '*.java')
java -cp out dev.ggorantala.grades.Main

```

On Windows PowerShell, pass the file list from `Get-ChildItem -Recurse -Filter *.java src/main | ForEach-Object FullName`. In IntelliJ, right-click `Main` and choose Run; in Eclipse, Run As → Java Application.

```log
map()          : [2, 2, 1, 0] (sizes of the lists)
flatMap count  : 5 courses
course codes   : [ART200, CS101, MATH101]
row            : Row[student=Asha, course=MATH101, title=quiz1, score=80]
row            : Row[student=Asha, course=MATH101, title=midterm, score=92]
row            : Row[student=Asha, course=CS101, title=lab1, score=95]
row            : Row[student=Ben, course=CS101, title=lab1, score=70]
row            : Row[student=Ben, course=CS101, title=lab2, score=75]
row            : Row[student=Chen, course=MATH101, title=quiz1, score=60]
mapMulti same  : true
score stats    : n=6 min=60 avg=78.67 max=95
lookup         : [Ben, Asha]
top Asha       : Optional[95]
top Dev        : Optional.empty
top Zed        : Optional.empty
average/student: {Asha=89.0, Ben=72.5, Chen=60.0, Dev=0.0}
word counts    : {effort=1, great=2, lab=1, work=1}

```

Check two lines by hand. Asha's average is (80 + 92 + 95) / 3 = 89.0\. Ben's is (70 + 75) / 2 = 72.5\. If yours match, the hard part's done.

## Prove each pipeline against a loop

A `flatMap` chain looks right when it's wrong. The way I trust one is to write the nested `for` loops for the same data and compare. The checks run both on 2,000 seeded students, where 0 to 3 courses and 0 to 3 assignments per course mean plenty of empty lists.

`src/test/java/dev/ggorantala/grades/Checks.java`

```java
package dev.ggorantala.grades;

import java.util.*;
import java.util.stream.*;

public class Checks {
    static int passed = 0, failed = 0;

    static void check(String name, boolean ok) {
        if (ok) { passed++; System.out.println("PASS  " + name); }
        else    { failed++; System.out.println("FAIL  " + name); }
    }

    public static void main(String[] args) {
        List<Student> small = SampleData.small();
        List<Student> big = SampleData.generated(2000, 7L);

        // loop versions
        int courseCount = 0, scoreSum = 0, scoreN = 0;
        List<GradeReports.Row> loopRows = new ArrayList<>();
        for (Student s : big) {
            for (Course c : s.courses()) {
                courseCount++;
                for (Assignment a : c.assignments()) {
                    scoreSum += a.score(); scoreN++;
                    loopRows.add(new GradeReports.Row(s.name(), c.code(), a.title(), a.score()));
                }
            }
        }
        check("allCourses count matches loop", GradeReports.allCourses(big).size() == courseCount);
        check("courseCodes matches loop", GradeReports.courseCodes(big).equals(new ArrayList<>(GradeReports.codesByLoop(big))));
        check("rows equals loop rows (same order)", GradeReports.rows(big).equals(loopRows));
        check("mapMulti rows equal flatMap rows", GradeReports.rowsMapMulti(big).equals(GradeReports.rows(big)));
        var st = GradeReports.scoreStats(big);
        check("scoreStats count/sum match loop", st.getCount() == scoreN && st.getSum() == scoreSum);

        check("map() keeps empty lists as elements", GradeReports.nestedWithMap(small).size() == 4);
        check("flatMap drops empty lists", GradeReports.allCourses(small).size() == 5);
        check("student with no courses adds no rows", GradeReports.rows(small).stream().noneMatch(r -> r.student().equals("Dev")));
        check("course with no assignments adds no rows", GradeReports.rows(small).stream().noneMatch(r -> r.course().equals("ART200")));

        check("lookup skips unknown names", GradeReports.lookup(small, List.of("Ben", "Zed", "Asha")).size() == 2);
        check("lookup keeps input order", GradeReports.lookup(small, List.of("Chen", "Asha")).get(0).name().equals("Chen"));
        check("topScore Asha = 95", GradeReports.topScore(small, "Asha").equals(Optional.of(95)));
        check("topScore of student with no scores is empty", GradeReports.topScore(small, "Dev").isEmpty());
        check("topScore of unknown student is empty", GradeReports.topScore(small, "Zed").isEmpty());

        check("averagePerStudent Ben = 72.5", GradeReports.averagePerStudent(small).get("Ben") == 72.5);
        check("averagingInt of no scores is 0.0, not missing", GradeReports.averagePerStudent(small).get("Dev") == 0.0);
        check("wordCounts handles blank and double spaces",
            GradeReports.wordCounts(List.of("Great lab work", "great  effort", "")).equals(
                new TreeMap<>(Map.of("great", 2L, "lab", 1L, "work", 1L, "effort", 1L))));

        // laziness: findFirst must not pull every inner stream
        int[] touched = {0};
        Optional<Integer> first = Stream.of(1, 2, 3, 4, 5)
            .flatMap(i -> { touched[0]++; return Stream.of(i, i * 10); })
            .findFirst();
        check("findFirst after flatMap returns 1", first.equals(Optional.of(1)));
        check("findFirst after flatMap only ran the mapper once (JDK 10+)", touched[0] == 1);

        // infinite inner stream still terminates with a short-circuit op
        List<Integer> five = Stream.of(1, 2).flatMap(i -> Stream.iterate(i, x -> x + 2)).limit(5).toList();
        check("infinite inner stream + limit terminates", five.equals(List.of(1, 3, 5, 7, 9)));

        // inner streams are closed
        boolean[] closed = {false};
        Stream.of(1).flatMap(i -> Stream.of("x").onClose(() -> closed[0] = true)).toList();
        check("flatMap closes the inner stream", closed[0]);

        // null returned from mapper acts as empty
        List<String> withNull = Stream.of("a", "b").flatMap(s -> s.equals("a") ? null : Stream.of(s)).toList();
        check("mapper returning null is treated as empty", withNull.equals(List.of("b")));

        System.out.println();
        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }
}

```

```bash
javac --release 21 -d out -cp out src/test/java/dev/ggorantala/grades/Checks.java
java -cp out dev.ggorantala.grades.Checks

```

```log
PASS  allCourses count matches loop
PASS  courseCodes matches loop
PASS  rows equals loop rows (same order)
PASS  mapMulti rows equal flatMap rows
PASS  scoreStats count/sum match loop
PASS  map() keeps empty lists as elements
PASS  flatMap drops empty lists
PASS  student with no courses adds no rows
PASS  course with no assignments adds no rows
PASS  lookup skips unknown names
PASS  lookup keeps input order
PASS  topScore Asha = 95
PASS  topScore of student with no scores is empty
PASS  topScore of unknown student is empty
PASS  averagePerStudent Ben = 72.5
PASS  averagingInt of no scores is 0.0, not missing
PASS  wordCounts handles blank and double spaces
PASS  findFirst after flatMap returns 1
PASS  findFirst after flatMap only ran the mapper once (JDK 10+)
PASS  infinite inner stream + limit terminates
PASS  flatMap closes the inner stream
PASS  mapper returning null is treated as empty

22 passed, 0 failed

```

The last five are about how `flatMap` behaves, not about the gradebook. They're worth a look because people repeat old advice about them. Before Java 10, a `flatMap` followed by `findFirst` could still run the function on the remaining elements, and an infinite inner stream could hang. On 21, `findFirst` ran the mapper once, and `Stream.iterate` inside `flatMap` followed by `limit(5)` came back with `[1, 3, 5, 7, 9]`. If your code has to run on Java 8, don't rely on that.

## What flatMap does to a stream of streams

For each element, `flatMap` calls your function, gets back a stream, and feeds that stream's elements downstream one at a time. Then it closes the inner stream, which is why the `onClose` check passes. If your function returns `null`, `flatMap` treats it as an empty stream, which the null check proves, though I'd return `Stream.empty()` and not lean on it.

Order is preserved. Elements from the first inner stream come before elements from the second, and so on, so the rows came out in student order. And because it's lazy since Java 10, only as much of each inner stream is pulled as the terminal operation needs.

`mapMulti` does the same job without building a stream per element. On 200,000 generated students (451,243 rows) on a 2-core machine, three rounds in one JVM gave flatMap 377, 89 and 158 ms against mapMulti's 96, 31 and 45 ms. That is an unscientific run with no JMH, and the first round includes warm-up. I'd read it as "mapMulti is cheaper when the inner collections are tiny" and nothing firmer. The Javadoc says the same: use it when replacing each element with a small number of results, or when imperative code is clearer than building a stream. For anything you plan to quote, measure with proper tooling.

For how the pipeline pulls elements through, see [How does the Streams API work](https://ggorantala.dev/how-does-streams-api-work/?ref=ggorantala.dev).

## Compiler errors and surprises, with the fixes

I forced each of these. Outputs are from JDK 21.

**Returning a List where flatMap wants a Stream.** `flatMap(x -> x.courses())`:

```log
error: incompatible types: cannot infer type-variable(s) R
    (argument mismatch; bad return type in lambda expression
      List<Course> cannot be converted to Stream<? extends R>)

```

The message talks about a type variable `R`, which isn't the problem. The problem is that your function returned a `List`. Add `.stream()`.

**Using map when you meant flatMap.** `map(x -> x.courses().stream())` and then `.toList()` into a `List<Course>`:

```log
error: incompatible types: List<Stream<Course>> cannot be converted to List<Course>

```

This one's clear: you have a list of streams. Change `map` to `flatMap`.

**The int\[\] trap.** I hit this one for real. `Stream.of(new int[]{1,2}, new int[]{3}).flatMap(Arrays::stream)`:

```log
error: incompatible types: cannot infer type-variable(s) R
    (argument mismatch; bad return type in method reference
      IntStream cannot be converted to Stream<? extends R>)

```

`Arrays.stream(int[])` returns an `IntStream`, and `flatMap` wants a `Stream`. With `String[]` the same method reference works, because it returns a `Stream<String>`. For primitives, use `flatMapToInt(Arrays::stream)`. With that, I got `[1, 2, 3]` after `.boxed().toList()`.

**The same trap, one level down.** `flatMapToInt(c -> c.length())` where `c` is a `String`:

```log
error: incompatible types: bad return type in lambda expression
    int cannot be converted to IntStream

```

`flatMapToInt` expects a function returning an `IntStream`, not an `int`. If you only have an `int` per element, you want `mapToInt`.

**Returning the inner list instead of its stream, two levels deep.** `c -> c.assignments()` inside the nested `flatMap`:

```log
error: incompatible types: cannot infer type-variable(s) R
          List<Assignment> cannot be converted to Stream<? extends R>

```

Same cause as the first, wrapped in more layers of error.

**Losing the outer variable.** Flatten everything first, then use the student's name:

```log
error: cannot find symbol
  symbol:   variable x
  location: class Bad4

```

This is the problem step 5 solves. Nest the inner pipeline inside the outer lambda.

**A null collection.** If `courses()` is `null`, the failure is in your lambda, not in `flatMap`:

```log
java.lang.NullPointerException: Cannot invoke "java.util.List.stream()" because the return value of "dev.ggorantala.grades.Student.courses()" is null

```

Records don't stop you passing `null`. Either validate in a compact constructor (see the records article) or write `Optional.ofNullable(s.courses()).stream().flatMap(List::stream)`.

**Unwrapping an empty result.** `topScore(..., "Dev").get()`:

```log
java.util.NoSuchElementException: No value present

```

Use `orElse`, `orElseThrow` with a message, or keep the `Optional`.

**Old language levels.** `javac --release 11` and `--release 15` both stop on the record:

```log
GradeReports.java:37: error: records are not supported in -source 11
  (use -source 16 or higher to enable records)

```

`mapMulti` is also Java 16\. On older releases, `flatMap` works as shown, but use classes instead of records.

## Where flatMap stops being the right tool

- **Deep nesting.** Three levels, like step 5, is my limit for one expression. Past that, pull the inner pipeline into a named method that returns a `Stream<Row>`, and call it from `flatMap(s -> rowsFor(s))`.
- **Tiny inner collections, hot paths.** Reach for `mapMulti` when profiling shows the stream-per-element cost. Not before.
- **Counting only.** If you only need totals, `mapToInt(s -> s.courses().size()).sum()` beats flattening to count. In my nested list test, `mapToInt(List::size).sum()` returned `3` for `[[1,2],[],[3]]` without creating a single inner stream.
- **Maps of lists.** `map.values().stream().flatMap(List::stream)` is the usual way to get all values. I ran it on `Map.of("k", List.of(1, 2))` and got `[1, 2]`.
- **When you need the grouping afterwards.** Use `groupingBy` with `flatMapping` as in step 9, not a flatten followed by a regroup.

Next reads: [How to chain Comparators](https://ggorantala.dev/how-to-chain-comparators-java/?ref=ggorantala.dev) and [How to merge, zip and join streams](https://ggorantala.dev/how-to-merge-zip-join-streams-java/?ref=ggorantala.dev), both forward links to articles not yet published. For other collector shapes, see the [collect article](https://ggorantala.dev/how-to-collect-stream-results-java/?ref=ggorantala.dev) again.

Version notes: `Optional.stream()` and `Collectors.flatMapping` are Java 9\. The lazy `flatMap` behaviour is Java 10\. `mapMulti` and `Stream.toList()` are Java 16\. Nothing here needs a preview flag.

## Checklist

- `map` returns one thing per element; `flatMap` returns a stream per element and merges them.
- The function you pass to `flatMap` must return a `Stream`. A `List` is the usual mistake.
- To keep an outer value, nest the inner `map` inside the outer lambda.
- For primitives, use `flatMapToInt`, `flatMapToLong` or `flatMapToDouble`; `Arrays.stream(int[])` is an `IntStream`.
- `flatMap(Optional::stream)` drops empty Optionals; `Optional.flatMap` avoids `Optional<Optional<T>>`.
- Compare every pipeline with the loop it replaces, on data with empty lists in it.

## Questions about flatMap

**What's the difference between map and flatMap in Java?**  
`map` turns each element into exactly one result. `flatMap` turns each element into a stream of results and merges all those streams into one. Use `flatMap` when your function returns a collection or stream.

**How do I flatten a List of Lists in Java?**  
`listOfLists.stream().flatMap(List::stream).toList()`. It works on any depth if you repeat it, and empty inner lists contribute nothing.

**Can I use flatMap with an array?**  
Yes, with `Arrays::stream` for object arrays. For `int[]`, `long[]` or `double[]`, use `flatMapToInt`, `flatMapToLong` or `flatMapToDouble`, because `Arrays.stream` on a primitive array returns a primitive stream.

**Why does flatMap with Optional return a Stream?**  
`Optional.stream()` gives a stream of zero or one element, so `flatMap(Optional::stream)` on a `Stream<Optional<T>>` gives you a `Stream<T>` with the empties removed. If you want the `Optional`\-to-`Optional` version, call `flatMap` on the `Optional` itself.

**Is flatMap lazy?**  
Since Java 10, yes: `findFirst` or `limit` after a `flatMap` stops pulling from inner streams early. On Java 8 and 9, don't count on it.

## References

- [java.util.stream.Stream, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html?ref=ggorantala.dev) (flatMap, flatMapToInt, mapMulti)
- [java.util.stream.Collectors, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html?ref=ggorantala.dev) (flatMapping)
- [java.util.Optional, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Optional.html?ref=ggorantala.dev) (stream, flatMap)
- [JEP 395: Records](https://openjdk.org/jeps/395?ref=ggorantala.dev)