📝 Lesson 1.3: Iterators and Lazy Sequences
What if a sequence could produce its values one at a time, on demand — even an infinite one? The yield keyword lets you write iterators that generate items lazily, the same mechanism that powers LINQ's efficiency.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain the pull-based model behind
IEnumerable/IEnumerator - Write iterator methods with
yield returnandyield break - Explain lazy (deferred) evaluation and its benefits
- Create infinite sequences and consume them safely
- Avoid the common pitfalls of deferred iterators
Estimated Time: 75 minutes
Project: Build lazy, composable sequences with custom iterators.
In This Lesson
The Pull Model
A foreach loop doesn't receive a whole collection at once — it pulls items one at a time. Under the hood, IEnumerable<T> hands out an IEnumerator<T>, and the loop repeatedly calls MoveNext() and reads Current until there's nothing left.
Historically, implementing that enumerator by hand was tedious boilerplate. The yield keyword lets the compiler write it for you — you just describe what to produce, and in what order.
📖 Definition
Iterator method: a method that returns IEnumerable<T> (or IEnumerator<T>) and uses yield return to produce values one at a time. The compiler turns it into a lazy state machine.
yield return
An iterator method uses yield return to hand back the next value. Execution pauses there and resumes on the following pull:
public static IEnumerable<int> Countdown(int from)
{
for (int i = from; i >= 1; i--)
{
yield return i; // produce i, then pause here until the next pull
}
yield return 0; // and finally 0
}
foreach (int n in Countdown(3))
{
Console.Write($"{n} ");
}
// 3 2 1 0
Use yield break to stop the sequence early — it ends the iteration, like return in a normal method:
public static IEnumerable<int> TakeWhilePositive(IEnumerable<int> source)
{
foreach (int n in source)
{
if (n <= 0) yield break; // stop entirely at the first non-positive
yield return n;
}
}
// From { 3, 7, 2, -1, 9 } → yields 3, 7, 2 then stops
💡 What you can't do in an iterator
An iterator method can't use return someValue; (only yield return/yield break), and can't have yield inside a try with a catch. Also, ref/out parameters aren't allowed. These constraints exist because the method is rewritten into a resumable state machine.
Lazy Evaluation
The defining feature of iterators is laziness: none of the method body runs until you start enumerating, and each item is produced only when pulled. This is the same deferred execution you saw with LINQ in Intermediate — now you understand its source.
public static IEnumerable<int> Numbers()
{
Console.WriteLine(" [producing 1]"); yield return 1;
Console.WriteLine(" [producing 2]"); yield return 2;
Console.WriteLine(" [producing 3]"); yield return 3;
}
Console.WriteLine("Before foreach");
var seq = Numbers(); // nothing printed yet — body hasn't run
Console.WriteLine("Created; now iterating");
foreach (int n in seq)
{
Console.WriteLine($"Got {n}");
}
Output — note the interleaving:
Before foreach
Created; now iterating
[producing 1]
Got 1
[producing 2]
Got 2
[producing 3]
Got 3
✅ Why laziness is powerful
- Memory: you never hold the whole sequence at once — great for huge or streamed data.
- Short-circuiting: operators like
FirstorTake(5)stop pulling early, so unneeded items are never computed. - Composability: chained lazy operators form a pipeline that runs in a single pass, item by item.
The Hidden State Machine
How can a method "pause" and "resume"? The compiler rewrites your iterator into a hidden class — a state machine — that remembers where it left off (which line, and the values of local variables) between pulls.
// You write this...
public static IEnumerable<int> TwoValues()
{
yield return 10;
yield return 20;
}
// ...the compiler generates (conceptually) a class that tracks a 'state'
// field: state 0 → return 10 and remember "next time, go to state 1";
// state 1 → return 20; state 2 → done. MoveNext() advances the state.
💡 The mental model: eachyield returnis a bookmark. When the enumerator'sMoveNext()is called, the state machine jumps back to the last bookmark, runs until the nextyield, and pauses again — carrying your local variables along.
💡 This is exactly how LINQ works internally
Operators like Where and Select are implemented as iterator methods using yield return. That's why a LINQ chain is lazy and single-pass — each operator pulls one item from the previous one, transforms or filters it, and yields it onward.
Infinite Sequences
Because items are produced on demand, an iterator can describe a sequence with no end — you just take as many as you need. A finite List could never do this:
public static IEnumerable<int> Naturals()
{
int n = 1;
while (true) // infinite — but that's fine, it's lazy!
{
yield return n++;
}
}
// Take just the first 5 — the loop only runs 5 times
foreach (int n in Naturals().Take(5))
{
Console.Write($"{n} ");
}
// 1 2 3 4 5
A classic example — an infinite Fibonacci sequence, consumed lazily with LINQ:
public static IEnumerable<long> Fibonacci()
{
long a = 0, b = 1;
while (true)
{
yield return a;
(a, b) = (b, a + b); // tuple deconstruction to advance
}
}
var firstTen = Fibonacci().Take(10).ToList();
Console.WriteLine(string.Join(", ", firstTen));
// 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
⚠️ Never fully enumerate an infinite sequence
Calling Fibonacci().ToList() or foreach-ing it without a Take/First/other limit will loop forever (or until it overflows). Always bound an infinite iterator with a limiting operator.
Pitfalls of Deferred Iterators
⚠️ Re-enumeration re-runs everything
Each time you iterate a lazy sequence, the iterator runs again from the start. If producing items is expensive (a query, a web call), iterating twice does the work twice:
var results = ExpensiveQuery(); // deferred
var count = results.Count(); // runs the query once
var first = results.First(); // runs it AGAIN
// Fix: materialize once
var list = ExpensiveQuery().ToList();
var count2 = list.Count; // no re-run
var first2 = list[0];
⚠️ Deferred exceptions and captured state
Because the body doesn't run until enumeration, an argument-validation exception thrown inside an iterator won't fire when the method is called — only when it's first iterated. A common pattern is to split validation into a non-iterator wrapper that calls a private iterator, so bad arguments fail immediately.
💡 When to prefer eager over lazy
Laziness is great for large/streamed/short-circuited data. But if the sequence is small, iterated multiple times, or you want side effects to happen once and immediately, materialize it with ToList()/ToArray(). Choose deliberately — the whole point of understanding iterators is knowing which behavior you're getting.
Exercise & Quiz
🏋️ Exercise: Build Lazy Sequences
Objective: Write iterator methods and a custom lazy operator.
Instructions:
- Create a new project called
Iterators. - Write
IEnumerable<int> Range(int start, int count)usingyield return(don't use the built-inEnumerable.Range). - Write an infinite
IEnumerable<int> Powers(int baseValue)that yieldsbaseValue^0, baseValue^1, baseValue^2, .... Print the first 6 powers of 2 with.Take(6). - Write a custom lazy extension method
IEnumerable<T> EveryOther<T>(this IEnumerable<T> source)that yields every second item (indices 0, 2, 4, …). Test it on yourRange.
Starter Code:
foreach (var n in Powers(2).Take(6)) Console.Write($"{n} "); // 1 2 4 8 16 32
Console.WriteLine();
Console.WriteLine(string.Join(", ", Range(1, 10).EveryOther())); // 1, 3, 5, 7, 9
static IEnumerable<int> Range(int start, int count)
{
// TODO: yield count values starting at start
}
static IEnumerable<int> Powers(int baseValue)
{
// TODO: infinite — yield baseValue^0, ^1, ^2, ...
}
public static class SeqExtensions
{
public static IEnumerable<T> EveryOther<T>(this IEnumerable<T> source)
{
// TODO: yield items at index 0, 2, 4, ...
}
}
💡 Hint
Range: a for loop yield return start + i;. Powers: keep a running long current = 1;, yield return it, then current *= baseValue;, forever. EveryOther: track a bool toggle or an index counter, yield only when it's even.
✅ Solution
foreach (var n in Powers(2).Take(6)) Console.Write($"{n} "); // 1 2 4 8 16 32
Console.WriteLine();
Console.WriteLine(string.Join(", ", Range(1, 10).EveryOther())); // 1, 3, 5, 7, 9
static IEnumerable<int> Range(int start, int count)
{
for (int i = 0; i < count; i++)
{
yield return start + i;
}
}
static IEnumerable<int> Powers(int baseValue)
{
long current = 1;
while (true)
{
yield return (int)current;
current *= baseValue;
}
}
public static class SeqExtensions
{
public static IEnumerable<T> EveryOther<T>(this IEnumerable<T> source)
{
int index = 0;
foreach (T item in source)
{
if (index % 2 == 0)
{
yield return item;
}
index++;
}
}
}
🎯 Quick Quiz
Question 1: What does yield return do?
Question 2: When does the body of an iterator method start running?
Question 3: How can an infinite iterator like Naturals() be safe to use?
Summary
🎉 Key Takeaways
IEnumerableis a pull model:foreachrepeatedly callsMoveNext()and readsCurrent.yield returnproduces a value and pauses;yield breakends the sequence. The compiler builds a resumable state machine.- Iterators are lazy — the body runs on enumeration, one item at a time — saving memory and enabling short-circuiting (this is how LINQ works).
- Iterators can describe infinite sequences; bound them with
Take/First— never fully enumerate them. - Beware re-enumeration (repeats the work) and deferred exceptions; materialize with
ToList()when eager is what you want.
📚 Additional Resources
🚀 What's Next?
That completes Module 1! You've mastered advanced language features. In Module 2, we tackle running code on many threads. First up: Lesson 2.1: Threads, the Thread Pool, and Tasks — the foundation of concurrency.
🎉 Module 1 complete!
Variance, extensions, and iterators — you command C#'s advanced language features. Next: concurrency.