Sequences

Lazy evaluation, asSequence, generateSequence, and when a lazy pipeline actually wins over an eager list chain.

Lazy evaluation asSequence generateSequence Intermediate vs terminal When it wins
πŸ’€

Lazy Evaluation

A List operator chain runs each step to completion, front to back, allocating a full intermediate list between every step. A Sequence chain instead processes one element through the entire chain of operations before moving to the next element β€” no step waits for the previous one to finish the whole collection first. This single change in evaluation order is the entire idea behind sequences; everything else on this page follows from it.

  List (eager): step-by-step over the WHOLE collection

    [1,2,3,4,5] --filter--> [2,4]     --map--> [4,16]     (2 full passes,
                (full pass)            (full pass)          2 intermediate lists)

  Sequence (lazy): element-by-element through the WHOLE chain

    1 --filter(fail)-------------------------------X
    2 --filter(pass)--> --map--> 4  ---------------> collected
    3 --filter(fail)-------------------------------X
    4 --filter(pass)--> --map--> 16 --------------> collected
    5 --filter(fail)-------------------------------X

    (1 pass total, no intermediate list β€” each element flows straight
     through filter then map before the next element is even touched)
πŸ’‘
Nothing runs at all until a terminal operation is called β€” building a sequence and chaining filter/map on it does no work whatsoever by itself. This is the same "cold" idea that shows up again with Flow: defining the pipeline and running it are two separate steps.
πŸ”€

asSequence

The most common way to get a Sequence is converting an existing collection with .asSequence(). This is a thin, near-free wrapper β€” it doesn't copy the underlying data, just changes how iteration over it behaves for whatever chain follows.

Converting a chain from eager to lazy asSequence
val numbers = (1..1_000_000).toList()

// Eager: allocates a full ~500,000-element list after filter,
// then a full list again after map, before take(3) ever runs
val eagerResult = numbers
    .filter { it % 2 == 0 }
    .map { it * it }
    .take(3)

// Lazy: only as many elements as needed to satisfy take(3) are
// ever pulled through filter and map β€” evaluation stops early
val lazyResult = numbers.asSequence()
    .filter { it % 2 == 0 }
    .map { it * it }
    .take(3)
    .toList()   // terminal operation: triggers the actual work

println(lazyResult)  // [4, 16, 36]
♾️

generateSequence

generateSequence builds a sequence from a seed value and a function computing the next element from the previous one, with no predetermined end β€” the sequence can be genuinely infinite, since nothing is computed until something downstream asks for it. This is something a List fundamentally can't represent (a list has a size, known up front); it's a place sequences do something collections structurally cannot, not just something they do more efficiently.

An infinite sequence, safely consumed generateSequence
// Powers of two: 1, 2, 4, 8, ... with no upper bound defined
val powersOfTwo = generateSequence(1) { it * 2 }

// take() is what makes this safe: only 5 elements are ever computed
println(powersOfTwo.take(5).toList())  // [1, 2, 4, 8, 16]

// A seed function instead of a fixed value, for lazy/random starts
val randomWalk = generateSequence(seedFunction = { 0 }) { it + (-1..1).random() }
println(randomWalk.take(5).toList())

// A nullable next function defines its own stopping point instead
val untilNull = generateSequence(1) { if (it < 5) it + 1 else null }
println(untilNull.toList())  // [1, 2, 3, 4, 5]: stops once the next function returns null
⚠️
Calling .toList() or any other unbounded terminal operation directly on a generateSequence with no natural stopping condition never returns β€” there's nothing to stop it. Always pair a truly infinite sequence with take(n), takeWhile { }, or a next function that eventually returns null.
βš™οΈ

Intermediate vs Terminal Operations

Every operation in a sequence chain is one of two kinds. Intermediate operations (filter, map, take, drop) are themselves lazy β€” calling one just records a step in the pipeline and returns another unevaluated Sequence, doing no actual work. Terminal operations (toList, sum, first, forEach, count) are what actually pulls elements through the whole pipeline, and there's exactly one terminal operation per chain, always the last call.

Nothing happens until the terminal call Lazy vs eager
val pipeline = listOf(1, 2, 3, 4, 5).asSequence()
    .map {
        println("mapping $it")
        it * it
    }
    .filter {
        println("filtering $it")
        it > 5
    }
// Nothing printed yet β€” map and filter are intermediate, not evaluated

val result = pipeline.first()   // terminal: pulls elements one at a time until one matches
// mapping 1
// filtering 1
// mapping 2
// filtering 4
// mapping 3
// filtering 9      ← 9 > 5: first() stops here, never touches 4 or 5
println(result)  // 9
βš–οΈ

When Sequences Win, When They Don't

πŸ’‘
Sequences win when either the collection is large and the chain has several steps (avoiding multiple full intermediate lists actually adds up), or a short-circuiting terminal operation (first, find, any, take(n)) means most of the collection never needs processing at all β€” the first() example above only touched 3 of 5 elements. Lists win for short chains over small collections, where the sequence machinery's own iterator overhead costs more than the intermediate lists it's avoiding, and for anything needing random access or a known size up front, which sequences don't provide.
Situation Prefer Why
Large collection, several chained operatorsSequenceAvoids one full intermediate list per step
Short-circuiting terminal op (first, take(n), any)SequenceStops early; may never process most elements
Small collection, one or two operatorsListSequence overhead outweighs the savings
Need the size upfront, or random access by indexListSequence has neither without a terminal op
Infinite or unbounded generationSequenceLists can't represent an unbounded collection at all
πŸ“‹

Quick Reference

Concept Syntax Notes
From a collectionlist.asSequence()Thin wrapper, no data copy
Fixed valuessequenceOf(1, 2, 3)Direct sequence construction
Infinite from seedgenerateSequence(1) { it * 2 }Must pair with take/takeWhile to terminate
Bounded by nullgenerateSequence(1) { if (...) it + 1 else null }Stops when the next function returns null
Intermediate operationfilter / map / take / dropLazy: records a step, does no work yet
Terminal operationtoList / sum / first / forEach / countTriggers evaluation of the whole pipeline
Large chain, large dataprefer SequenceNo per-step intermediate collection
Small chain, small dataprefer ListSequence's iterator overhead isn't worth it