In the previous tutorial, you learned about delegation. Now let’s learn about sequences. Sequences are lazy collections that process elements one at a time instead of all at once. This can make a big difference in performance when working with large data sets.

In this tutorial, you will learn:

  • Sequences vs Lists (lazy vs eager)
  • Creating sequences
  • generateSequence
  • Sequence builders with yield and yieldAll
  • Infinite sequences
  • When to use sequences
  • Performance comparison
  • Practical examples

Sequences vs Lists

Lists are eager — they process all elements at each step and create intermediate collections. Sequences are lazy — they process one element through all steps before moving to the next.

List:     filter ALL -> map ALL -> take result
Sequence: take one -> filter it -> map it -> next one -> ...

Here is the difference in action:

// List (eager) — processes ALL elements at each step
val listResult = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    .filter { println("List filter: $it"); it % 2 == 0 }
    .map { println("List map: $it"); it * 10 }
    .first()

Output: filters all 10, then maps all 5 evens, then takes the first.

// Sequence (lazy) — processes one element at a time
val seqResult = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    .asSequence()
    .filter { println("Seq filter: $it"); it % 2 == 0 }
    .map { println("Seq map: $it"); it * 10 }
    .first()

Output: filters 1 (fail), filters 2 (pass), maps 2, done! Only 2 elements processed instead of 10.

Both give the same result (20), but the sequence does much less work.

Creating Sequences

There are several ways to create a sequence.

From a Collection

val fromList = listOf(1, 2, 3, 4, 5).asSequence()

Using sequenceOf

val direct = sequenceOf(1, 2, 3, 4, 5)

Using generateSequence

val counting = generateSequence(1) { it + 1 }
    .take(5)
    .toList()
println(counting) // [1, 2, 3, 4, 5]

With Null Termination

Return null to stop the sequence:

val halving = generateSequence(100) { if (it > 1) it / 2 else null }
    .toList()
println(halving) // [100, 50, 25, 12, 6, 3, 1]

Using sequence Builder

val custom = sequence {
    yield(1)
    yield(2)
    yield(3)
}
println(custom.toList()) // [1, 2, 3]

generateSequence

generateSequence creates a sequence from a seed value and a function that produces the next value.

Fibonacci

val fibonacci = generateSequence(Pair(0, 1)) {
        Pair(it.second, it.first + it.second)
    }
    .map { it.first }
    .take(10)
    .toList()
println(fibonacci) // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Powers of 2

val powersOf2 = generateSequence(1) { it * 2 }
    .take(10)
    .toList()
println(powersOf2) // [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]

Countdown

val countdown = generateSequence(10) { if (it > 0) it - 1 else null }
    .toList()
println(countdown) // [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

Sequence Builders: yield and yieldAll

The sequence { } builder uses yield() to produce one value and yieldAll() to produce many values.

yield

val simple = sequence {
    yield(1)
    println("After 1")
    yield(2)
    println("After 2")
    yield(3)
}

println(simple.first()) // 1
// "After 1" is NOT printed — the sequence is lazy!

The sequence stops as soon as first() gets what it needs.

yieldAll

val combined = sequence {
    yield(0)
    yieldAll(listOf(1, 2, 3))
    yieldAll(generateSequence(4) { it + 1 })
}
println(combined.take(8).toList()) // [0, 1, 2, 3, 4, 5, 6, 7]

Flatten Nested Structures

data class Category(val name: String, val items: List<String>)

val categories = listOf(
    Category("Fruits", listOf("Apple", "Banana")),
    Category("Veggies", listOf("Carrot", "Pea")),
    Category("Grains", listOf("Rice", "Wheat", "Oat"))
)

val allItems = sequence {
    for (category in categories) {
        yieldAll(category.items)
    }
}
println(allItems.toList())
// [Apple, Banana, Carrot, Pea, Rice, Wheat, Oat]

Infinite Sequences

Sequences can be infinite. Just make sure you use take(), first(), or another terminal operation that limits the output.

Natural Numbers

val naturals = generateSequence(1) { it + 1 }
println(naturals.take(5).toList()) // [1, 2, 3, 4, 5]

Find Primes

fun isPrime(n: Int): Boolean {
    if (n < 2) return false
    for (i in 2..Math.sqrt(n.toDouble()).toInt()) {
        if (n % i == 0) return false
    }
    return true
}

val firstPrimeAbove100 = generateSequence(101) { it + 1 }
    .filter { isPrime(it) }
    .first()
println(firstPrimeAbove100) // 101

val first10Primes = generateSequence(2) { it + 1 }
    .filter { isPrime(it) }
    .take(10)
    .toList()
println(first10Primes) // [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

The sequence only computes what is needed. It does not check every number up to infinity — it stops as soon as it finds enough primes.

When to Use Sequences

Use sequences when:

  1. The collection is large (thousands of elements or more)
  2. You chain multiple operations (filter, map, etc.)
  3. You only need some elements (first(), take())
  4. You want to avoid intermediate collections

Use lists when:

  1. The collection is small
  2. You need random access (list[index])
  3. You need the full result anyway
  4. You have a single operation

Example: Large Collection

val largeList = (1..1_000_000).toList()

// List: creates 2 intermediate lists (1M and 500K elements)
val listResult = largeList
    .filter { it % 2 == 0 }
    .map { it * 2 }
    .take(5)

// Sequence: processes only 10 elements total
val seqResult = largeList
    .asSequence()
    .filter { it % 2 == 0 }
    .map { it * 2 }
    .take(5)
    .toList()

Both produce the same result, but the sequence uses much less memory and does much less work.

Performance Comparison

Here is a comparison with a million elements:

val data = (1..1_000_000).toList()

// List approach — creates intermediate collections
val listTime = measureTimeMillis {
    data
        .filter { it % 3 == 0 }
        .map { it.toLong() * it }
        .filter { it > 1000 }
        .take(10)
}

// Sequence approach — processes lazily
val seqTime = measureTimeMillis {
    data
        .asSequence()
        .filter { it % 3 == 0 }
        .map { it.toLong() * it }
        .filter { it > 1000 }
        .take(10)
        .toList()
}

println("List: ${listTime}ms")
println("Sequence: ${seqTime}ms")

The sequence version is faster because it only processes the few elements needed to get 10 results, while the list version processes all 1 million elements through each step.

Practical Examples

Process Lines (Like a File)

fun processLines(lines: List<String>): List<String> {
    return lines.asSequence()
        .filter { it.isNotBlank() }
        .map { it.trim() }
        .filter { !it.startsWith("#") } // Skip comments
        .map { it.uppercase() }
        .toList()
}

val lines = listOf(
    "# This is a comment",
    "  hello world  ",
    "",
    "  kotlin is great  ",
    "# Another comment",
    "  sequences are lazy  "
)
println(processLines(lines))
// [HELLO WORLD, KOTLIN IS GREAT, SEQUENCES ARE LAZY]

Paginated Data

Simulate fetching pages of data:

fun fetchPages(): Sequence<List<String>> {
    var page = 0
    return generateSequence {
        if (page < 5) {
            val data = listOf("Item ${page * 3 + 1}", "Item ${page * 3 + 2}", "Item ${page * 3 + 3}")
            page++
            data
        } else null
    }
}

val allData = fetchPages().flatten().toList()
println(allData) // [Item 1, Item 2, ..., Item 15]

Collatz Sequence

The Collatz sequence is a famous math problem. Start with any number. If even, divide by 2. If odd, multiply by 3 and add 1. The sequence always reaches 1.

fun collatzSequence(start: Int): List<Int> {
    return generateSequence(start) { n ->
        when {
            n == 1 -> null
            n % 2 == 0 -> n / 2
            else -> 3 * n + 1
        }
    }.toList()
}

println(collatzSequence(6))
// [6, 3, 10, 5, 16, 8, 4, 2, 1]

// Find starting number (1-100) with longest Collatz sequence
val longest = (1..100)
    .asSequence()
    .map { it to collatzSequence(it).size }
    .maxByOrNull { it.second }
println("Start: ${longest?.first}, Length: ${longest?.second}")

Batch Processing

Process items in chunks:

fun processBatches(items: List<String>, batchSize: Int): List<String> {
    return items.asSequence()
        .chunked(batchSize)
        .map { batch -> batch.map { it.uppercase() } }
        .flatten()
        .toList()
}

val items = (1..10).map { "item$it" }
println(processBatches(items, 3))
// [ITEM1, ITEM2, ITEM3, ITEM4, ITEM5, ITEM6, ITEM7, ITEM8, ITEM9, ITEM10]

This is useful when you need to process data in batches, like sending API requests or writing to a database.

Sequence Operations Reference

Here are the most common sequence operations:

Intermediate Operations (Lazy)

These return a new sequence and are not executed until a terminal operation is called:

val seq = (1..10).asSequence()

seq.filter { it > 5 }           // Keep matching elements
seq.map { it * 2 }              // Transform elements
seq.flatMap { listOf(it, it) }  // Transform to collection and flatten
seq.take(3)                     // Take first N
seq.drop(3)                     // Skip first N
seq.distinct()                  // Remove duplicates
seq.sorted()                    // Sort (collects all elements!)
seq.zip(otherSeq)               // Pair with another sequence
seq.chunked(3)                  // Group into chunks

Terminal Operations (Eager)

These trigger the computation and produce a result:

val seq = (1..10).asSequence()

seq.toList()        // Convert to list
seq.toSet()         // Convert to set
seq.first()         // First element
seq.last()          // Last element (processes all!)
seq.count()         // Count elements
seq.sum()           // Sum of numbers
seq.average()       // Average of numbers
seq.min()           // Minimum
seq.max()           // Maximum
seq.any { it > 5 }  // Any match?
seq.all { it > 0 }  // All match?
seq.none { it < 0 } // None match?
seq.forEach { }     // Execute for each
seq.joinToString()  // Join to string

Warning: sorted() is an intermediate operation, but it must collect all elements to sort them. This removes the lazy benefit. If you need sorting, consider whether a list would be better.

Important Rules

  1. Sequences are lazy — nothing happens until you call a terminal operation (toList(), first(), sum(), count(), etc.)
  2. Call toList() when you need the result as a list
  3. Never iterate an infinite sequence without take() or first()
  4. Order matters — put filter before map to reduce work
  5. Don’t overuse sequences — for small collections, lists are fine and simpler

Summary

ConceptExample
Create from listlist.asSequence()
Create directlysequenceOf(1, 2, 3)
GenerategenerateSequence(1) { it + 1 }
Buildsequence { yield(1); yieldAll(list) }
Terminal operation.toList(), .first(), .sum()
Intermediate operation.filter(), .map(), .take()

Key takeaways:

  • Sequences are lazy, lists are eager
  • Sequences process one element at a time through all steps
  • Use sequences for large collections with multiple operations
  • Use lists for small collections or when you need random access
  • Always end a sequence chain with a terminal operation
  • Never iterate an infinite sequence without take() or first()

Source Code

You can find the complete source code for this tutorial on GitHub:

KT-16 Source Code on GitHub

What’s Next?

Congratulations! You have completed the Intermediate series of the Kotlin Tutorial. You now know:

  1. Lambdas and higher-order functions
  2. Extension functions and properties
  3. Scope functions
  4. Sealed classes, enum classes, and value classes
  5. Interfaces, generics, and type constraints
  6. Error handling
  7. Delegation
  8. Sequences

With these skills, you are ready for more advanced topics. In the next tutorial, you will learn about coroutines — Kotlin’s way of writing asynchronous code without blocking threads.


This is part 16 of the Kotlin Tutorial series. Check out Part 15: Delegation if you missed it. Need a quick reference? See the Kotlin Cheat Sheet.