Sequences (Lazy Collections)
Reviewed & published by Brayan K
A sequence is a lazily-evaluated collection that processes elements one at a time through an entire operation chain, only doing work when a terminal operation finally pulls the results.
Part of the free Kotlin course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
You'll learn asSequence(), the difference between lazy and eager evaluation, generateSequence, terminal operations, and exactly when sequences beat plain lists.
What You'll Learn in This Lesson
- What lazy evaluation means in practice
- How eager list chains differ from sequences
- Converting a collection with asSequence()
- Building infinite streams with generateSequence
- Intermediate vs terminal operations
- When sequences win — and when they don't
1️⃣ Eager Lists: Each Step Finishes First
Ordinary collection operations are eager. When you chain .map { }.filter { } on a list, map runs over every element and builds a whole new list, then filter runs over that. The print statements below reveal the order: all the maps, then all the filters.
fun main() {
// A LIST is EAGER: each operation runs fully and builds a new list before
// the next one starts. Watch the print statements show that order.
val result = listOf(1, 2, 3, 4)
.map { println("map $it"); it * 2 }
.filter { println("filter $it"); it > 4 }
println("result = $result")
}Every map runs before any filter, and an intermediate list of all four doubled values exists in memory in between.
2️⃣ Sequences: One Element All the Way Through
Call .asSequence() and the same chain becomes lazy. Now each element travels through the entire pipeline before the next one starts, and no work happens at all until a terminal operation such as toList() pulls the values. Watch how map and filter now interleave.
fun main() {
// A SEQUENCE is LAZY: nothing runs until a terminal operation pulls
// values. Each element flows through the WHOLE chain one at a time.
val result = listOf(1, 2, 3, 4)
.asSequence()
.map { println("map $it"); it * 2 }
.filter { println("filter $it"); it > 4 }
.toList() // terminal operation triggers the work
println("result = $result")
}No intermediate list is built, and because elements flow one at a time, a sequence can stop early — first { } would halt as soon as it found a match instead of transforming everything first.
3️⃣ generateSequence: Infinite, but Lazy
Because sequences only compute what's consumed, they can be infinite. generateSequence(seed) { next } starts from a seed and repeatedly applies a function. Pair it with take(n) and a terminal operation to get exactly the values you need and not one more.
fun main() {
// generateSequence builds an INFINITE sequence lazily.
// It only computes as many values as you actually take.
val powersOfTwo = generateSequence(1) { it * 2 }
// take(5) is lazy; toList() is the terminal step that materializes them.
val first5 = powersOfTwo.take(5).toList()
println(first5) // -> [1, 2, 4, 8, 16]
// Sum the first 4 multiples of 3 without ever building a full collection:
val total = generateSequence(3) { it + 3 }
.take(4)
.sum()
println(total) // -> 30 (3 + 6 + 9 + 12)
}Without laziness, an infinite source like this could never finish. With it, take(5) computes precisely five values.
Your turn. Fill in the ___, then run and compare.
// 🎯 YOUR TURN — replace the ___ then run main()
fun main() {
// Turn this list into a sequence so the chain runs lazily.
val firstBig = listOf(1, 2, 3, 4, 5, 6)
.___() // 👉 put "asSequence" in the blank
.map { it * it } // squares: 1, 4, 9, 16, 25, 36
.first { it > 10 } // stop at the first square over 10
println(firstBig)
// ✅ Expected: 16
}Mini-Challenge: A Lazy Pipeline
Combine generateSequence with a lazy filter/map chain and an early take.
// 🎯 MINI-CHALLENGE: a lazy pipeline.
// Save as Pipeline.kt and run main().
//
// Using generateSequence, produce the natural numbers 1, 2, 3, ...
// Then, with a LAZY chain:
// 1. keep only the EVEN numbers
// 2. square each of them
// 3. take the first 3 results
// 4. collect them into a list and print it
//
// ✅ Expected output:
// [4, 16, 36]
fun main() {
// your code here
}Common Errors (and the fix)
- ❌ Nothing prints / nothing happens — You built a sequence but never added a terminal operation. ✅ End the chain with toList(), sum(), first(), etc.
- ❌ Program hangs forever — You called a terminal like toList() on an infinite generateSequence without take(n). ✅ Limit it with take or takeWhile first.
- ❌ Using a sequence for a tiny list — The lazy machinery adds overhead. ✅ For short chains over small collections, plain list operations are simpler and just as fast.
- ❌ Iterating a sequence twice — Some sequences are single-use and re-running them re-executes side effects. ✅ Materialize with toList() if you need the result more than once.
Pro Tips
- 💡 Long chains + big data = sequence: the more operations and the larger the input, the more laziness pays off.
- 💡 Early exit is free: asSequence().first { ... } stops at the first match instead of processing everything.
- 💡 Same result, different timing: a list chain and a sequence chain produce identical answers — sequences just avoid intermediate collections.
📋 Quick Reference — Sequences
| Concept | Kotlin Syntax |
|---|---|
| From a collection | list.asSequence() |
| Infinite sequence | generateSequence(1) { it + 1 } |
| Intermediate op | .map { } / .filter { } |
| Limit values | .take(5) |
| Terminal op | .toList() / .sum() / .first() |
| Build directly | sequenceOf(1, 2, 3) |
🎉 Lesson Complete!
- ✅ Lists are eager; sequences are lazy
- ✅ In a sequence each element flows through the whole chain at once
- ✅ Work only happens at a terminal operation
- ✅ generateSequence models infinite streams safely with take
- ✅ Sequences win for long chains, large data, and early exits
Practice quiz
How is a List chain evaluated?
- Lazily, one element at a time
- Only when printed
- Eagerly: each operation completes and builds a new list before the next
- In random order
Answer: Eagerly: each operation completes and builds a new list before the next. List operations are eager; each step builds a full intermediate list before the next runs.
How is a Sequence evaluated?
- Lazily: nothing runs until a terminal operation pulls values
- Eagerly, like a list
- All at once at declaration
- Never
Answer: Lazily: nothing runs until a terminal operation pulls values. A sequence is lazy; work happens only when a terminal operation requests results.
In a sequence chain, how does each element move through the operations?
- All elements finish map, then all finish filter
- Elements are processed in parallel
- Only the first element is processed
- One element flows through the whole chain before the next starts
Answer: One element flows through the whole chain before the next starts. In a sequence each element travels through the entire pipeline one at a time.
Which converts a list into a lazy sequence?
- toList()
- asSequence()
- toSequence()
- lazy()
Answer: asSequence(). asSequence() turns a collection into a lazily-evaluated sequence.
What happens if a sequence chain has no terminal operation?
- No work happens at all
- It runs immediately
- It throws an exception
- It loops forever
Answer: No work happens at all. Without a terminal operation, intermediate operations never execute.
Which of these is a terminal operation?
- map
- filter
- toList
- take
Answer: toList. toList (along with sum, first, count, forEach) is terminal; map/filter/take are intermediate.
What does generateSequence(1) { it * 2 } produce?
- A finite list of two values
- A lazy, conceptually infinite sequence: 1, 2, 4, 8, ...
- A compile error
- Only the number 1
Answer: A lazy, conceptually infinite sequence: 1, 2, 4, 8, .... generateSequence builds a lazy, potentially infinite sequence from a seed and a next function.
Why must you usually pair an infinite generateSequence with take(n)?
- take makes it faster
- take is required syntactically
- To make it eager
- Otherwise a terminal like toList() never finishes
Answer: Otherwise a terminal like toList() never finishes. Limiting with take(n) prevents a terminal operation from running forever on an infinite source.
When do sequences typically outperform plain list operations?
- For very short chains on tiny lists
- For long chains, large data, or an early exit
- Always, in every case
- Only when printing
Answer: For long chains, large data, or an early exit. Laziness pays off with long chains, big data, and early exits that avoid extra work.
What is the relationship between a list chain and the equivalent sequence chain's final result?
- The sequence gives a different answer
- The list is always wrong
- They produce identical results; only the timing and memory differ
- The sequence drops elements
Answer: They produce identical results; only the timing and memory differ. Both produce the same final result; sequences just avoid intermediate collections.
Continue this course
- Previous: Inline Functions & reified
- Next: Interfaces & Abstract Classes — Default methods, interface properties, and interface vs abstract class
- Quick reference: Kotlin cheat sheet
Frequently asked questions
What is the difference between a List and a Sequence?
A List is eager: every operation in a chain runs to completion and produces a whole new intermediate list before the next operation begins. A Sequence is lazy: nothing happens until a terminal operation (like toList, sum, or first) asks for results, and then each element flows through the entire chain one at a time. The final answer is the same, but the order of work and the amount of memory used differ — sequences avoid building intermediate collections.
When are sequences actually faster?
Sequences win when you have a long chain of operations, a large data set, or an early exit. Because no intermediate lists are allocated and elements are processed one at a time, a chain like asSequence().map { }.filter { }.first { } can stop as soon as it finds a match instead of transforming every element first. For short chains over small collections, plain list operations are usually just as fast and simpler — the lazy machinery has its own small overhead.
What are intermediate versus terminal operations?
Intermediate operations (map, filter, take, distinct, and so on) return another sequence and do no work on their own — they just describe the pipeline. A terminal operation (toList, sum, count, first, forEach) is what actually pulls values through that pipeline and produces a result or side effect. A sequence with only intermediate operations and no terminal operation never runs at all, which surprises people the first time.
What does generateSequence do?
generateSequence creates a sequence from a starting value and a function that produces the next value, so generateSequence(1) { it * 2 } yields 1, 2, 4, 8, 16, and onward. Because it is lazy it can be conceptually infinite — you only ever compute as many elements as a terminal operation consumes, typically after a take(n). It is the idiomatic way to model unbounded streams such as counters, Fibonacci numbers, or repeated polling.