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 fromflatMap(s -> rowsFor(s)). - Tiny inner collections, hot paths. Reach for
mapMultiwhen 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()returned3for[[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 onMap.of("k", List.of(1, 2))and got[1, 2]. - When you need the grouping afterwards. Use
groupingBywithflatMappingas 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
mapreturns one thing per element;flatMapreturns a stream per element and merges them.- The function you pass to
flatMapmust return aStream. AListis the usual mistake. - To keep an outer value, nest the inner
mapinside the outer lambda. - For primitives, use
flatMapToInt,flatMapToLongorflatMapToDouble;Arrays.stream(int[])is anIntStream. flatMap(Optional::stream)drops empty Optionals;Optional.flatMapavoidsOptional<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 (flatMap, flatMapToInt, mapMulti)
- java.util.stream.Collectors, Java SE 21 API (flatMapping)
- java.util.Optional, Java SE 21 API (stream, flatMap)
- JEP 395: Records