flatMap
On This Lesson 11 topics
Lesson Overview
Real data is often nested. An order contains many items. A customer has many addresses. A sentence contains many words. A list of orders is therefore a list where each element holds another list. If you want "all the items across all the orders", plain map gives you the wrong shape: it hands you a list of lists, not one flat list.
flatMap is the tool for this. On the conveyor belt, imagine each arriving box is itself full of smaller parcels. A map station replaces each box with one new thing and keeps the boxes. A flatMap station opens every box and puts all the parcels on the belt, so everything downstream sees single parcels, not boxes.
Why this matters: "all products ever sold", "every tag used by any article", and "all words in a file" are one-liners with flatMap, and loops for the same jobs need a nested for. Nested collections are everywhere in backend models.
This lesson builds on Lessons 1 to 3. It uses records (previewed in BEG-026), generics (BEG-031) and method references (BEG-035).
Learning Objectives
By the end of this lesson you will be able to:
- Explain the difference between
mapandflatMapusing the type of the result. - Flatten a list of lists, or the items of many orders, into one stream.
- Write the lambda that
flatMapneeds, which must return a stream. - Combine
flatMapwithfilter,distinct,sorted, andcount. - Keep a link to the parent object while flattening, by using
mapinsideflatMap. - Recognise when a nested loop is clearer.
Written Explanation
The problem: map keeps the boxes
Suppose each Order has a list of item names:
record Order(String customer, List<String> items) { }If you write orders.stream().map(o -> o.items()), the lambda returns a List<String> for each order, so the result is a stream of lists: Stream<List<String>>. Collected, that is List<List<String>>, a list of lists. You wanted a single list of strings.
The solution: flatMap
flatMap takes a function that turns each element into a stream, then joins all those little streams into one stream:
List<String> allItems = orders.stream()
.flatMap(o -> o.items().stream())
.toList();The function o -> o.items().stream() is called once per order, and returns that order's items as a stream. flatMap joins them. The result is Stream<String>, and toList() gives a plain List<String>.
| Function returns | Stream becomes | One element in gives | |
|---|---|---|---|
map | one value | Stream<R> | exactly one element out |
flatMap | a stream of values | Stream<R> | zero, one, or many elements out |
The key rule is in the second column: the function you give to flatMap must return a stream. If you return a List, the code will not compile; add .stream(). If you want the method reference form for a list of lists, write flatMap(List::stream).
Because the function may return an empty stream, flatMap can also drop an element entirely: an order with no items contributes nothing. The Javadoc also notes that a null returned from the function is treated as an empty stream, but do not rely on returning null; return Stream.empty() or an empty collection's stream.
Flatten, then keep working
After flatMap, the rest of the pipeline works on single items, so every earlier tool applies:
orders.stream()
.flatMap(o -> o.items().stream())
.distinct()
.sorted()
.toList();This produces every distinct item name ever ordered, in alphabetical order. Replace toList() with count() after a filter and you can answer "how many pens were ordered in total?".
Splitting text
A string can be turned into a stream of its words with Arrays.stream(text.split(" ")). Combined with flatMap, a list of lines becomes a stream of words:
lines.stream()
.flatMap(line -> Arrays.stream(line.split("\\s+")))
.filter(w -> !w.isEmpty())
.map(String::toLowerCase)
.distinct()
.toList();Notice the filter for empty strings. In Java, "".split(" ") returns an array with one empty string in it, so an empty line yields an empty "word". The example in this lesson shows it in the real output.
Keeping the parent: map inside flatMap
Sometimes flattening loses information you still need. After flattening orders to items, you no longer know which customer ordered each item. To keep it, build the pair inside the function with a nested map:
orders.stream()
.flatMap(o -> o.items().stream().map(item -> o.customer() + ":" + item))
.toList();Read it from the inside out: for each order o, turn its items into labelled strings, then flatten all those streams into one. The lambda item -> ... can use o because it sits inside the outer lambda, where o is in scope.
map versus flatMap in one question
Ask: "For each input, do I get exactly one thing, or a collection of things?" One thing: map. A collection of things that should become part of the main stream: flatMap. Another test is the type: if you see Stream<List<...>> or List<List<...>> appear and you did not want it, you probably needed flatMap.
When a loop is clearer
A flatMap followed by a map that needs the parent's fields can get tangled. If you need several nested conditions, counters, or an early exit, a nested for loop with named variables may read better. Lesson 6 gives the test: could a teammate read it quickly?
Examples
Example 1: flatten a list of lists (simple)
import java.util.*;
public class Flatten {
public static void main(String[] args) {
List<List<Integer>> nested = List.of(
List.of(1, 2), List.of(3), List.of(), List.of(4, 5, 6));
System.out.println(nested.stream().map(l -> l.size()).toList());
System.out.println(nested.stream().flatMap(List::stream).toList());
}
}Real output:
[2, 1, 0, 3]
[1, 2, 3, 4, 5, 6]The first line uses map to get each inner list's size: still one answer per inner list. The second uses flatMap to open every inner list, and the empty list simply contributes nothing.
Example 2: every item ever ordered (practical)
import java.util.*;
public class AllItems {
record Order(String customer, List<String> items) { }
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("Amal", List.of("pen", "book", "pen")),
new Order("Bo", List.of("lamp", "pen")),
new Order("Chen", List.of()));
List<String> all = orders.stream()
.flatMap(o -> o.items().stream())
.toList();
List<String> unique = orders.stream()
.flatMap(o -> o.items().stream())
.distinct()
.sorted()
.toList();
long pens = orders.stream()
.flatMap(o -> o.items().stream())
.filter(i -> i.equals("pen"))
.count();
System.out.println(all);
System.out.println(unique);
System.out.println(pens);
}
}Real output:
[pen, book, pen, lamp, pen]
[book, lamp, pen]
3Chen's empty order does not appear and does not cause an error. Three pens were ordered in total.
Example 3: words in lines, including the empty-line trap (realistic)
import java.util.*;
public class Words {
public static void main(String[] args) {
List<String> lines = List.of("the cat sat", "the dog", "");
System.out.println(lines.stream()
.flatMap(l -> Arrays.stream(l.split(" ")))
.toList());
System.out.println(lines.stream()
.flatMap(l -> Arrays.stream(l.split("\\s+")))
.filter(w -> !w.isEmpty())
.map(String::toLowerCase)
.distinct()
.toList());
}
}Real output:
[the, cat, sat, the, dog, ]
[the, cat, sat, dog]In the first line, the empty last line produced an empty string (shown as a blank after the final comma). The second pipeline removes it with filter, lower-cases, and removes duplicates, giving the vocabulary of the text. The same pattern works on lines read from a file (BEG-034).
Backend Insight
Domain models are full of "one-to-many" relationships: an order has line items, a user has roles, a blog post has tags. Business questions cross them: "which roles are held by anyone in this team?", "which products appear in any order this week?". flatMap answers each in one pipeline.
When those relationships live in a relational database, the SQL equivalent is a join. Joining orders to order_items produces one row per item with the order's columns repeated, just like the map-inside-flatMap example above, which produces one customer:item pair per item. You will see this in BEG-050. A good rule: if the database can do the join and filter before the data reaches Java, let it; use flatMap for what you already hold in memory, such as the result of one query with its child collections.
Be careful with huge nested data: flattening a million orders with ten items each creates a stream of ten million elements. Streams are lazy, so it does not hold them all at once until you collect them, and collecting them does.
A practical tip for reviews: when you read a pipeline that contains flatMap, ask what the "box" and the "parcel" are. If you can say "each order is a box, each item is a parcel" in plain words, the pipeline is probably clear. If you cannot, give the lambda a clearer name or split the work into a small helper method.
Common Mistakes
Mistake 1: Using map and getting a list of lists
- Incorrect:
orders.stream().map(o -> o.items()).toList()which givesList<List<String>>. - Correct:
orders.stream().flatMap(o -> o.items().stream()).toList().
Mistake 2: Forgetting .stream() in the lambda
flatMap(o -> o.items()) does not compile, because the function must return a stream, not a list. Add .stream().
Mistake 3: Losing the parent
After a plain flatten, you cannot tell which order an item came from. Use a nested map to carry the parent field along, or use a loop.
Mistake 4: Trusting split on an empty string
"".split(" ") gives an array with one empty string, so you may count an empty "word". Filter empty strings out.
Mistake 5: Returning null from the function
A null is treated as empty by the library, but returning it hides intent and is easy to break later. Return an empty stream instead, for example Stream.empty().
Exercises
Try each exercise yourself before opening the solutions. Use this record: record Order(String customer, List<String> items) { }.
Level A: Foundation
A1. Which operation would you use to turn List<List<String>> into one List<String>: map or flatMap?
Show solution A1
A1. flatMap, using flatMap(List::stream).
A2. What type does orders.stream().map(o -> o.items()) produce: Stream<String>, Stream<Order>, or Stream<List<String>>?
Show solution A2
A2. Stream<List<String>>: map keeps one output per order, and each output is a list.
A3. Given List<List<Integer>> grid = List.of(List.of(1, 2), List.of(3, 4)), write a pipeline that gives the sum of all the numbers.
Show solution A3
A3.
int sum = grid.stream()
.flatMap(List::stream)
.reduce(0, Integer::sum); // 10Level B: Application
B1. Given List<Order> orders, write a pipeline returning the number of items ordered in total, counting duplicates.
Show solution B1
B1.
long total = orders.stream()
.flatMap(o -> o.items().stream())
.count();B2. Write a pipeline returning the distinct item names that start with "p", sorted.
Show solution B2
B2.
List<String> p = orders.stream()
.flatMap(o -> o.items().stream())
.filter(i -> i.startsWith("p"))
.distinct()
.sorted()
.toList();B3. Given Map<String, List<String>> tagsByArticle, write a pipeline returning every distinct tag, sorted. (Hint: values() is a collection.)
Show solution B3
B3.
List<String> tags = tagsByArticle.values().stream()
.flatMap(List::stream)
.distinct()
.sorted()
.toList();Level C: Challenge
C1. Return, for each item of each order, a string customer:item, but only for items that are not "pen".
Show solution C1
C1.
List<String> labels = orders.stream()
.flatMap(o -> o.items().stream()
.filter(i -> !i.equals("pen"))
.map(i -> o.customer() + ":" + i))
.toList();C2. A teammate's code orders.stream().flatMap(o -> o.items()).toList() does not compile. Explain why in one sentence and give the fix. Then say whether a nested for loop would be a better choice for "print each customer followed by their items on one line", and why.
Show solution C2
C2. The function passed to flatMap must return a stream, but o.items() returns a list; the fix is o.items().stream(). For "each customer followed by their items on one line", a loop is better: the output is one line per customer, so the natural shape is a for over orders with String.join on each item list, not a flat stream of items.
Mini Challenge
Invent a small nested data set, for example students with a list of courses, or blog posts with a list of tags (five or six entries, with repeats). Write three pipelines: (1) every item as one flat list, (2) the distinct items, sorted, (3) a parent:item list that keeps the link to the parent. Add one sentence saying which of the three needed a nested map and why.
Check your work: no Stream<List<...>> appears in your results, and at least one parent has an empty list to prove that case works.
Summary & Next Lesson
Key takeaways
mapgives exactly one output per input;flatMapgives zero, one, or many.- The function given to
flatMapmust return a stream. - After flattening,
filter,distinct,sorted, andcountwork on single items. - Use a
mapinside theflatMapto keep information about the parent. - Filter out empty strings after
split.
Next lesson
The next lesson, "Collectors and grouping", teaches collect properly, including groupingBy, counting, and summingDouble, which work like SQL GROUP BY.
Self-check
- What is the type difference between
map(o -> o.items())andflatMap(o -> o.items().stream())? - What must the function given to
flatMapreturn? - What does an empty inner list contribute after flattening?
- How do you keep the parent's name next to each flattened child?
- Why can
"".split(" ")cause a surprise?
Show answers
mapgivesStream<List<String>>;flatMapgivesStream<String>.- A stream.
- Nothing: it adds no elements.
- Use a nested
mapinside theflatMap, for exampleo.items().stream().map(i -> o.customer() + ":" + i). - It returns an array holding one empty string, so you may get an empty "word".
References
- Stream, Java SE 21 API. Supports:
flatMapreplaces each element with the contents of a mapped stream, and anullmapped stream is treated as empty. - The behavior of
"".split(" ")returning a one-element array, the real output shown, and the nestedmappattern were verified by compiling and running the examples with JDK 21;String.splitwas not checked against a fetched page. The box-and-parcel picture is this course's own analogy.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.