Day 22: IEnumerable and Iterators - The Conveyor Belt Behind Your Loops
arrow_back All Posts
April 06, 2026 12 min read .NET/C#

Day 22: IEnumerable and Iterators - The Conveyor Belt Behind Your Loops

Yesterday you learned how to bundle values together with tuples and rip them apart with deconstruction — like packing a suitcase and then immediately dumping it on the hotel bed. Today we're exploring something you've been using since Day 5 without fully understanding: how foreach actually gets its items. Welcome to IEnumerable<T> and iterators — the behind-the-scenes conveyor belt that feeds your loops one item at a time.

1. What Is IEnumerable<T>? The One-Item-at-a-Time Contract

Every time you've written a foreach loop over a list, an array, or even a string, you've been relying on IEnumerable<T>. It's an interface — a contract — that says exactly one thing: "I can give you items, one at a time."

That's it. Not "I know how many items I have." Not "you can access item number 7 directly." Just: "Ask me for the next item, and I'll either give you one or tell you I'm done."

Think of it like a vending machine with a single button. You press the button, you get a snack. Press it again, another snack. Eventually it's empty and nothing comes out. You can't peek inside to count the snacks, and you can't skip ahead to the good stuff — you just keep pressing until it's done.

IEnumerable<string> names = new List<string> { "Alice", "Bob", "Charlie" };

foreach (string name in names)
{
    Console.WriteLine(name);
}

The List<string> implements IEnumerable<string>, which is why foreach works. But here's the thing — foreach doesn't care that it's a List. It only cares that the object implements IEnumerable<T>. You could swap in an array, a HashSet, or something you built yourself, and foreach wouldn't blink.

2. How foreach Actually Works — The Machinery Behind the Magic

Time to pull back the curtain. When the compiler sees your foreach loop, it doesn't just magically iterate. It rewrites your code into something like this:

IEnumerator<string> enumerator = names.GetEnumerator();

try
{
    while (enumerator.MoveNext())
    {
        string name = enumerator.Current;
        Console.WriteLine(name);
    }
}
finally
{
    enumerator.Dispose();
}

Three players make this work:

  • GetEnumerator() — returns an enumerator object, which is like hiring a tour guide who remembers where you stopped.
  • MoveNext() — advances to the next item. Returns true if there's something there, false if the collection is exhausted.
  • Current — the property that holds whatever item the enumerator is currently pointing at.

So IEnumerable<T> is the collection — it says "I have items." IEnumerator<T> is the cursor — it walks through them. The collection is the museum; the enumerator is the tour guide with the little flag.

You'll almost never implement IEnumerator<T> by hand, because C# has a far better trick up its sleeve.

3. yield return — Building Iterators Without the Boilerplate

Imagine someone told you: "To create a custom sequence, you need to build a class that implements IEnumerator<T>, track your position in a field, handle MoveNext() logic, wire up Current, implement Reset(), and don't forget Dispose()."

You'd close the laptop and go outside.

Thankfully, C# has yield return — a keyword that lets you write an iterator method as if you're just writing a normal method with return statements, except the method pauses after each value and resumes where it left off when the caller asks for the next one.

IEnumerable<int> CountToFive()
{
    yield return 1;
    yield return 2;
    yield return 3;
    yield return 4;
    yield return 5;
}

foreach (int number in CountToFive())
{
    Console.WriteLine(number);
}

Each yield return hands back a value and freezes the method's state. When MoveNext() is called again, the method wakes up right after the last yield return and keeps going. It's like a bookmark — the method remembers exactly where it was.

The compiler secretly generates a whole state machine class behind the scenes — a hidden class with fields tracking local variables, a switch statement mapping resume points, and enough plumbing to make your eyes water. You get the clean syntax; the compiler gets the headache. Fair trade.

A few rules about iterator methods:

  • The return type must be IEnumerable<T>, IEnumerable, IEnumerator<T>, or IEnumerator.
  • You can't use yield return inside a try block that has a catch clause. (A try/finally is fine, though.)
  • You can't use ref or out parameters in an iterator method.
  • You can use multiple yield return statements, loops, conditionals — whatever you'd normally write.
IEnumerable<string> GetGreetings(string[] names)
{
    foreach (string name in names)
    {
        yield return $"Hello, {name}!";
    }
}

foreach (string greeting in GetGreetings(["Alice", "Bob", "Charlie"]))
{
    Console.WriteLine(greeting);
}
// Hello, Alice!
// Hello, Bob!
// Hello, Charlie!

Notice that GetGreetings has a return type of IEnumerable<string> but never constructs a list. It yields values one at a time, and the caller consumes them one at a time. No intermediate collection needed.

4. Lazy Evaluation — Items on Demand, Not All at Once

Here's where things get interesting. Iterator methods using yield return are lazy — they don't execute any code until someone actually starts iterating. And they only run just enough code to produce the next item.

IEnumerable<int> GetNumbers()
{
    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");

foreach (int n in GetNumbers())
{
    Console.WriteLine($"Got {n}");
}

Output:

Before foreach
Producing 1
Got 1
Producing 2
Got 2
Producing 3
Got 3

See how "Producing" and "Got" messages alternate? The method doesn't race ahead and produce all three values — it produces one, pauses, waits for the consumer to come back, then produces the next. This is lazy evaluation in action.

Why does this matter? Because you can work with sequences that would be absurd to generate all at once:

IEnumerable<int> AllPositiveIntegers()
{
    int i = 1;
    while (true)
    {
        yield return i++;
    }
}

// Take just the first 10 — the infinite loop never "runs away"
foreach (int n in AllPositiveIntegers().Take(10))
{
    Console.WriteLine(n);
}

An infinite loop inside a method that returns normally? Only with yield return. The method produces values on demand, so as long as the consumer stops asking at some point, the infinite loop is perfectly safe. It's like a tap — water doesn't flood your house just because the pipe goes to a reservoir. You only get what you turn on.

This also means that calling an iterator method doesn't execute any code at all. The method body is deferred entirely until the first MoveNext() call. Watch:

IEnumerable<int> Surprise()
{
    Console.WriteLine("You called?");
    yield return 42;
}

var result = Surprise();  // Nothing printed yet!
Console.WriteLine("Haven't iterated yet...");

foreach (int n in result)  // NOW "You called?" appears
{
    Console.WriteLine(n);
}

This catches people off guard — especially when they put argument validation at the top of an iterator method. The validation won't run until iteration begins, which might be much later (or never). If you need immediate validation, split the method: a public method that validates and then calls a private iterator method.

5. yield break — The Emergency Stop Button

Sometimes you want your iterator to quit early — maybe a condition is met, or the data source is exhausted. That's what yield break does. It signals "I'm done, stop calling me."

IEnumerable<int> NumbersUntilNegative(int[] values)
{
    foreach (int value in values)
    {
        if (value < 0)
            yield break;

        yield return value;
    }
}

int[] data = [10, 20, 30, -1, 40, 50];

foreach (int n in NumbersUntilNegative(data))
{
    Console.WriteLine(n);
}
// 10
// 20
// 30

The moment the method hits yield break, iteration ends — MoveNext() returns false, and the foreach loop exits. The values 40 and 50 never get touched.

You can think of yield break as the return statement for iterators. A normal return; (without a value) isn't allowed in an iterator method — the compiler will yell at you. Use yield break instead.

Here's a more realistic example — imagine you're reading items from an external source and want to stop when you hit a sentinel value:

IEnumerable<string> ReadUntilQuit()
{
    while (true)
    {
        Console.Write("Enter a name (or 'quit'): ");
        string input = Console.ReadLine()!;

        if (input.Equals("quit", StringComparison.OrdinalIgnoreCase))
            yield break;

        yield return input;
    }
}

List<string> names = ReadUntilQuit().ToList();
Console.WriteLine($"You entered {names.Count} names.");

The iterator keeps yielding names until the user types "quit", then yield break shuts the whole thing down gracefully. No boolean flags, no break in a calling loop — the producer decides when it's done.

6. Practical Uses — Where Iterators Really Shine

Iterators aren't just a party trick for interview questions. They solve real problems elegantly.

Reading Large Files Line by Line

Loading a 2 GB log file into a List<string>? Your app's memory usage just entered orbit. With an iterator, you read one line at a time:

IEnumerable<string> ReadLines(string filePath)
{
    using StreamReader reader = new(filePath);

    while (reader.ReadLine() is string line)
    {
        yield return line;
    }
}

foreach (string line in ReadLines("server.log").Where(l => l.Contains("ERROR")))
{
    Console.WriteLine(line);
}

Only one line lives in memory at a time. You could process a file larger than your total RAM this way — the iterator reads lazily and the garbage collector cleans up behind it.

Infinite Sequences

We saw AllPositiveIntegers() earlier. This pattern is great for ID generators, random value streams, or any "keep going until I tell you to stop" scenario:

IEnumerable<string> GenerateIds(string prefix)
{
    int counter = 0;
    while (true)
    {
        yield return $"{prefix}-{counter++:D6}";
    }
}

// Take 5 order IDs
var orderIds = GenerateIds("ORD").Take(5).ToList();
// ["ORD-000000", "ORD-000001", "ORD-000002", "ORD-000003", "ORD-000004"]

Custom Collection Wrappers

Want to expose a filtered or transformed view of internal data without copying it?

public class StudentRoster(List<Student> students)
{
    public IEnumerable<Student> GetHonorStudents()
    {
        foreach (Student s in students)
        {
            if (s.Gpa >= 3.5)
                yield return s;
        }
    }
}

The caller gets a lazy sequence — no new list is allocated, and if they only need the first honor student, only the data up to that point is evaluated.

7. IEnumerable vs IList vs ICollection — Choosing the Right Abstraction

This is one of those things that trips up even experienced developers. You've got three main collection interfaces, and picking the right one for your method signatures matters.

  • IEnumerable<T> — "I can be iterated." That's all. No count, no indexing, no adding or removing. Read-forward-only.
  • ICollection<T> — everything IEnumerable<T> does, plus Count, Add, Remove, Contains, and Clear.
  • IList<T> — everything ICollection<T> does, plus integer indexing (list[3]) and Insert/RemoveAt at specific positions.

Here's the rule of thumb that'll serve you well:

Accept the most general type. Return the most specific type your caller needs.

// GOOD: accepts IEnumerable — works with lists, arrays, sets, lazy sequences, anything
void PrintAll(IEnumerable<string> items)
{
    foreach (string item in items)
        Console.WriteLine(item);
}

// GOOD: returns List when the caller will need Count, indexing, or mutation
List<string> GetActiveUsers()
{
    return dbContext.Users
        .Where(u => u.IsActive)
        .Select(u => u.Name)
        .ToList();
}

Why accept IEnumerable<T>? Because it keeps your method flexible. If your method only needs to loop through items, don't demand a List<T> — you'd be locking out arrays, sets, and lazy sequences for no reason. It's like requiring a fire truck when all you need is water.

When returning, consider what your caller actually needs. If they'll just iterate, return IEnumerable<T>. If they need to know the count or index into it, return IList<T> or List<T>. Don't return IEnumerable<T> when you've already materialized a list internally — you'd force the caller to call .ToList() themselves, which is just wasteful.

One gotcha: if you return a lazy IEnumerable<T> from a method that uses a database connection, the connection might close before the caller iterates. When in doubt about resource lifetimes, materialize with .ToList() before returning.

Here's a quick reference table:

Feature IEnumerable<T> ICollection<T> IList<T>
foreach support
Count property
Add / Remove
Index access [i]
Lazy-friendly

8. Your Homework: Build a Fibonacci Generator

Time to put yield return to work. Write a method that generates the Fibonacci sequence — where each number is the sum of the two before it (0, 1, 1, 2, 3, 5, 8, 13, 21, ...).

Requirements:

  1. The method should return IEnumerable<long> and use yield return.
  2. It should be an infinite sequence — don't hardcode a limit.
  3. In your Main method, use .Take() to grab the first 20 Fibonacci numbers and print them.
  4. Bonus: write a second method that takes the Fibonacci sequence and filters it to only return values divisible by 3.

Here's a skeleton to get you started:

IEnumerable<long> Fibonacci()
{
    long previous = 0;
    long current = 1;

    yield return previous;
    yield return current;

    while (true)
    {
        long next = previous + current;
        yield return next;
        previous = current;
        current = next;
    }
}

// Grab the first 20
foreach (long fib in Fibonacci().Take(20))
{
    Console.Write($"{fib} ");
}
// 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181

Console.WriteLine();

// Bonus: Fibonacci numbers divisible by 3
foreach (long fib in Fibonacci().Take(20).Where(n => n % 3 == 0))
{
    Console.Write($"{fib} ");
}
// 0 3 21 144 987

Notice how the infinite while (true) loop inside Fibonacci() never causes a problem — .Take(20) only asks for 20 values, and the method politely pauses forever after that. No stack overflow, no runaway loop, no drama.

Summary of Day 22

  • IEnumerable<T> is a contract that says "I can give you items one at a time" — it's the foundation of every foreach loop in C#.
  • Behind the scenes, foreach calls GetEnumerator(), then repeatedly calls MoveNext() and reads Current until the sequence ends.
  • yield return lets you build iterator methods without the agony of implementing IEnumerator<T> manually — the compiler generates the state machine for you.
  • Iterator methods are lazily evaluated — no code runs until someone starts iterating, and only enough code runs to produce the next item.
  • yield break stops an iterator early, signaling that no more items are available.
  • Iterators shine for large file processing, infinite sequences, and custom collection wrappers where you don't want to load everything into memory at once.
  • When designing method signatures: accept IEnumerable<T> for maximum flexibility, return more specific types like List<T> when the caller needs count or indexing.

Tomorrow: we'll tackle Day 23: IDisposable and Resource Management — cleaning up after yourself when your code opens files, connections, or anything that doesn't garbage-collect itself. 🧹

See you on Day 23!

Share
FM

Farhad Mammadov

.NET Engineer & Cloud Architect · Bayern, Germany. Writing about scalable backend systems, AWS, and SRE.