Sequences are Kotlin's answer to lazy collection processing. While regular collections process all elements at each step, sequences process elements one at a time through the entire pipeline — only computing what's actually needed. This seemingly small difference has a huge impact on performance when working with large datasets or expensive operations. This guide explains how sequences work, when to use them, and how they compare to regular collections.


The Problem — Eager Evaluation

Regular collection operations are eager — each operation processes the entire collection and returns a new intermediate collection before the next operation starts.

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

val result = numbers
    .map { it * 2 }        // Step 1: creates [2, 4, 6, 8, 10, 12, 14, 16, 18, 20] — all 10
    .filter { it > 10 }    // Step 2: creates [12, 14, 16, 18, 20] — from all 10
    .first()               // Step 3: takes first element — 12

// Total elements processed: 10 + 10 = 20 operations
// Even though we only needed ONE result

With 10 elements this is fine. With 10,000 articles loaded in memory — you've processed 20,000 elements just to get one.


What Is a Sequence?

A Sequence<T> is a lazily-evaluated collection. Operations on a sequence don't execute immediately — they're set up as a pipeline. Elements flow through the entire pipeline one at a time, only when a terminal operation requests a result.

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

val result = numbers.asSequence()
    .map { it * 2 }        // no work done yet — just defines the step
    .filter { it > 10 }    // no work done yet — just defines the step
    .first()               // terminal operation — NOW elements start flowing

// Element flow:
// 1 → map → 2  → filter → skip (2 ≤ 10)
// 2 → map → 4  → filter → skip (4 ≤ 10)
// 3 → map → 6  → filter → skip (6 ≤ 10)
// 4 → map → 8  → filter → skip (8 ≤ 10)
// 5 → map → 10 → filter → skip (10 ≤ 10)
// 6 → map → 12 → filter → match! → first() returns 12 → STOP

// Total elements processed: 6 (stopped as soon as result found)
// vs 20 with eager collections

This is the core of lazy evaluation — stop as soon as you have what you need.


Creating Sequences

From existing collections

val list = listOf(1, 2, 3, 4, 5)
val sequence = list.asSequence()   // convert List to Sequence

val result = sequence
    .filter { it % 2 == 0 }
    .map { it * 10 }
    .toList()   // terminal — triggers evaluation, returns List
// [20, 40]

sequenceOf — directly

val seq = sequenceOf(1, 2, 3, 4, 5)
val result = seq.filter { it > 2 }.toList()   // [3, 4, 5]

generateSequence — infinite sequences

// Infinite sequence of natural numbers
val naturals = generateSequence(1) { it + 1 }

val first10 = naturals.take(10).toList()
// [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// First 10 even numbers
val firstTenEvens = naturals
    .filter { it % 2 == 0 }
    .take(10)
    .toList()
// [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

// Fibonacci sequence
val fibonacci = generateSequence(Pair(0, 1)) { (a, b) -> Pair(b, a + b) }
    .map { it.first }

val first10Fib = fibonacci.take(10).toList()
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

generateSequence with null terminator

// Sequence ends when the lambda returns null
val descendingFromTen = generateSequence(10) { if (it > 0) it - 1 else null }
val result = descendingFromTen.toList()
// [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

// Walk up a directory tree until root
fun File.parents(): Sequence<File> = generateSequence(parentFile) { it.parentFile }

val allParents = File("/a/b/c/d.txt").parents().toList()
// [/a/b/c, /a/b, /a, /]

sequence builder — coroutine-style

// Build a sequence using yield and yieldAll
val customSequence = sequence {
    yield(1)                   // produce single value
    yield(2)
    yieldAll(listOf(3, 4, 5))  // produce multiple values
    yield(6)
}

customSequence.toList()   // [1, 2, 3, 4, 5, 6]

// Useful for tree traversal
fun <T> treeNodes(root: TreeNode<T>): Sequence<T> = sequence {
    yield(root.value)
    root.children.forEach { child ->
        yieldAll(treeNodes(child))
    }
}

Intermediate vs Terminal Operations

This distinction is critical to understanding sequences:

Type What It Does Returns Examples
Intermediate Adds a step to the pipeline, no computation yet Sequence<T> map, filter, take, drop, flatMap, distinct, sorted
Terminal Triggers evaluation, produces a result Value / Collection toList(), first(), count(), sum(), any(), find(), forEach()
val seq = sequenceOf(1, 2, 3, 4, 5)

// Chaining intermediates — no work happens yet
val pipeline = seq
    .map { it * 2 }        // intermediate
    .filter { it > 4 }     // intermediate
    .map { it + 1 }        // intermediate
// Nothing computed yet — pipeline just defined

// Terminal operation triggers everything
val result = pipeline.toList()   // [7, 9, 11]

// Another terminal — runs entire pipeline again from start
val first = pipeline.first()     // 7

How Elements Flow Through a Sequence

Understanding the flow order is key. With sequences, each element travels through all steps before the next element starts:

sequenceOf(1, 2, 3)
    .map {
        println("map: $it")
        it * 2
    }
    .filter {
        println("filter: $it")
        it > 2
    }
    .forEach { println("result: $it") }

// Output:
// map: 1
// filter: 2      ← 1 goes through entire pipeline first
// map: 2
// filter: 4
// result: 4      ← 2 produced a result
// map: 3
// filter: 6
// result: 6      ← 3 produced a result

Compare this to eager collection processing — where all map calls happen first for every element, then all filter calls for every element.


Real Android Examples

Processing large article list efficiently

// 10,000 articles in memory — only need first 20 matching complex criteria
fun getRecommendedArticles(
    allArticles: List<Article>,
    userInterests: Set<String>,
    readArticleIds: Set<String>
): List<Article> {
    return allArticles.asSequence()
        .filter { it.id !in readArticleIds }         // not already read
        .filter { it.isPublished }                   // published only
        .filter { it.category in userInterests }     // matches interests
        .filter { it.readTimeMinutes <= 10 }        // quick reads
        .sortedByDescending { it.publishedAt }       // newest first
        .take(20)                                    // only first 20
        .toList()
    // Stops processing after finding 20 matches
}

Finding first match without scanning all

fun findFirstBreakingNews(articles: List<Article>): Article? {
    return articles.asSequence()
        .filter { it.isBreakingNews }
        .filter { it.publishedAt > System.currentTimeMillis() - 3600000 }
        .firstOrNull()
    // Stops at first match — doesn't scan remaining articles
}

Reading large files line by line

// Read huge log file without loading it all into memory
fun findErrorsInLog(logFile: File): List<String> {
    return logFile.bufferedReader()
        .lineSequence()                          // reads one line at a time
        .filter { it.contains("ERROR") }
        .map { it.substringAfter("ERROR: ") }
        .take(100)                               // first 100 errors only
        .toList()
    // Never loads entire file — processes line by line
}

Generating paginated data lazily

// Walk through pages until empty page is returned
fun pageNumbers(): Sequence<Int> = generateSequence(1) { it + 1 }

suspend fun fetchAllArticles(repository: ArticleRepository): List<Article> {
    return pageNumbers()
        .map { page -> runBlocking { repository.getArticles(page) } }
        .takeWhile { articles -> articles.isNotEmpty() }
        .flatten()
        .toList()
}

Sequences vs Collections — Performance

val articles = List(100_000) { Article(id = "$it", title = "Article $it") }

// ❌ Eager — processes all 100,000 at each step
val eagerResult = articles
    .filter { it.id.toInt() % 2 == 0 }   // creates list of 50,000
    .map { it.title.uppercase() }         // creates list of 50,000
    .take(5)                              // takes 5 from 50,000
// Total: 150,000 operations + 2 intermediate lists in memory

// ✅ Lazy — stops after finding 5 matches
val lazyResult = articles.asSequence()
    .filter { it.id.toInt() % 2 == 0 }   // no intermediate list
    .map { it.title.uppercase() }         // no intermediate list
    .take(5)                              // stops after 5 found
    .toList()
// Total: ~10 operations + zero intermediate lists

When Sequences Are Slower

Sequences have overhead — setting up the pipeline and lambda dispatch. For small collections this overhead outweighs the benefit:

// ✅ Faster for small lists — no sequence overhead
val result1 = listOf(1, 2, 3, 4, 5).filter { it > 2 }.map { it * 2 }

// ❌ Slower for small lists — sequence setup not worth it
val result2 = listOf(1, 2, 3, 4, 5).asSequence().filter { it > 2 }.map { it * 2 }.toList()

The crossover point is roughly 1,000+ elements, or whenever you use early-termination operations like first(), find(), or take(n) on large collections.


Common Mistakes to Avoid

Mistake 1: Forgetting the terminal operation

val seq = listOf(1, 2, 3).asSequence().map { it * 2 }
// seq is still a Sequence — nothing computed yet, no result!

// ✅ Add terminal operation to get a result
val result = listOf(1, 2, 3).asSequence().map { it * 2 }.toList()   // [2, 4, 6]

Mistake 2: Calling sorted() on an infinite sequence

// ❌ Infinite loop — sorted() must consume all elements first
generateSequence(1) { it + 1 }.sorted().first()   // hangs forever

// ✅ Always bound infinite sequences with take() before sorting
generateSequence(1) { it + 1 }.take(100).sorted().first()   // 1

Mistake 3: Reusing a one-shot sequence

val lineSeq = File("data.txt").bufferedReader().lineSequence()

val count = lineSeq.count()      // consumes the sequence
val lines = lineSeq.toList()     // ❌ empty — already consumed!

// ✅ Collect to list first if you need to reuse
val lines = File("data.txt").readLines()
val count = lines.size
val filtered = lines.filter { it.isNotBlank() }

Mistake 4: Using sequences for small collections

// ❌ Unnecessary overhead for small lists
val result = listOf(1, 2, 3).asSequence().filter { it > 1 }.toList()

// ✅ Plain collection operations are faster here
val result = listOf(1, 2, 3).filter { it > 1 }

Summary

  • Sequences are lazily evaluated — operations define a pipeline but execute only when a terminal operation is called
  • Elements flow through the entire pipeline one at a time — not step by step across the whole collection
  • Create sequences with asSequence(), sequenceOf(), generateSequence(), or the sequence { } builder
  • Intermediate operations (map, filter, take) return a new Sequence — no work done yet
  • Terminal operations (toList(), first(), count()) trigger evaluation and return a concrete result
  • Sequences shine with large collections, early termination, and infinite streams
  • For small collections (<1,000 elements), regular collections are faster — sequence overhead isn't worth it
  • generateSequence is perfect for infinite streams, Fibonacci, and walking up parent/directory hierarchies
  • Never call sorted() on an infinite sequence — always take() first to bound it
  • One-shot sequences (from readers/streams) can only be iterated once — collect to a list if you need to reuse

Sequences are one of those tools you don't reach for every day — but when you have a large dataset, an infinite stream, or a pipeline with early termination, they make a dramatic difference in both performance and memory usage.

Happy coding!