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'
.ToList() or .ToArray().
Immediate Execution
These operators force the query to execute right away and return a concrete result:
| Category | Methods | Returns |
|---|---|---|
| Materializing | ToList(), ToArray(), ToDictionary(), ToHashSet() | A new collection (snapshot) |
| Single-value | First(), Last(), Single(), ElementAt() | One element |
| Aggregates | Count(), Sum(), Min(), Max(), Average() | A scalar value |
| Boolean | Any(), 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>
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
| Feature | IEnumerable<T> | IQueryable<T> |
|---|---|---|
| Namespace | System.Collections.Generic | System.Linq |
| Execution location | In memory (application) | Remote (database server) |
| Lambda representation | Delegates (compiled code) | Expression trees (data) |
| When to use | In-memory collections | ORMs like Entity Framework |
| Can translate to SQL? | No | Yes |
| Supports deferred execution? | Yes | Yes |
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 });
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() 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.