1. Introduction
When we write code over collections, it’s very easy to fall into a pleasant trap: the chain looks nice, reads like text, and you want to believe it “runs nicely” too. Often it does. But sometimes a single line hides several full passes over the data and a bunch of temporary lists that get created and immediately thrown away.
This is not “micro-optimization for a checkbox”; it’s a normal engineering habit: understanding how much work your code actually does—especially if the data becomes 100 times larger.
Imagine we don’t have 20 expenses, but 200_000, and the user asks for a “top‑10 categories” report. If our code makes four passes and builds three intermediate lists, it can still be correct—but it will simply waste extra time and memory.
We won’t measure milliseconds or draw profiler graphs (that’s a separate story), but we will learn to read a pipeline “by cost,” like a renovation estimate: “there’s an extra layer of plaster here.”
2. Pipeline cost: passes, collections, and objects
When you see a chain like filter().map().filter().map(), it has a price. And that price usually consists of three parts: how many times we traverse the original data, how many intermediate collections we create between steps, and how many temporary objects we create along the way (for example, new strings, Pair, Triple, etc.). This model is useful even without exact numbers: you start noticing “extra movement” in the code.
Let’s capture this in a small table—no fanaticism, just to have a reference point:
| What happens | How it shows up in code | What it can cost you |
|---|---|---|
| Extra passes over the data | multiple steps, each “over everything again” | extra CPU time |
| Intermediate collections | a chain on List often materializes the result of each step | extra memory + garbage collection |
| Temporary objects | often creating new elements (for example, map { ... }) | GC pressure, especially at large volumes |
Important: sometimes readability matters more, and you intentionally keep “two clear steps” instead of one clever step. But it should be a conscious choice, not an accident.
How to mentally “estimate” pipeline execution
It helps to keep a simple picture in mind: imagine a pipeline as an assembly line.
flowchart TD
A[List: input data] --> B[filter]
B --> C[map]
C --> D[filter]
D --> E[toList / fold / count]
When this is a List, many steps (roughly B, C, D) often create intermediate lists: the result of filter is a list, the result of map is a list, and so on.
When this is a Sequence, steps B, C, D are usually not materialized until a terminal operation E happens. And then elements flow “one by one” through the whole assembly line. This idea of “intermediate vs terminal” and “laziness” is the key to understanding why Sequence can reduce the number of intermediate collections.
3. Extra passes and intermediate collections
Extra passes: where they appear “out of habit”
A very typical pattern for beginners (and, honestly, for experienced developers when they’re tired) looks like this: “first I’ll filter,” and then “separately I’ll sum.” This is fine… as long as there isn’t much data. But it’s useful to understand that it’s two passes: one for filter, another for fold/sum.
Suppose we have expenses as a Triple(id, category, amount). No OOP yet—we’re in the part of the course where we build the project “with functions and collections.”
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "taxi", 250),
Triple(3, "food", 80),
)
val foodOnly = expenses.filter { it.second == "food" }
val totalFood = foodOnly.fold(0) { acc, e -> acc + e.third }
println(totalFood) // 200
}
The code reads perfectly: first keep only food, then add up the amounts. But the execution model is: we traversed expenses, built a new list foodOnly, then traversed foodOnly one more time and computed the sum.
fold itself is the standard way to “accumulate a result,” and it sequentially combines the accumulator with the elements.
Can we do it in one pass? Yes. Do we always need to? No. But being able to is useful.
Intermediate collections: what happens in a chain on List
When you call operations like filter and map on a regular collection (List), you typically get a new list as the result of each step. That is, the chain:
val result = xs.filter { ... }.map { ... }.filter { ... }
in the conceptual model does roughly this:
- built a list after the first filter
- built a list after map
- built a list after the second filter
And only after that do you see the final result.
This is not “bad” and not a “Kotlin bug.” On the contrary: this contract makes code predictable, because each step gives you a ready collection. But if there are many steps and a lot of data, you start paying for each intermediate list.
4. Sequence: when it helps and when it hurts
Where Sequence really helps
Sequence is useful not because it is “magically faster always,” but because it changes the execution model: operations become lazy, and elements can flow through the chain “as a stream,” without intermediate lists—until you request the result with a terminal operation.
A particularly strong effect appears when we have “early termination”: for example, take(n) or searching for the first matching element. The idea is: if we only need 5 elements, we don’t need to process 100_000.
To see the idea, let’s do an example not about expenses but about numbers (it’s easier to visually parse). The technique is the same.
fun main() {
val xs = (1..1_000).toList()
val firstFive = xs.asSequence()
.map { it * it }
.filter { it % 10 == 0 }
.take(5)
.toList()
println(firstFive) // [100, 400, 900, 1600, 2500]
}
What matters here is that take(5) is the operation “take the first N.” In a lazy model, that often means we won’t process the tail that wouldn’t make it into the result anyway.
If we did the same thing on a List without Sequence, then we would most likely first build a list of squares for all 1000 numbers, then filter, then take 5. On 1000 elements there’s almost no difference, but on 10_000_000 it can already be very noticeable.
Where Sequence can hurt
Sequence is a tool, not a religion. Sometimes it makes life harder, because laziness adds a “time factor”: it’s not always obvious when exactly the code inside map/filter will run.
There’s also a nuance that often surprises beginners: you can accidentally “traverse” a Sequence multiple times, and then the work will be done multiple times.
Let’s look at an example. We’ll build a chain and request the result twice using different terminal operations.
fun main() {
val xs = listOf(1, 2, 3, 4)
val seq = xs.asSequence()
.map { x ->
println("map $x")
x * 10
}
println(seq.count()) // map 1..4 will be printed, count = 4
println(seq.toList()) // map 1..4 will be printed AGAIN
}
The output will be roughly like this (simplified):
map 1
map 2
map 3
map 4
4
map 1
map 2
map 3
map 4
[10, 20, 30, 40]
And that’s logical: Sequence is not a “ready result,” but a “description of how to get elements.” So if you ask for the result twice, the elements will be computed twice.
With a regular List, you would build the new list once, and then it would be reused. So sometimes Sequence doesn’t speed things up; instead it adds work if you keep “pulling the result” multiple times.
A practical rule sounds like this: if you created a Sequence and you want to use the final result more than once, it often makes sense to materialize it into a List once via toList(), and then work with that.
Side effects as a problem amplifier
There’s one more reason why Sequence sometimes “gets in the way”: side effects (for example, println) inside map/filter become tied to the moment of execution, and that moment is delayed until a terminal operation. You already saw this idea in the previous lecture as “why nothing happened.”
Today, a different point matters: side effects make a pipeline harder to understand and sometimes lead to repeated work (as in the example above), and repeated work = repeated side effects. If you send logs inside map, the log will be sent as many times as you request a terminal result. Sometimes that’s fine. Sometimes it’s “why do I have 2000 identical lines in my logs?”
In educational examples we sometimes use println inside chains on purpose, as a highlight. But in real code it’s better to keep transformations “pure” and effects separate. This is especially important when you move to lazy computations.
5. Practice: an expense report without extra work
Now let’s carefully tie everything to our practical console project (let’s call it BudgetCli). Reminder of the data format: a list of Triple(id, category, amount). Our goal is to make a “top‑N categories by total” report.
A readable report version
First, let’s write the most understandable version: collect totals by category, turn that into a list, sort it, take take(n).
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "taxi", 250),
Triple(3, "food", 80),
Triple(4, "books", 300),
)
val totals = expenses.fold(mutableMapOf<String, Int>()) { acc, e ->
acc[e.second] = (acc[e.second] ?: 0) + e.third
acc
}
val top2 = totals.entries
.sortedByDescending { it.value }
.take(2)
println(top2) // [books=300, food=200]
}
Here fold does an understandable thing: it accumulates a structure (MutableMap) one element at a time. It’s important that the accumulator in fold can be not only a number—this is a normal idea for aggregations.
But then there’s a point: sortedByDescending returns a new list (i.e., materializes the sorted result), and only then take(2) takes the first two. For a small number of categories this is fine. But if there are a lot of categories, sorting the whole list just to get top‑2 is somewhat expensive.
We won’t study “advanced data structures” and algorithms for top‑N selection without full sorting yet (that’s another layer of topics). But we can at least understand: sorting everything is work.
Where Sequence helps in the project—and where it doesn’t
At this point many people automatically say: “Oh, let’s do asSequence().” And this is where grown-up engineering begins: you have to ask yourself—what exactly will get better?
If we have:
- little data (tens/hundreds),
- a short chain (2–3 steps),
- the full result is needed,
then Sequence often brings no benefit, and sometimes makes the code slightly less obvious.
But if we have:
- a long chain,
- potential early termination,
- many intermediate steps (especially map/filter/map/filter/...),
then Sequence can reduce the number of intermediate collections.
For example, if we want to take only the first few “suspicious” operations (say, expenses greater than 500), then Sequence + take(n) is a good candidate.
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "taxi", 250),
Triple(3, "books", 900),
Triple(4, "taxi", 700),
Triple(5, "food", 50),
)
val bigOnes = expenses.asSequence()
.filter { it.third >= 500 }
.take(2)
.toList()
println(bigOnes) // [(3, books, 900), (4, taxi, 700)]
}
Here take(2) is very important: it gives a chance not to process the tail of the list if the first two matching elements are found early. The idea of take is “limit the number of elements,” and it fits the lazy model well.
“Merging steps” instead of extra collections
Sometimes the best way to speed up a pipeline is not laziness, but reducing the number of steps. For example, instead of:
filter → map → filterNotNull
you can use a single operation mapNotNull. You already covered this in the lectures about data cleaning and type changes.
In our project this is useful when parsing input lines. Suppose we have raw commands and we want to extract only amounts (as Int), skipping garbage.
fun main() {
val rawAmounts = listOf("120", " ", "oops", "250")
val amounts = rawAmounts.mapNotNull { it.trim().toIntOrNull() }
println(amounts) // [120, 250]
}
Here we made one pass and immediately got a “clean” List<Int>. If we had done map { ... } returning Int?, and then filterNotNull, that would be two steps. Again: sometimes two steps are more readable, and that’s okay. But now you at least understand the price.
6. A practical selection rule: List or Sequence
It’s important not to turn the course into a Sequence cult, so let’s lock in a healthy intuition. If your chain is short and clear and the data volume is small, then regular operations on List are an excellent choice: readable, predictable, easy to debug. Kotlin’s standard library is designed around you actively using map/filter/reduce/fold without being afraid of it.
If the chain is long, there’s a lot of data, and especially if there’s take(n) or searching for the “first matching” element, then Sequence becomes a useful candidate, because it can remove intermediate collections and stop earlier.
But if you plan to use the result multiple times, Sequence can sometimes be harmful: you can accidentally compute the same thing multiple times. Then it’s better to materialize into a List once (via toList()) and then work with ready data.
7. Typical mistakes when trying to “optimize a pipeline”
Mistake #1: optimizing without understanding what exactly you are optimizing.
It’s very easy to replace list.filter { ... }.map { ... } with list.asSequence().filter { ... }.map { ... }.toList() and proudly assume it’s faster. But if there’s little data and the chain is short, you may gain nothing, while the code becomes slightly less transparent. It’s better to first learn to see extra steps and intermediate lists, and only then choose the tool.
Mistake #2: reusing a Sequence as if it were a ready result.
Sequence is a “recipe,” not a “ready dish.” If you run a terminal operation twice (for example, count(), then toList()), the recipe will be “cooked” twice. This is especially painful if the pipeline contains heavy computations or side effects (println, logging).
Mistake #3: side effects inside map/filter and being surprised by “weird execution timing.”
When code is lazy, side effects happen not where you visually wrote them, but where the terminal operation occurs. As a result, you can get “late” logs, duplicated logs, or the feeling that the program “does nothing.” For learning purposes, println inside a chain is useful, but in production style it’s better to keep transformations as pure as possible.
Mistake #4: blindly sorting “everything” just to get top‑N.
sortedByDescending(...).take(n) is a convenient and readable pattern; you’ve already used it. But it’s important to understand: the entire list is sorted, and only then you take the first elements. Sometimes that’s fine, sometimes it’s the heaviest part of the report. Today we’re not studying alternative top‑N selection algorithms without full sorting, but you should learn to notice the fact “this sorts everything.”
Mistake #5: trying to solve intermediate-collection problems where the real problem is extra steps.
Sometimes you do filter and then another filter with almost the same condition, or first map and then map again, even though it could have been one step. In such places, Sequence can reduce the number of intermediate lists, but it won’t reduce the number of logical steps. Often a stronger improvement is simply to simplify the pipeline—for example, replacing map { ... }.filterNotNull() with mapNotNull { ... }.
GO TO FULL VERSION