DEV_NET_CORE
GET_STARTED
.NETModern C# patterns

Deferred Execution in C#

Overview

Deferred execution in C# means that an operation is not executed when it is defined. Instead, it is executed later, usually when the result is enumerated.

This concept appears most often in LINQ, IEnumerable<T>, iterators using yield return, IQueryable<T>, Entity Framework Core queries, and asynchronous streams using IAsyncEnumerable<T>.

Deferred execution matters because it affects performance, memory usage, correctness, database access, exception timing, and debugging. A query that looks simple may not run immediately. It may run every time it is enumerated. It may also return different results if the source data changes before enumeration.

For interviews, deferred execution is important because it tests whether a developer understands more than LINQ syntax. A strong C# developer should know when queries execute, how to avoid repeated database calls, when to use ToList(), how yield return works, and why multiple enumeration can become a production bug.

Core Concepts

What Deferred Execution Means

Deferred execution means the query or iterator describes work to be done later.

The following query is defined but not executed immediately:

Code
var numbers = new List<int> { 1, 2, 3, 4, 5 };

var evenNumbers = numbers.Where(n => n % 2 == 0);

At this point, Where has not filtered the list yet. It has created an object that knows how to filter the list when someone asks for the values.

The query runs when it is enumerated:

Code
foreach (var number in evenNumbers)
{
    Console.WriteLine(number);
}

Output:

Code
2
4

The key idea is:

Code
Query definition != query execution

This is one of the most common LINQ interview topics.

Immediate Execution

Immediate execution means the operation runs right away and produces a concrete result.

Examples:

Code
var numbers = new List<int> { 1, 2, 3, 4, 5 };

int count = numbers.Where(n => n > 2).Count();

List<int> result = numbers.Where(n => n > 2).ToList();

int first = numbers.Where(n => n > 2).First();

Common immediate execution methods include:

  • ToList()
  • ToArray()
  • Count()
  • Any()
  • All()
  • First()
  • Single()
  • Max()
  • Min()
  • Sum()
  • Average()
  • Aggregate()

Some of these return a materialized collection, such as ToList() and ToArray(). Others return a scalar value, such as Count() or First().

Deferred Execution in LINQ

Most LINQ methods that return IEnumerable<T> are deferred.

Examples:

Code
var query = users
    .Where(u => u.IsActive)
    .OrderBy(u => u.LastName)
    .Select(u => u.Email);

This query describes the pipeline:

  1. Filter active users.
  2. Sort them by last name.
  3. Select email addresses.

The pipeline does not run until enumeration:

Code
foreach (var email in query)
{
    Console.WriteLine(email);
}

This is useful because LINQ can compose operations before executing them.

IEnumerable<T> and Enumeration

IEnumerable<T> represents something that can be enumerated. It does not guarantee that the data is already stored in memory.

Examples of IEnumerable<T> sources:

Code
IEnumerable<int> numbers = new List<int> { 1, 2, 3 };
IEnumerable<string> lines = File.ReadLines("data.txt");
IEnumerable<int> generated = GenerateNumbers();

Each time a foreach loop runs, C# asks the enumerable for an enumerator.

Simplified mental model:

Code
foreach (var item in source)
{
    // use item
}

is similar to:

Code
using var enumerator = source.GetEnumerator();

while (enumerator.MoveNext())
{
    var item = enumerator.Current;
    // use item
}

Deferred execution is closely tied to this enumeration process.

Multiple Enumeration

A deferred query may execute again every time it is enumerated.

Code
var numbers = new List<int> { 1, 2, 3, 4, 5 };

var query = numbers.Where(n =>
{
    Console.WriteLine($"Checking {n}");
    return n > 2;
});

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

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

The filter runs twice because the query is enumerated twice.

This can be harmless for in-memory collections, but expensive or incorrect for:

  • database queries
  • API calls
  • file reads
  • queries with side effects
  • queries over mutable data
  • queries that perform expensive calculations

If the result should be reused, materialize it:

Code
var result = query.ToList();

foreach (var number in result)
{
    Console.WriteLine(number);
}

foreach (var number in result)
{
    Console.WriteLine(number);
}

Now the filtering logic runs once.

Source Data Is Evaluated at Enumeration Time

Deferred queries read the current state of the source when the query is enumerated, not when the query is defined.

Code
var names = new List<string> { "Anna", "Ben" };

var query = names.Where(name => name.StartsWith("A"));

names.Add("Alex");

foreach (var name in query)
{
    Console.WriteLine(name);
}

Output:

Code
Anna
Alex

Alex appears because the query was executed after Alex was added.

This behavior can be useful, but it can also surprise developers.

Lazy Evaluation

Lazy evaluation means values are produced only when needed.

Code
var numbers = Enumerable.Range(1, 1_000_000);

var firstEven = numbers
    .Where(n => n % 2 == 0)
    .First();

The query does not check all one million numbers. It stops when it finds the first even number.

This can improve performance because only the necessary work is performed.

Streaming vs Non-Streaming Deferred Operators

Deferred execution does not always mean each item is returned immediately.

Some deferred operators are streaming. They can return one item at a time.

Examples:

Code
Where
Select
Take
Skip
Concat

Example:

Code
var query = numbers
    .Where(n => n > 10)
    .Select(n => n * 2);

As each item is requested, the pipeline can process and return it.

Some deferred operators are non-streaming. They are deferred, but when enumeration starts, they may need to inspect all or many items before returning the first result.

Examples:

Code
OrderBy
GroupBy
Distinct
Reverse
Join

Example:

Code
var sorted = numbers.OrderBy(n => n);

The sorting does not happen when OrderBy is called. However, when enumeration starts, the operator generally needs to process the full source before returning the first sorted item.

This distinction is important for performance and memory usage.

yield return and Custom Deferred Execution

C# supports deferred execution directly with iterator methods using yield return.

Code
public static IEnumerable<int> GetEvenNumbers(IEnumerable<int> numbers)
{
    foreach (var number in numbers)
    {
        Console.WriteLine($"Checking {number}");

        if (number % 2 == 0)
        {
            yield return number;
        }
    }
}

Usage:

Code
var numbers = new[] { 1, 2, 3, 4 };

var evens = GetEvenNumbers(numbers);

Console.WriteLine("Query created");

foreach (var number in evens)
{
    Console.WriteLine(number);
}

Output:

Code
Query created
Checking 1
Checking 2
2
Checking 3
Checking 4
4

The method body does not run when GetEvenNumbers is called. It runs when the returned sequence is enumerated.

How yield return Works Conceptually

When the compiler sees yield return, it generates a state machine.

Instead of returning all values at once, the method returns an object that remembers where it stopped.

Code
public static IEnumerable<int> CountToThree()
{
    yield return 1;
    yield return 2;
    yield return 3;
}

Each call to MoveNext() advances to the next yield return.

This allows efficient iteration without building a full collection first.

IEnumerable<T> vs IQueryable<T>

IEnumerable<T> is usually used for in-memory enumeration.

IQueryable<T> is usually used for remote query providers, such as Entity Framework Core.

Example:

Code
IQueryable<User> query = dbContext.Users
    .Where(u => u.IsActive)
    .OrderBy(u => u.LastName);

This query is not executed immediately. It is represented as an expression tree that a provider can translate, often into SQL.

Execution happens when the query is materialized or enumerated:

Code
List<User> users = await query.ToListAsync();

With Entity Framework Core, this is the difference between composing a database query and actually sending it to the database.

Deferred Execution with Entity Framework Core

Deferred execution is especially important in EF Core.

Code
var query = dbContext.Products
    .Where(p => p.Price > 100);

query = query.Where(p => p.IsActive);

var products = await query.ToListAsync();

The database query is built step by step. It is sent to the database when ToListAsync() is called.

This is useful because it allows dynamic query composition:

Code
IQueryable<Product> query = dbContext.Products;

if (!string.IsNullOrWhiteSpace(search))
{
    query = query.Where(p => p.Name.Contains(search));
}

if (minPrice is not null)
{
    query = query.Where(p => p.Price >= minPrice);
}

var results = await query.ToListAsync();

However, it can also cause problems if a query is enumerated multiple times:

Code
var query = dbContext.Products.Where(p => p.IsActive);

var count = await query.CountAsync();
var items = await query.ToListAsync();

This sends two database queries. That may be acceptable, but developers should understand it.

Materialization

Materialization means converting a deferred query into a concrete result.

Examples:

Code
var list = query.ToList();
var array = query.ToArray();
var dictionary = query.ToDictionary(x => x.Id);

Materialization is useful when:

  • the result will be reused multiple times
  • the source might change
  • the database context will be disposed
  • you want predictable exception timing
  • you want to avoid repeated expensive work
  • you want to separate query execution from later business logic

Example:

Code
public async Task<IReadOnlyList<ProductDto>> GetProductsAsync()
{
    var products = await dbContext.Products
        .Where(p => p.IsActive)
        .Select(p => new ProductDto(p.Id, p.Name))
        .ToListAsync();

    return products;
}

The method returns already-loaded data instead of returning a query tied to the lifetime of the DbContext.

Deferred Execution and Resource Lifetime

Deferred execution can cause bugs when the underlying resource is disposed before enumeration.

Problem:

Code
public IEnumerable<string> ReadLines()
{
    using var reader = new StreamReader("data.txt");

    while (!reader.EndOfStream)
    {
        yield return reader.ReadLine()!;
    }
}

This example can work because the using scope is part of the iterator state machine and remains active during enumeration.

However, this pattern can be dangerous when returning deferred queries from disposed services or database contexts.

Problem with EF Core:

Code
public IQueryable<Product> GetProducts()
{
    using var dbContext = new AppDbContext();

    return dbContext.Products.Where(p => p.IsActive);
}

The returned query depends on a disposed context. It will fail when enumerated.

Better:

Code
public async Task<List<Product>> GetProductsAsync()
{
    await using var dbContext = new AppDbContext();

    return await dbContext.Products
        .Where(p => p.IsActive)
        .ToListAsync();
}

Deferred Execution and Exceptions

With deferred execution, exceptions may happen later than expected.

Code
var query = numbers.Select(n => 10 / n);

If numbers contains zero, the exception is not thrown when the query is defined.

It is thrown during enumeration:

Code
foreach (var value in query)
{
    Console.WriteLine(value);
}

This matters when debugging and when choosing where to catch exceptions.

Side Effects in LINQ Queries

A common mistake is putting side effects inside LINQ queries.

Bad example:

Code
var processed = orders.Select(order =>
{
    order.MarkAsProcessed();
    return order;
});

Nothing happens until processed is enumerated. If it is enumerated twice, the side effect may run twice.

Better:

Code
foreach (var order in orders)
{
    order.MarkAsProcessed();
}

LINQ should usually be used for querying and projection, not for changing state.

Captured Variables

LINQ queries can capture variables from the surrounding scope.

Code
int threshold = 10;

var query = numbers.Where(n => n > threshold);

threshold = 20;

var result = query.ToList();

The query uses the value of threshold at execution time, so it filters using 20.

To avoid confusion, store values intentionally before defining or executing the query.

Code
int threshold = GetThreshold();

var result = numbers
    .Where(n => n > threshold)
    .ToList();

Modifying a Collection During Enumeration

Changing a collection while it is being enumerated can throw an exception.

Code
foreach (var item in items)
{
    if (item.IsDeleted)
    {
        items.Remove(item);
    }
}

Better:

Code
var deletedItems = items
    .Where(item => item.IsDeleted)
    .ToList();

foreach (var item in deletedItems)
{
    items.Remove(item);
}

The ToList() creates a snapshot of the items to remove.

File.ReadLines vs File.ReadAllLines

Deferred execution is useful for large files.

Code
IEnumerable<string> lines = File.ReadLines("large-file.txt");

File.ReadLines reads lines lazily as they are enumerated.

Code
string[] lines = File.ReadAllLines("large-file.txt");

File.ReadAllLines loads the whole file into memory immediately.

Use ReadLines when streaming large files is better. Use ReadAllLines when the full file is needed in memory.

IEnumerable<T> vs List<T> Return Types

Returning IEnumerable<T> can communicate that the result can be enumerated, but it may also hide deferred execution.

Example:

Code
public IEnumerable<UserDto> GetUsers()
{
    return users.Select(u => new UserDto(u.Id, u.Name));
}

This returns a deferred query.

If callers may enumerate multiple times, or if the data should be stable, return a materialized collection:

Code
public IReadOnlyList<UserDto> GetUsers()
{
    return users
        .Select(u => new UserDto(u.Id, u.Name))
        .ToList();
}

A practical guideline:

  • Return IEnumerable<T> when streaming or deferred behavior is intentional.
  • Return IReadOnlyList<T> when returning a stable in-memory result.
  • Return IQueryable<T> only from carefully designed query-building APIs, not casually from service layers.

Async Deferred Execution with IAsyncEnumerable<T>

IAsyncEnumerable<T> supports asynchronous streaming.

Code
public async IAsyncEnumerable<int> GetNumbersAsync()
{
    for (int i = 1; i <= 3; i++)
    {
        await Task.Delay(100);
        yield return i;
    }
}

Usage:

Code
await foreach (var number in GetNumbersAsync())
{
    Console.WriteLine(number);
}

Like IEnumerable<T>, the method does not produce all results immediately. Values are produced as the async sequence is consumed.

This is useful for:

  • streaming database results
  • reading large files
  • consuming paginated APIs
  • real-time data pipelines
  • reducing memory pressure

Performance Benefits

Deferred execution can improve performance by:

  • avoiding unnecessary work
  • processing only needed items
  • supporting query composition
  • reducing memory allocation
  • enabling streaming pipelines
  • allowing providers to optimize queries before execution

Example:

Code
var firstMatch = products
    .Where(p => p.IsActive)
    .Select(p => p.Name)
    .FirstOrDefault();

The query can stop once the first matching item is found.

Performance Risks

Deferred execution can also hurt performance when misunderstood.

Problem:

Code
var activeUsers = users.Where(u => u.IsActive);

var count = activeUsers.Count();

foreach (var user in activeUsers)
{
    SendEmail(user);
}

The filter runs once for Count() and again for the foreach.

Better:

Code
var activeUsers = users
    .Where(u => u.IsActive)
    .ToList();

var count = activeUsers.Count;

foreach (var user in activeUsers)
{
    SendEmail(user);
}

For EF Core, the first version may result in multiple database queries.

Common Mistakes

Common mistakes include:

  • assuming a LINQ query executes when declared
  • enumerating the same deferred query multiple times
  • returning IQueryable<T> from service layers without clear ownership
  • using side effects inside Select, Where, or other LINQ methods
  • forgetting that source data changes affect deferred queries
  • disposing a context or resource before enumeration
  • using ToList() too early and losing database-side filtering
  • using ToList() too late and causing lifetime or repeated execution bugs
  • not understanding that OrderBy is deferred but still needs buffering
  • mixing IEnumerable<T> and IQueryable<T> without understanding where execution happens

Best Practices

Use deferred execution intentionally.

Good habits:

  • Keep LINQ queries side-effect free.
  • Materialize with ToList() or ToArray() when you need a stable snapshot.
  • Avoid returning IQueryable<T> from application services unless there is a clear reason.
  • Use IQueryable<T> inside repositories or query builders to compose database queries before execution.
  • Be careful with multiple enumeration.
  • Avoid expensive logic inside deferred queries unless needed.
  • Materialize before disposing a DbContext, stream, or other resource.
  • Use Any() instead of Count() > 0 when checking existence.
  • Use FirstOrDefault() or Take() to avoid reading more data than necessary.
  • Push filters before materialization when using EF Core.
  • Prefer clear code over clever LINQ chains.

Comparison: Deferred Execution vs Immediate Execution

AspectDeferred ExecutionImmediate Execution
When it runsWhen enumeratedWhen the method is called
Common return typesIEnumerable<T>, IQueryable<T>List<T>, array, scalar values
Memory usageOften lowerOften higher
ReuseMay re-run each timeReuses stored result
Source changesReflected at enumeration timeSnapshot at execution time
Common examplesWhere, Select, TakeToList, Count, First
Main riskUnexpected repeated executionUnnecessary memory usage

Comparison: IEnumerable<T> vs IQueryable<T>

AspectIEnumerable<T>IQueryable<T>
Main useIn-memory sequencesRemote query providers
Query representationDelegates and iteratorsExpression trees
Execution locationUsually application memoryOften database or remote provider
Common providerLINQ to ObjectsEF Core
MaterializationToList(), ToArray()ToListAsync(), FirstOrDefaultAsync()
Main riskMultiple enumerationUnexpected database queries or translation issues

Real-World Usage

Deferred execution appears in real projects when:

  • filtering and projecting in-memory collections
  • building EF Core queries dynamically
  • streaming large files
  • processing large datasets without loading everything into memory
  • implementing custom iterators
  • building reusable query pipelines
  • exposing async streams from APIs or services

Example from a service method:

Code
public async Task<IReadOnlyList<CustomerDto>> SearchCustomersAsync(
    string? keyword,
    bool activeOnly)
{
    IQueryable<Customer> query = dbContext.Customers;

    if (activeOnly)
    {
        query = query.Where(c => c.IsActive);
    }

    if (!string.IsNullOrWhiteSpace(keyword))
    {
        query = query.Where(c => c.Name.Contains(keyword));
    }

    return await query
        .OrderBy(c => c.Name)
        .Select(c => new CustomerDto(c.Id, c.Name, c.Email))
        .ToListAsync();
}

This is a good use of deferred execution because the query is composed first and executed once.

Interview Practice

PreviousValue Types vs Reference TypesNext UpDelegates