Sequences and Lazy Evaluation
A Sequence in Kotlin looks a lot like a List chained with map, filter, and similar functions, but it evaluates completely differently under the hood. Instead of building a new collection after every step, a sequence builds a pipeline of operations that only runs, element by element, when you finally ask for a result. That laziness lets you chain many transformations over huge or even infinite data sources without materializing intermediate lists, and it lets terminal operations like first() stop as soon as they have an answer instead of processing everything.
Overview / How Sequences Work
Kotlin’s standard collections (List, Set, Map) are eager. When you write list.map { ... }.filter { ... }, map runs over the entire list and allocates a brand-new list, then filter runs over that entire new list and allocates yet another one. Each intermediate operation is a separate, fully-completed pass with its own allocation. For a chain of several operations over a large list, that means several full passes and several temporary lists, most of which exist only to feed the next step.
A Sequence<T> takes the opposite approach: it is lazy and evaluated element-by-element, operation-by-operation rather than pass-by-pass. Calling .map { } or .filter { } on a sequence does not run anything – it just wraps the sequence in a new Sequence object that remembers what to do. Nothing executes until you call a terminal operation such as toList(), first(), sum(), or forEach(). When a terminal operation runs, each element is pulled from the source one at a time and pushed through the entire chain of intermediate operations before the next element is even looked at. The pipeline is evaluated depth-first per element instead of breadth-first per operation – which is why interleaved println calls inside a sequence chain print in a different order than the same calls inside a list chain, as Example 1 below shows.
Sequences come from three places: call .asSequence() on any existing Iterable (a List, Set, or range) to wrap it lazily with no copying; call the sequence { } builder function and yield values from a suspending lambda to generate values on demand, including infinitely; or call generateSequence(seed) { next } to build a sequence purely from a rule for producing the next value from the previous one. The sequence { } builder uses Kotlin’s built-in restricted-suspension support from the standard library – it has nothing to do with kotlinx.coroutines or coroutine dispatchers; it is a language feature that lets a function pause mid-execution and hand back a value with yield.
Because a sequence can be infinite and only produces values on demand, operations fall into two very different categories. Stateless intermediate operations like map, filter, take, and takeWhile only need to look at the current element, so they stay fully lazy and can short-circuit. Stateful intermediate operations like sorted(), distinct(), and chunked() need to see the whole sequence before producing their first output element, so they force full evaluation up to that point – and will hang forever on an infinite sequence. Terminal operations like toList(), sum(), and count() always consume the entire sequence, so an infinite sequence must be limited with take() or a similar bound before reaching one of these.
Syntax
The general shape of working with sequences is: obtain a Sequence, chain zero or more lazy intermediate operations, then finish with exactly one terminal operation.
collection.asSequence()
.map { transform(it) } // intermediate, lazy
.filter { predicate(it) } // intermediate, lazy
.take(n) // intermediate, lazy
.toList() // terminal, triggers evaluation
| Part | Meaning |
|---|---|
asSequence() |
Wraps an existing Iterable or Array as a lazy Sequence, no copying involved |
sequence { yield(x) } |
Builds a sequence by suspending and producing values on demand; can be infinite |
generateSequence(seed) { next } |
Builds a sequence from a starting value and a rule for the next value; stops when the rule returns null, or runs forever if it never does |
| intermediate operation | map, filter, take, sorted, etc. – returns a new Sequence, executes nothing by itself |
| terminal operation | toList(), first(), sum(), forEach { }, etc. – pulls elements through the pipeline and produces a real result |
Examples
Example 1: Evaluation order – List vs Sequence
This chains map and filter over the same five numbers twice: once as a List, once as a Sequence. Each lambda prints before it computes, so the printed order exposes exactly when each operation runs.
fun main() {
val listResult = listOf(1, 2, 3, 4, 5)
.map { println("map $it"); it * 2 }
.filter { println("filter $it"); it > 4 }
println("List result: $listResult")
val sequenceResult = listOf(1, 2, 3, 4, 5).asSequence()
.map { println("map $it"); it * 2 }
.filter { println("filter $it"); it > 4 }
.toList()
println("Sequence result: $sequenceResult")
}
Output:
map 1
map 2
map 3
map 4
map 5
filter 2
filter 4
filter 6
filter 8
filter 10
List result: [6, 8, 10]
map 1
filter 2
map 2
filter 4
map 3
filter 6
map 4
filter 8
map 5
filter 10
Sequence result: [6, 8, 10]
For the list, map runs to completion over all five elements first, allocating a full intermediate list [2, 4, 6, 8, 10]; only then does filter make its own complete pass. For the sequence, each element is pushed all the way through map then filter before the next element is touched – notice how a “map” line and its matching “filter” line alternate. Both produce [6, 8, 10], but the sequence version never builds the intermediate list at all.
Example 2: Short-circuiting with first()
Because sequence operations run element-by-element, a terminal operation that only needs part of the data – like first() – can stop pulling elements the moment it finds a match. This searches 1,000 numbers for the first square greater than 50.
fun main() {
val firstMatch = (1..1000).asSequence()
.map {
println("Checking $it")
it * it
}
.first { it > 50 }
println("First square over 50: $firstMatch")
}
Output:
Checking 1
Checking 2
Checking 3
Checking 4
Checking 5
Checking 6
Checking 7
Checking 8
First square over 50: 64
Only eight numbers are ever checked, even though the source range has a thousand elements – as soon as first { it > 50 } is satisfied by 8 * 8 = 64, the sequence stops asking the range for more elements. The same chain built on a List instead of a Sequence would compute all 1,000 squares up front before first even started looking.
Example 3: An infinite sequence with the sequence builder
The sequence { } builder lets you generate values with ordinary imperative code – loops, conditionals, recursion – and hand each one back with yield. Because it only produces a value when asked, it can describe an infinite source safely, as long as something downstream eventually limits it.
fun main() {
val naturalNumbers = sequence {
var n = 1
while (true) {
println("Generating $n")
yield(n)
n++
}
}
val firstFiveSquares = naturalNumbers
.map { it * it }
.take(5)
.toList()
println(firstFiveSquares)
}
Output:
Generating 1
Generating 2
Generating 3
Generating 4
Generating 5
[1, 4, 9, 16, 25]
The lambda passed to sequence { } has an infinite while (true) loop, but the program does not hang: take(5) stops requesting more elements once five have flowed through map, so the generator only runs its body five times. Removing take(5) here, or calling toList() directly on an unbounded sequence, would loop forever.
Example 4: generateSequence for a mathematical sequence
generateSequence is a compact way to build a sequence when each element depends only on a fixed rule applied to the previous value(s), as in the Fibonacci sequence. The seed is the pair (0, 1), and each step produces the next pair from the previous one.
fun main() {
val fibonacci = generateSequence(0 to 1) { (a, b) -> b to (a + b) }
.map { it.first }
.take(10)
.toList()
println(fibonacci)
}
Output:
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Each step destructures the previous pair (a, b) and produces the next pair (b, a + b); map { it.first } extracts just the Fibonacci number from each pair, and take(10) limits the naturally infinite sequence to its first ten values before toList() materializes them.
How It Works Step by Step
Under the hood, a Sequence<T> is really just an interface with a single method, iterator(): Iterator<T>. Each intermediate operation like map or filter does not touch any elements when you call it – it returns a new Sequence whose iterator() wraps the previous sequence’s iterator. A chain of several operations builds several nested iterator wrappers, not several completed passes.
Evaluation only starts when a terminal operation calls iterator() on the outermost wrapper and starts calling hasNext()/next() on it, the way a for loop would. That call recurses inward: the outermost filter iterator calls next() on the map iterator beneath it, which calls next() on the source iterator, transforms the raw value, and hands it back up to be tested by the filter’s predicate. If the predicate rejects the value, the filter iterator immediately asks its inner iterator for another one rather than returning – it does not wait for a full pass over anything. Only when a value survives the whole chain does it get returned to the terminal operation, which then decides whether to ask for another element (as toList() always does) or stop (as first() does the moment it is satisfied). This pull-based, element-at-a-time protocol is exactly why the printed order in Example 1 alternates for sequences but batches for lists, and why first() in Example 2 only ever evaluates as many elements as it needs.
Common Mistakes
A Sequence built by wrapping a fixed, already-created Iterator is single-use: once that iterator is exhausted, the sequence has nothing left to give, even though the code looks perfectly reasonable.
fun main() {
val source = listOf("a", "b", "c")
val iterator = source.iterator()
val brokenSequence = Sequence { iterator }
println(brokenSequence.toList())
println(brokenSequence.toList())
}
Output:
[a, b, c]
[]
The lambda passed to Sequence { } is supposed to return a fresh Iterator<T> every time it is called, but here it always returns the same iterator object. The first toList() drains that iterator completely; the second call gets back the same, now-empty, iterator.
fun main() {
val source = listOf("a", "b", "c")
val fixedSequence = Sequence { source.iterator() }
println(fixedSequence.toList())
println(fixedSequence.toList())
}
Output:
[a, b, c]
[a, b, c]
Returning a new iterator from the source collection on every call makes the sequence safely re-iterable, because each traversal starts source.iterator() from scratch.
The second mistake is assuming every operation in a sequence chain is lazy and short-circuits. Only stateless operations like map, filter, and take do that. Stateful operations like sorted() and distinct() must see every element before producing even their first output, because they cannot know the correct order (or which elements are duplicates) until they have looked at everything.
fun main() {
val result = (5 downTo 1).asSequence()
.map { println("map $it"); it }
.sorted()
.first()
println("Result: $result")
}
Output:
map 5
map 4
map 3
map 2
map 1
Result: 1
Even though only the smallest value is ultimately needed, sorted() forces the sequence to pull and transform all five elements before it can hand anything to first() – the laziness benefit disappears the moment a stateful operation appears in the chain.
fun main() {
val result = (5 downTo 1).asSequence()
.map { println("map $it"); it }
.minOrNull()
println("Result: $result")
}
Output:
map 5
map 4
map 3
map 2
map 1
Result: 1
minOrNull() still has to look at every element – finding a minimum inherently requires that – but it does the comparison in a single O(n) pass and never allocates a sorted copy of the data, unlike sorted().first(), which pays for a full O(n log n) sort just to read the first element off the front.
Best Practices
- Use
asSequence()when chaining three or more intermediate operations over a large collection – it avoids allocating an intermediate list after every step. - Prefer sequences whenever a terminal operation might stop early, such as
first(),find(),any(), ortake()– that is where laziness pays off the most. - For small collections or a single operation, plain
Listoperations are usually simpler and can even be faster, since every sequence step carries its own function-call and iterator-wrapping overhead. - Always bound an infinite sequence – one from
generateSequencewithout a null-returning stop condition, or an unboundedsequence { while (true) { ... } }– withtake()ortakeWhile()before calling a terminal operation. - Remember that stateful intermediate operations (
sorted(),sortedBy(),distinct(),chunked()) consume the entire sequence up front; never rely on them to short-circuit or call them on an unbounded sequence. - When building a sequence with the
Sequence { ... }constructor, make sure the lambda produces a brand-new iterator on every call so the sequence can be traversed more than once. - Reach for
generateSequencewhen each value is a pure function of the previous value(s), and thesequence { }builder when generation needs loops, branching, or multipleyieldpoints.
Practice Exercises
- Write code that takes a list of integers and, using a sequence, finds the first number whose square is greater than 500. Print inside the mapping lambda so you can see how many elements actually get checked. Hint: chain
asSequence(),map, andfirst. - Using
generateSequence, build an infinite sequence of powers of three (1, 3, 9, 27, …) and print the first eight values as a list. Expected output:[1, 3, 9, 27, 81, 243, 729, 2187]. - Use the
sequence { }builder to yield the integers from 1 upward while skipping every multiple of 3. Take the first ten results and print them. Expected output:[1, 2, 4, 5, 7, 8, 10, 11, 13, 14].
Summary
- A
Sequenceevaluates lazily and element-by-element, unlikeList/Set, which evaluate eagerly and pass-by-pass. - Create sequences with
asSequence()on an existing collection,sequence { yield(...) }for imperative generation, orgenerateSequence(seed) { next }for rule-based generation. - Nothing runs until a terminal operation like
toList(),first(), orsum()is called; intermediate operations likemapandfilteronly build a pipeline. - Stateless intermediate operations (
map,filter,take) can short-circuit; stateful ones (sorted,distinct) must consume the whole sequence first. - Sequences shine for large collections, long operation chains, and early-exit searches; for small collections or a single operation, plain list functions are simpler and often just as fast.
- Always bound infinite sequences with
take()ortakeWhile()before a terminal operation, or the program will hang.
