Skip to content

How to Use flatMap in Java to Flatten Nested Lists

Flatten students, courses and assignments with flatMap, flatMapToInt, Optional.stream and mapMulti on Java 21. 22 checks, forced errors and the fixes included.

how-to-tutorials java-streams java-collections java-tutorials
Gopi Gorantala
Reading Progress

On This Page

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:

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:

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. If you want the next step after this one, How to collect stream results in Java covers groupingBy and friends in depth; I only use them lightly here.

The data flows like this:

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 (not published yet).

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

java -version

Scaffold the gradebook in a terminal, IntelliJ IDEA or Eclipse

On macOS and Linux:

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.

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

package dev.ggorantala.grades;

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

src/main/java/dev/ggorantala/grades/Course.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

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 explains why that matters, and the equality half is in How to implement equals() and hashCode().

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

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)

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.

    /** 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.

    /** 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 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.

    /** 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.

    /** 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.

    /** 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.

    /** 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 flatMaps 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 first.

Checkpoint:

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.

    /** 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

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:

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.

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

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);
    }
}
javac --release 21 -d out -cp out src/test/java/dev/ggorantala/grades/Checks.java
java -cp out dev.ggorantala.grades.Checks
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.

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()):

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>:

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):

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:

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:

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:

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:

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():

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:

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 and How to merge, zip and join streams, both forward links to articles not yet published. For other collector shapes, see the collect article 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

how-to-tutorialsjava-streamsjava-collectionsjava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments