Sequences and when they matter
The collection operations you just learned have a cost that is invisible until your data gets big: each one creates a whole new list. Chain five of them and you have built five intermediate lists, most of which you never wanted. Sequences are Kotlin's answer, and understanding when they help — and when they do not — is what separates someone who uses the operations from someone who understands them.
The problem: intermediate lists
Consider a chain on a list:
val result = (1..10)
.map { it * 2 } // builds a list of 10 items
.filter { it > 5 } // builds another list
.map { it + 1 } // builds a third list
.first() // takes ONE item
Read what actually happens: map processes all 10 elements into a new list, then filter processes
all 10 of those into another list, then map processes all of those into a third — and then
first() throws away everything but one element. You built three full lists and used one value from
the last. On ten elements, who cares. On ten million, that is a lot of wasted work and memory.
This is eager evaluation: each operation runs completely, producing a full intermediate
collection, before the next begins. It is the default for List operations, and for small
collections it is perfectly fine.
Sequences: lazy, element-by-element
A sequence processes elements lazily — one element flows all the way through the whole chain before the next one starts, and nothing is computed until a result is actually needed:
val result = (1..10).asSequence()
.map { it * 2 }
.filter { it > 5 }
.map { it + 1 }
.first()
Add .asSequence() and everything changes under the hood. Now: take element 1, run it through map,
filter, map; take element 2, run it through; and so on — but because the terminal operation is
first(), the sequence stops the moment it has one result. It never processes elements it does
not need, and it never builds an intermediate list. For a chain that ends in first, find,
take(n), or any, this can be dramatically less work.
Two properties define a sequence:
- Lazy: nothing happens until a terminal operation (like
first,toList,sum,count) asks for a result. Intermediate operations (map,filter) just record what to do. - Element-by-element: each element goes through the entire chain before the next, so no intermediate collections are built.
When sequences help — and when they hurt
Here is the honest guidance, because "sequences are faster" is a myth that leads people to sprinkle
.asSequence() everywhere and make things slower.
Sequences help when:
- The collection is large (thousands of elements or more), so intermediate lists are expensive.
- You have a long chain of operations, so eager evaluation builds many intermediates.
- You short-circuit —
first,find,take(n),any,none— so laziness can stop early and skip most of the work.
Sequences do not help, and add overhead, when:
- The collection is small. For a few dozen elements, the lazy machinery costs more than the intermediate lists it saves. Plain list operations are faster and clearer.
- You process every element anyway and the chain is short. If you
mapthentoListon 100 items, a sequence just adds bookkeeping.
The rule: use plain list operations by default; reach for asSequence() only when the collection
is large and you either have a long chain or short-circuit. And when in doubt, measure — do not
guess. Reflexively adding .asSequence() "for performance" is a classic mistake that makes small-
collection code slower and less readable for no benefit.
A concrete illustration
The difference is easiest to see by counting how many times a lambda runs. With a plain list, every operation runs on every element:
val list = listOf(1, 2, 3, 4, 5)
list.map { print("map($it) "); it * 2 }
.filter { print("filter($it) "); it > 4 }
.first()
// map(1) map(2) map(3) map(4) map(5) filter(2) filter(4) filter(6) filter(8) filter(10)
// — every element mapped, every result filtered, THEN first() took one
With a sequence, it stops as soon as first() is satisfied:
val seq = listOf(1, 2, 3, 4, 5).asSequence()
seq.map { print("map($it) "); it * 2 }
.filter { print("filter($it) "); it > 4 }
.first()
// map(1) filter(2) map(2) filter(4) map(3) filter(6)
// — stopped at 3, because map(3)=6 passed the filter and first() had its answer
Read the second trace: the sequence took each element through the whole chain and stopped at the
third element the moment first() had a value. It never touched 4 or 5. That is laziness and
short-circuiting working together — and on a large collection where the answer is near the front,
the saving is enormous.
Generating infinite sequences
Because sequences are lazy, they can even be infinite — you only take what you need:
val firstFivePowers = generateSequence(1) { it * 2 } // 1, 2, 4, 8, 16, 32, ... forever
.take(5)
.toList()
println(firstFivePowers) // [1, 2, 4, 8, 16]
generateSequence produces values on demand forever; take(5) stops after five. This is impossible
with eager lists (you cannot build an infinite list), and it is occasionally exactly what you need —
the first N of some open-ended series.
Check your work
The cost of chained list operations. Each operation builds a full intermediate list.
What "eager" evaluation means. Each operation runs completely, producing a full intermediate collection, before the next begins — the default for lists.
What a sequence does differently. Processes lazily, element-by-element, building no intermediates, computing nothing until a terminal operation asks.
The two defining properties of a sequence. Lazy (waits for a terminal operation) and element-by-element (no intermediate collections).
Three situations where sequences help. Large collections, long chains, and short-circuiting
(first/take/any).
When sequences hurt. Small collections, or processing every element with a short chain — the lazy overhead costs more than it saves.
The rule. Plain list operations by default; asSequence() only for large collections with a long
chain or short-circuit — and measure when unsure.
What laziness enables that lists cannot. Infinite sequences with generateSequence, taking only
what you need.
Practice
- Write a chain
map.filter.map.firston a list, then the same with.asSequence(). For a small list, confirm both give the same result. - Add
printstatements to the lambdas (as in the lesson) and compare how many times each runs for the list versus the sequence version. Explain the difference. - Take a chain that ends in
.first()over a large range (say1..1_000_000) and compare the two forms. Note which does far less work. - Take a chain that ends in
.toList()over a small list and reason about why a sequence would only add overhead here. - Use
generateSequence(1) { it * 2 }.take(10).toList()to get the first ten powers of two. - Write down, for three chains you have written, whether each should be a list or a sequence, and why.
Official documentation
- Kotlin — Sequences — Lazy evaluation, intermediate versus terminal operations, and when to use them.
- Kotlin — Sequences: construction —
asSequenceandgenerateSequence. - Kotlin standard library — Sequence — The full API.
Next: putting it all together — transforming a real dataset.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship