Course Home L1 — Core Interfaces L2 — Hash & Sorted L3 — Specialized L4 — LINQ Basics L5 — Advanced LINQ All Guides

Lesson 3: Introduction to LINQ & Execution Models

Connect your knowledge of IEnumerable to LINQ and master the execution lifecycle that interviewers love to test.

What Is LINQ?

LINQ (Language-Integrated Query) is a set of features in C# that adds query capabilities directly into the language. Rather than writing loops, conditionals, and temporary variables to filter and transform data, LINQ lets you express these operations declaratively. Under the hood, LINQ is powered by extension methods on IEnumerable<T> (defined in the System.Linq namespace) and lambda expressions.

The key insight: because LINQ operates on IEnumerable<T>, it works with anything that implements that interface — lists, arrays, dictionaries, hash sets, database query results, XML documents, and even your own custom collections.

The 3 Parts of Every LINQ Operation

Every LINQ operation has three distinct phases. Understanding this separation is critical for reasoning about when code executes:

1. Obtain the Data Source

Any object that implements IEnumerable<T> (or IQueryable<T> for remote sources) can serve as a data source. No special preparation is needed:

// Arrays, lists, sets — anything IEnumerable<T>
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
List<string> names = new List<string> { "Alice", "Bob", "Charlie" };
HashSet<int> primes = new HashSet<int> { 2, 3, 5, 7, 11 };

2. Create the Query

This is where you define what you want — filtering, transforming, ordering, grouping. You can use either query syntax (SQL-like) or method syntax (fluent chaining). Both compile to the same code:

// Method syntax (more common in real-world code)
var evens = numbers.Where(n => n % 2 == 0)
                   .Select(n => n * n);

// Query syntax (more readable for complex joins)
var evens2 = from n in numbers
             where n % 2 == 0
             select n * n;

// IMPORTANT: At this point, NOTHING has executed yet!

3. Execute the Query

Execution happens when you actually consume the results — by iterating with foreach, calling ToList(), or using an aggregate like Count():

// Execution happens HERE, when we iterate
foreach (var e in evens)
{
    Console.WriteLine(e);
}

// Or force execution with a materializing method
List<int> evenList = evens.ToList();

Deferred vs. Immediate Execution

This is the single most important concept in LINQ, and the source of many subtle bugs.

Deferred Execution

Most LINQ operators (Where, Select, OrderBy, Skip, Take, GroupBy, SelectMany, etc.) use deferred execution. They do not process any elements when called. Instead, they build up a pipeline — a chain of iterators — that only executes when you enumerate the result.

This has a profound consequence: if the underlying data changes between query creation and query execution, the query reflects the latest data:

List<int> numbers = new List<int> { 1, 2, 3 };

// Define the query (NOT executed yet)
var evens = numbers.Where(n => n % 2 == 0);

// Modify the source AFTER defining the query
numbers.Add(4);
numbers.Add(6);

// Execute NOW — sees the latest state of 'numbers'
foreach (var e in evens)
{
    Console.WriteLine(e); // Prints: 2, 4, 6
}

// If we iterate AGAIN, it re-executes the entire pipeline
// from scratch on the current state of 'numbers'
The re-execution trap Every time you iterate a deferred query, the entire pipeline runs again from the source. If the pipeline includes expensive computations (database calls, file I/O, network requests), this can cause serious performance issues. If you need the results more than once, materialize with .ToList() or .ToArray().

Immediate Execution

These operators force the query to execute right away and return a concrete result:

CategoryMethodsReturns
MaterializingToList(), ToArray(), ToDictionary(), ToHashSet()A new collection (snapshot)
Single-valueFirst(), Last(), Single(), ElementAt()One element
AggregatesCount(), Sum(), Min(), Max(), Average()A scalar value
BooleanAny(), All(), Contains()bool
// Snapshot: captures the current state. Future changes won't affect it.
List<int> snapshot = numbers.Where(n => n > 2).ToList();
numbers.Add(100);
// 'snapshot' does NOT contain 100

Visualizing the Pipeline

Think of a deferred query as an assembly line that only starts when someone is at the end ready to receive items:

  Source         Where()          Select()         foreach/ToList()
┌─────────┐   ┌───────────┐   ┌────────────┐   ┌──────────────────┐
│ 1,2,3,4 │──→│ n % 2 == 0│──→│ n * n      │──→│ Pulls one at a   │
│ 5,6,7,8 │   │ pass/skip │   │ transforms │   │ time, on demand  │
└─────────┘   └───────────┘   └────────────┘   └──────────────────┘
                                                  ↑ Execution starts HERE

IEnumerable<T> vs. IQueryable<T>

Top interview question This is one of the most frequently asked LINQ questions. The distinction has real performance implications in any application that talks to a database.

IEnumerable<T> — In-Memory Execution

When you chain LINQ operations on an IEnumerable<T>, the runtime uses delegates (compiled C# lambdas) to filter and transform data in memory. Each element is passed through the pipeline on the calling machine's RAM.

// All 1,000,000 rows are loaded into memory first,
// then filtered in C# on the application server
IEnumerable<Customer> allCustomers = dbContext.Customers;
var active = allCustomers.Where(c => c.IsActive).ToList();

IQueryable<T> — Server-Side Execution

IQueryable<T> extends IEnumerable<T> but instead of delegates, it uses expression trees. An expression tree is a data structure representing your lambda as raw data (nodes, operators, property references) that can be translated into another language — typically SQL.

// The Where clause is translated to SQL:
// SELECT * FROM Customers WHERE IsActive = 1
// Only matching rows are transferred over the network
IQueryable<Customer> query = dbContext.Customers;
var active = query.Where(c => c.IsActive).ToList();

The Danger of Premature Materialization

If you accidentally convert an IQueryable to an IEnumerable too early in the chain, all subsequent operations run in memory instead of on the server:

// BAD: AsEnumerable() switches from Queryable to Enumerable LINQ provider.
// The .Where() that follows now uses in-memory delegates, not expression trees.
// When .ToList() triggers execution, ALL rows are fetched first, THEN filtered in C#.
var result = dbContext.Customers
    .AsEnumerable()       // ← Switches LINQ provider (rows load at iteration time)
    .Where(c => c.IsActive) // ← Delegate, not expression tree — runs in C#
    .ToList();              // ← NOW all rows are fetched and filtered in memory

// GOOD: Keep it as IQueryable so the database does the work
var result = dbContext.Customers
    .Where(c => c.IsActive) // ← Expression tree — translated to SQL WHERE clause
    .ToList();              // ← Only matching rows are fetched from DB

Side-by-Side Comparison

FeatureIEnumerable<T>IQueryable<T>
NamespaceSystem.Collections.GenericSystem.Linq
Execution locationIn memory (application)Remote (database server)
Lambda representationDelegates (compiled code)Expression trees (data)
When to useIn-memory collectionsORMs like Entity Framework
Can translate to SQL?NoYes
Supports deferred execution?YesYes

Query Syntax vs. Method Syntax

C# supports two ways to write LINQ. They compile to identical IL code. The choice is purely about readability:

List<Student> students = GetStudents();

// Query syntax — reads like SQL, great for joins and grouping
var honors = from s in students
             where s.GPA >= 3.5
             orderby s.LastName
             select new { s.FirstName, s.LastName, s.GPA };

// Method syntax — more common, supports all operators
var honors2 = students
    .Where(s => s.GPA >= 3.5)
    .OrderBy(s => s.LastName)
    .Select(s => new { s.FirstName, s.LastName, s.GPA });
When to prefer query syntax Use query syntax when you have join operations or let clauses (intermediate variables). The method syntax equivalent of a join is much harder to read. For everything else, method syntax is generally preferred in industry.

Essential LINQ Methods Reference

Filtering

// Where — keep elements matching a condition
var adults = people.Where(p => p.Age >= 18);

// OfType — filter by runtime type
var strings = mixedList.OfType<string>();

// Distinct — remove duplicates
var unique = numbers.Distinct();

Ordering

// OrderBy / ThenBy for multi-level sorting
var sorted = employees
    .OrderBy(e => e.Department)
    .ThenByDescending(e => e.Salary);

Quantifiers

// Any — at least one element matches?
bool hasAdmin = users.Any(u => u.Role == "Admin");

// All — every element matches?
bool allActive = users.All(u => u.IsActive);

// Contains — is a specific value in the collection?
bool hasFive = numbers.Contains(5);

Element Operators (with safe variants)

// First / FirstOrDefault — get the first element
var first = list.First();              // Throws if empty
var firstSafe = list.FirstOrDefault(); // Returns default(T) if empty

// Single / SingleOrDefault — exactly one element expected
var one = list.Single(x => x.Id == 42);  // Throws if 0 or 2+ matches

// ElementAt / ElementAtOrDefault
var third = list.ElementAt(2);
First() vs. Single() Use First() when you want one result but there might be many. Use Single() when your business logic demands exactly one result — it validates this assumption by throwing if zero or multiple matches exist. In database queries, Single() adds a guard rail against bad data.

Coding Challenge

Write a LINQ query that extracts all words starting with "A" from a List<string>. Then, add a new word starting with "A" to the list after the query is defined. Print the results. Afterwards, fix the code using .ToList() so the new word is not included.

View Solution
// Part 1: Demonstrate deferred execution
List<string> words = new List<string>
    { "Apple", "Banana", "Avocado", "Cherry", "Apricot" };

// Define the query — NOT executed yet
var aWords = words.Where(w => w.StartsWith("A"));

// Add a new "A" word AFTER query definition
words.Add("Artichoke");

Console.WriteLine("=== Deferred execution (includes Artichoke) ===");
foreach (var w in aWords)
{
    Console.WriteLine(w);
}
// Output: Apple, Avocado, Apricot, Artichoke

// Part 2: Fix with immediate execution
List<string> words2 = new List<string>
    { "Apple", "Banana", "Avocado", "Cherry", "Apricot" };

// ToList() forces immediate execution — takes a snapshot
var aWordsFixed = words2.Where(w => w.StartsWith("A")).ToList();

words2.Add("Artichoke");

Console.WriteLine("=== Immediate execution (NO Artichoke) ===");
foreach (var w in aWordsFixed)
{
    Console.WriteLine(w);
}
// Output: Apple, Avocado, Apricot

Key takeaway: Deferred execution means the query is a live view of the data source. .ToList() freezes the results at a point in time. Both behaviors are useful — the choice depends on whether you want a live view or a snapshot.