Question

IEnumerable vs List in C#: LINQ Execution and Performance

csharplinqlistienumerable

Question

Given these C# LINQ queries, one stored as a List<Animal> with .ToList() and one stored as an IEnumerable<Animal>, how do they differ?

List<Animal> selectedAnimals =
    (from animal in Animals
     join species in Species
         on animal.SpeciesKey equals species.SpeciesKey
     select animal)
    .Distinct()
    .ToList();
IEnumerable<Animal> selectedAnimals =
    (from animal in Animals
     join species in Species
         on animal.SpeciesKey equals species.SpeciesKey
     select animal)
    .Distinct();

When debugging the IEnumerable<Animal> query, why might its internal fields refer to an outer sequence of Animal objects, an inner sequence of Species objects, and key-selector delegates? How do Distinct() and deferred execution affect what is visible? Finally, which approach is more appropriate for performance: materializing the query with .ToList() or enumerating the IEnumerable<Animal> directly?

Short Answer

You will learn that IEnumerable<T> usually represents a sequence that can produce values on demand, while List<T> is an already-created, indexable collection. You will also learn why LINQ queries are commonly deferred, why debugger internals can be misleading, and how to choose whether .ToList() is appropriate.

Concept

IEnumerable<T> and List<T> are related, but they represent different ideas.

  • IEnumerable<T> is an interface for enumerating values. It promises that code can request the next value, usually with foreach.
  • List<T> is a concrete, in-memory collection. It stores its items and also implements IEnumerable<T>.

A LINQ query such as Where, Select, Join, or Distinct commonly returns an IEnumerable<T>. At that point, LINQ has usually built a description of the work to perform; it has not necessarily performed the work yet. This behavior is called deferred execution.

IEnumerable<Animal> query = Animals.Where(animal => animal.IsActive);

The Where condition is generally evaluated later, when you enumerate query:

foreach (Animal animal in query)
{
    Console.WriteLine(animal.Name);
}

Calling .ToList() is different. It immediately enumerates the source, runs the query at that moment, and stores all resulting values in a new List<Animal>.

List<Animal> animals = Animals
    .Where(animal => animal.IsActive)
    .ToList();

This distinction matters because it affects:

  • When work happens.
  • Whether later changes to the source can affect the result.
  • Whether repeated enumeration repeats the query.
  • Memory use.
  • Whether you need list-specific features such as indexing, Count, or mutation.

Why Join has outer, inner, and key selectors

A LINQ join has two inputs:

Animals.Join(
    Species,
    animal => animal.SpeciesKey,
    species => species.SpeciesKey,
    (animal, species) => animal)
  • Animals is the outer sequence.
  • Species is the inner sequence.
  • animal => animal.SpeciesKey is the outer key selector.
  • species => species.SpeciesKey is the inner key selector.

The delegates are functions that tell Join how to obtain each matching key. It is therefore normal for debugger internals to show Animal values in an outer-related field and Species values in an inner-related field.

Those fields are implementation details of the LINQ iterator, not the public meaning of your result. The public result is still an IEnumerable<Animal> because the final select animal projects each matched pair to an Animal.

What Distinct() actually means

Distinct() removes duplicate values according to an equality comparer. With a custom class such as Animal, the default comparison is often reference equality unless Animal implements value equality, such as IEquatable<Animal>, or you provide an IEqualityComparer<Animal>.

Also, Distinct() does not need to know all results before returning the first unique item. As it enumerates, it keeps a set of values already seen. Debugger views of nested LINQ iterators may show source inputs or partial internal state, not a simple final list of distinct output values.

Mental Model

Think of an IEnumerable<Animal> as a recipe card and a List<Animal> as a prepared tray of food.

  • The recipe card says how to obtain animals: join two sources, select animals, and remove duplicates.
  • Each time someone asks to use the recipe, the preparation can happen again.
  • .ToList() prepares the entire recipe once and puts the finished animals on a tray.

foreach is the person taking one item at a time. It does not require a list; it only requires something that can provide a next item.

For a join, imagine matching animal records with species records using labeled tags. The outer key selector reads an animal's tag, and the inner key selector reads a species record's tag. Matching tags produce a result.

Take Quiz

Syntax and Examples

IEnumerable<T> is useful when callers only need to read values in sequence.

IEnumerable<Animal> activeAnimals = Animals.Where(animal => animal.IsActive);

foreach (Animal animal in activeAnimals)
{
    Console.WriteLine(animal.Name);
}

List<T> is useful when you need a materialized collection.

List<Animal> activeAnimals = Animals
    .Where(animal => animal.IsActive)
    .ToList();

Console.WriteLine(activeAnimals.Count);
Console.WriteLine(activeAnimals[0].Name);

A method can return IEnumerable<T> even if it internally creates a list. This exposes only the capability the caller needs.

public IEnumerable<Animal> GetActiveAnimals(IEnumerable<Animal> animals)
{
    return animals.Where(animal => animal.IsActive);
}

To specify value-based uniqueness for Distinct(), provide a comparer. This example considers animals equal when their IDs match.

IEnumerable<Animal> uniqueAnimals = Animals
    .DistinctBy(animal => animal.Id);

DistinctBy is available in modern .NET versions. On earlier versions, implement IEqualityComparer<Animal> and pass it to Distinct(comparer).

Step by Step Execution

Consider this deferred query:

IEnumerable<int> numbers = new[] { 1, 2, 2, 3 };

IEnumerable<int> query = numbers
    .Where(number => number > 1)
    .Distinct();

Console.WriteLine("Query created");

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

Execution proceeds as follows:

  1. The array is created immediately.
  2. Where and Distinct create an enumerable query pipeline. The numbers have not yet been filtered for this foreach.
  3. Query created is printed.
  4. foreach asks the query for its first item.
  5. Where examines 1 and rejects it because it is not greater than 1.
  6. Where examines the first 2 and accepts it. Distinct has not seen 2, so it yields 2.
  7. The second 2 passes Where, but Distinct has already seen it, so it does not yield it.
  8. 3 passes Where, is new to Distinct, and is yielded.

Output:

Query created
2
3

Now materialize the query:

List<int> results = numbers
    .Where(number => number > 1)
    .Distinct()
    .ToList();

The filtering and distinct work happens during the .ToList() call. Afterward, results already contains 2 and 3.

Real World Use Cases

  • Streaming a file: Return lines as IEnumerable<string> when processing them one at a time, rather than keeping every line in memory.
  • Filtering application data: Use a LINQ IEnumerable<T> pipeline to filter users, orders, or log entries before displaying or processing them.
  • Reuse results: Call .ToList() when an expensive result will be iterated several times, such as rendering a report and then calculating totals from the same result.
  • Stable snapshot: Materialize with .ToList() before working with data that could change during processing.
  • UI binding or indexed access: Use a list when code needs items[0], a stable Count, sorting in place, or adding/removing elements.
  • Database and API boundaries: Materialize data before disposing a resource or before passing a stable result to code that may enumerate it later. The exact behavior depends on the data provider.
Take Quiz

Real Codebase Usage

A common design rule is to accept and return the least-specific useful type.

public IEnumerable<Animal> FindAnimalsBySpecies(
    IEnumerable<Animal> animals,
    int speciesKey)
{
    return animals.Where(animal => animal.SpeciesKey == speciesKey);
}

The method does not promise indexing or mutation, so IEnumerable<Animal> is an appropriate return type.

Materialize deliberately at a boundary or when reuse requires it:

List<Animal> visibleAnimals = FindAnimalsBySpecies(Animals, selectedSpeciesKey)
    .OrderBy(animal => animal.Name)
    .ToList();

RenderTable(visibleAnimals);
ExportCsv(visibleAnimals);

Here, the list prevents the filtering and sorting work from being repeated by RenderTable and ExportCsv.

Use a guard clause to avoid unnecessary query work:

public IEnumerable<Animal> GetAnimalsForSpecies(
    IEnumerable<Animal> animals,
    int? speciesKey)
{
    if (speciesKey is null)
    {
        return Enumerable.Empty<Animal>();
    }

    return animals.Where(animal => animal.SpeciesKey == speciesKey.Value);
}

For validation or error handling, materializing can make failures occur at a predictable point. Without .ToList(), an exception in a deferred query may occur later, during foreach rather than where the query variable was assigned.

Common Mistakes

Assuming assignment executes a LINQ query

IEnumerable<Animal> query = Animals.Where(animal => animal.IsActive);
// The filter may not have run yet.

Avoid this mistake by remembering that enumeration triggers most LINQ-to-Objects queries. Use .ToList() when immediate execution is intentionally required.

Enumerating an expensive query more than once

IEnumerable<Animal> query = GetAnimalsFromExpensiveSource();

int count = query.Count();
foreach (Animal animal in query)
{
    Console.WriteLine(animal.Name);
}

This can execute the source twice. Materialize once if repeated use is intended:

List<Animal> animals = GetAnimalsFromExpensiveSource().ToList();
int count = animals.Count;

Calling .ToList() automatically

List<Animal> animals = Animals
    .Where(animal => animal.IsActive)
    .ToList();

This is not wrong, but it allocates a list and processes all results immediately. Do not materialize just because foreach follows; foreach works directly with IEnumerable<T>.

Expecting Distinct() to compare object properties automatically

// May keep two different Animal objects with the same Id.
IEnumerable<Animal> uniqueAnimals = Animals.Distinct();

Use DistinctBy(animal => animal.Id) or provide an equality comparer when uniqueness should be based on an ID or another property.

Relying on debugger implementation fields

Fields such as inner, outer, and key selectors describe how a particular LINQ iterator is implemented. They are not a stable API and can differ by .NET version. Inspect the query by enumerating it or materializing a small test result instead.

Modifying a list during foreach

foreach (Animal animal in animals)
{
    animals.Remove(animal); // Invalid during enumeration
}

Create a separate list of items to remove, use a reverse for loop when appropriate, or use RemoveAll.

Comparisons

FeatureIEnumerable<T>List<T>
What it isAn interface for sequential enumerationA concrete in-memory collection
Stores all items itselfNot necessarilyYes
Typical LINQ executionOften deferredAlready materialized when created
foreach supportYesYes
Index access such as [0]No guaranteeYes
Count propertyNo guarantee; Count() may enumerateYes, constant-time property access
Add or remove itemsNoYes
Repeated enumerationMay rerun the query or sourceReads the stored items
Memory useCan be low for streaming pipelinesRequires memory for all stored items

IEnumerable<T> is not automatically faster than List<T>. It can avoid allocating a list and can produce values lazily. A List<T> can be faster overall when the same sequence must be traversed multiple times, because the result is computed once.

ChoicePrefer it when
Keep IEnumerable<T>You need one pass, want lazy processing, or do not need list operations.
Use .ToList()You need a snapshot, indexing, list operations, or repeated enumeration of an expensive query.
Use an arrayYou need a fixed-size materialized sequence and do not need to add or remove items.
Take Quiz

Cheat Sheet

// Deferred query: usually runs when enumerated.
IEnumerable<Animal> query = Animals.Where(a => a.IsActive);

// Immediate materialization: runs now and stores results.
List<Animal> list = query.ToList();

// Both work with foreach.
foreach (Animal animal in query) { }
foreach (Animal animal in list) { }

// List-only conveniences.
Animal first = list[0];
int count = list.Count;
list.Add(new Animal());

// Distinct values by a property in modern .NET.
IEnumerable<Animal> unique = Animals.DistinctBy(a => a.Id);
  • List<T> implements IEnumerable<T>.
  • foreach requires enumeration, not a list.
  • Most LINQ-to-Objects operators are deferred; terminal operations such as ToList(), ToArray(), Count(), and First() enumerate immediately.
  • A deferred query can observe changes to its source made before enumeration.
  • Re-enumerating a deferred query can repeat work.
  • Distinct() uses equality; define the equality rule when object identity is not the intended rule.
  • Do not make decisions based on private debugger fields inside LINQ iterator objects.

FAQ

Is IEnumerable<T> always lazy?

No. It is only an enumeration interface. Many LINQ methods return deferred enumerables, but an IEnumerable<T> can also be a list, array, or already-computed result.

Does .ToList() improve performance?

Sometimes. It can prevent repeated execution when you enumerate the same expensive query multiple times. It also allocates memory and forces all results to be produced immediately, so it is unnecessary for a single pass.

Can I use foreach on a List<T> and an IEnumerable<T>?

Yes. List<T> implements IEnumerable<T>, so both can be enumerated with foreach.

Why does my LINQ join show inner and outer in the debugger?

A join needs two input sequences. LINQ commonly calls the first one outer and the second one inner. The debugger is showing iterator implementation state, not a different result type.

Why does Distinct() not remove objects with the same property values?

For custom objects, the default equality behavior may treat different object instances as different values. Use DistinctBy with a property or implement a suitable equality comparer.

When does a deferred query run?

Usually when it is enumerated by foreach or a terminal operation such as ToList(), Count(), First(), or ToArray().

Should a method return List<T> or IEnumerable<T>?

Return IEnumerable<T> when callers only need to read items sequentially. Return List<T> only when callers genuinely need list capabilities such as mutation or indexing.

Take Quiz

Mini Project

Description

Build a small report that joins animals to species, removes duplicate animals by ID, and demonstrates the difference between reusing a deferred query and reusing a materialized list. This mirrors reporting and data-processing code where a result is displayed and then reused for another calculation.

Goal

Create a stable list of unique animals for a selected report and use it for both output and counting without repeating the join.

Requirements

Use Join to match animals to species by SpeciesKey. Remove duplicates based on each animal's Id. Materialize the final report result once with .ToList(). Print each selected animal and then print the total count. Use foreach to display the results.

Take Quiz

Keep learning

Related questions

View more