Engineering Notes
Home/Languages/C#/LINQ (Language Integrated Query)
Languages · C#

LINQ (Language Integrated Query)

LINQ is a set of extension methods (defined mostly on IEnumerable<T> in System.Linq, and IQueryable<T> for providers like EF Core) that let you filter, transform, and aggregate sequences with a consistent, composable API, plus two equivalent syntaxes to write those calls: method syntax and query syntax.

💻 Languages● Beginner⏱ 9 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • What LINQ Actually Is
  • Filtering, Projecting, Ordering
  • Aggregation
  • Grouping and Joining
  • Deferred Execution vs Immediate Execution

1What LINQ Actually Is

LINQ is a set of extension methods (defined mostly on IEnumerable<T> in System.Linq, and IQueryable<T> for providers like EF Core) that let you filter, transform, and aggregate sequences with a consistent, composable API, plus two equivalent syntaxes to write those calls: method syntax and query syntax.

csharp
var names = new[] { "Alice", "Bob", "Charlie", "Dave", "Eve" };

// Method syntax
var longNamesMethod = names
    .Where(n => n.Length > 3)
    .OrderBy(n => n)
    .Select(n => n.ToUpper());

// Query syntax — same result
var longNamesQuery =
    from n in names
    where n.Length > 3
    orderby n
    select n.ToUpper();

Both produce identical results because query syntax is compiled directly into the equivalent method-syntax chain (see "Under the Hood"). Method syntax can express things query syntax cannot (e.g. Sum, Count, Any, most aggregates), so most C# code uses method syntax by default and reaches for query syntax mainly when a join/group by-heavy query reads more clearly that way.

2Filtering, Projecting, Ordering

csharp
var people = new List<(string Name, int Age)>
{
    ("Alice", 30), ("Bob", 25), ("Charlie", 35), ("Dave", 25)
};

var adults = people.Where(p => p.Age >= 18);                 // filter
var names = people.Select(p => p.Name);                       // project (transform each element)
var sorted = people.OrderBy(p => p.Age).ThenBy(p => p.Name);    // multi-key sort, ascending
var descending = people.OrderByDescending(p => p.Age);            // sort descending

Select is a 1-to-1 transform; SelectMany flattens a sequence of sequences into one:

csharp
var groups = new List<List<int>> { new() { 1, 2 }, new() { 3, 4, 5 } };
var flat = groups.SelectMany(g => g);  // 1, 2, 3, 4, 5

3Aggregation

csharp
int[] nums = { 4, 8, 15, 16, 23, 42 };

int sum = nums.Sum();
double avg = nums.Average();
int max = nums.Max();
int count = nums.Count(n => n > 10);
bool anyBig = nums.Any(n => n > 100);
bool allPositive = nums.All(n => n > 0);
int product = nums.Aggregate(1, (acc, n) => acc * n);  // general-purpose fold/reduce

Aggregate is LINQ's reduce — everything else in this section could theoretically be written in terms of it, but the specialized methods (Sum, Max, ...) exist because they can be implemented more efficiently for their specific case and read far more clearly.

4Grouping and Joining

csharp
var byAge = people.GroupBy(p => p.Age);
foreach (var group in byAge)
    Console.WriteLine($"Age {group.Key}: {string.Join(", ", group.Select(p => p.Name))}");

var orders = new[] { (CustomerId: 1, Item: "Book") };
var customers = new[] { (Id: 1, Name: "Alice") };

var joined = orders.Join(
    customers,
    order => order.CustomerId,
    customer => customer.Id,
    (order, customer) => $"{customer.Name} bought {order.Item}");

GroupBy returns a sequence of IGrouping<TKey, TElement> — each group is itself an IEnumerable<TElement> with an extra Key property. Join is an inner join (unmatched elements from either side are dropped); GroupJoin performs the LINQ equivalent of a left outer join grouped by key.

5Deferred Execution vs Immediate Execution

csharp
var query = nums.Where(n => n > 10);   // NOTHING has executed yet — just builds a description
nums[0] = 999;                           // mutate the source AFTER building the query...
foreach (var n in query)                  // ...the mutation IS visible here, because Where() only
    Console.WriteLine(n);                  // runs when you actually enumerate

Most LINQ operators (Where, Select, OrderBy, GroupBy, ...) are deferred — calling them doesn't iterate the source at all; it returns an object that knows how to produce results once something actually asks for them (a foreach, .ToList(), another LINQ call, etc.). Count(), Sum(), ToList(), ToArray(), First(), and similar are immediate — they force full or partial enumeration right away and return a concrete value/collection.

6Materializing Results

csharp
List<int> list = nums.Where(n => n > 10).ToList();   // executes NOW, snapshots into a List<int>
int[] array = nums.Where(n => n > 10).ToArray();
Dictionary<int, string> dict = people.ToDictionary(p => p.Age, p => p.Name);  // throws on duplicate keys!

Call .ToList()/.ToArray() when you need to: (a) enumerate the results more than once without re-running the whole query pipeline, (b) freeze the results against later mutation of the source, or (c) hand the data to an API expecting a concrete collection type.

Under the HoodQuery Syntax Desugaring and Deferred Execution's Iterator Machinery▾

Query syntax is pure sugar for method chains

The C# compiler translates from/where/select/orderby/group by/join clauses into calls to Where, Select, OrderBy, GroupBy, Join, etc. — this happens entirely at compile time, before any type checking of LINQ-to-Objects vs LINQ-to-SQL/EF even occurs. This translation is mechanical and can be reproduced by hand:

csharp
from n in names where n.Length > 3 orderby n select n.ToUpper();
//  desugars to:
names.Where(n => n.Length > 3).OrderBy(n => n).Select(n => n.ToUpper());

Because this is a textual, compile-time rewrite, orderby ... select query-syntax expressions ALWAYS have a method-syntax equivalent, but the reverse isn't true — many method-only operators (Sum, Aggregate, Any, SelectMany with certain overloads) have no query-syntax keyword at all, which is exactly why mixed method+query syntax in the same expression is common and completely normal.

Extension methods are how this attaches to IEnumerable<T> at all

Where, Select, and friends are static methods on the static class System.Linq.Enumerable, written as C# extension methods (this IEnumerable<TSource> source as the first parameter). names.Where(...) compiles to Enumerable.Where(names, ...) — there's no special language feature giving IEnumerable<T> these methods; any type implementing that one interface gets the entire LINQ surface for free, purely through extension-method resolution, as long as using System.Linq; is in scope.

Where and Select are nested iterator state machines; MoveNext calls pull right-to-left, one element at a time, only when foreach asks
Where and Select are nested iterator state machines; MoveNext calls pull right-to-left, one element at a time, only when foreach asks

Deferred execution is implemented with C# iterator methods (yield return)

Where isn't implemented by eagerly looping over the source and building a result list. It's implemented (conceptually) as an iterator method:

csharp
public static IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
    foreach (var item in source)
        if (predicate(item))
            yield return item;
}

When the C# compiler sees yield return inside a method, it doesn't compile that method's body to run when called — it generates an entire compiler-synthesized state machine class implementing IEnumerable<T>/IEnumerator<T>, whose MoveNext() method contains the loop body, resuming from wherever it last yielded each time MoveNext() is called. Calling Where(...) just constructs and returns one of these state machine objects immediately — none of the actual filtering logic runs until something calls MoveNext() on it (which foreach, .ToList(), etc. all do internally). This is the entire mechanism behind deferred execution: a LINQ query is a chain of nested iterator state machines, each one pulling from the one before it, lazily, one element at a time, only when the outermost consumer asks for the next element.

text
foreach over query
  → outer iterator.MoveNext()
    → pulls from Select's iterator.MoveNext()
      → pulls from Where's iterator.MoveNext()
        → pulls from the actual source's enumerator.MoveNext()

This pull-based chaining is why .Where(...).Select(...).First() on a huge sequence can be dramatically cheaper than eagerly filtering and projecting everything: First() only pulls elements one at a time until it finds one, potentially processing a tiny fraction of the source before stopping, rather than running the whole pipeline to completion up front.

Why IQueryable<T> is a completely different execution model

IEnumerable<T> LINQ (LINQ-to-Objects) runs your lambda delegates directly, in-process, against in-memory data. IQueryable<T> (used by EF Core, LINQ-to-SQL) instead builds an expression tree — a data structure literally representing the syntax of your lambda (not a compiled delegate) — and hands that tree to a query provider, which translates it into SQL (or another target query language) and executes it remotely. This is why the exact same LINQ method-syntax code can mean "run this C# in a loop" or "generate and run this SQL query" depending purely on whether the source is IEnumerable<T> or IQueryable<T> — the compiler picks between Enumerable.Where and Queryable.Where based on the static type, and Queryable.Where takes an Expression<Func<T,bool>> instead of a plain Func<T,bool>, forcing the compiler to build an expression tree instead of a delegate at the call site.

⚠️ Common Pitfalls

  1. Multiple enumeration of a deferred query, e.g. calling .Count() then .ToList() on the same un-materialized query — each enumeration re-runs the entire pipeline from scratch (re-querying a database twice, for example). Materialize once with .ToList()/.ToArray() if you need the results more than once.
  2. Assuming a query executes at the point it's declared — var query = source.Where(...) does not touch source yet; if source mutates before the query is enumerated, the mutation is visible, which can be surprising.
  3. .ToDictionary() throwing ArgumentException on duplicate keys — unlike a plain loop with dict[key] = value (which silently overwrites), ToDictionary throws if the key selector produces a duplicate; use GroupBy or ToLookup when duplicates are expected.
  4. Using .Count() > 0 instead of .Any() — Count() on a plain IEnumerable<T> may have to enumerate the entire sequence to count it, while Any() stops at the first matching (or first, if no predicate) element; on ICollection<T>-backed sequences Count() is O(1) via a cached count, but that's not guaranteed for a general IEnumerable<T> from a deferred query.
  5. Capturing a mutable loop variable in a LINQ lambda inside a loop and being surprised by closure-over-variable behavior — modern C# (post C# 5 foreach) allocates a fresh loop variable per iteration for foreach, but a manually indexed for loop with a shared variable can still bite you if captured across deferred queries evaluated later.
  6. Silently changing IEnumerable to IQueryable performance characteristics by calling .ToList() too early — materializing before applying further Where/Select operators forces the ENTIRE table/source to be pulled into memory first, defeating server-side filtering that would otherwise translate to a WHERE clause in SQL.

📋 Quick Reference

text
Where(predicate)          → filter
Select(selector)           → project / transform
SelectMany(selector)        → flatten nested sequences
OrderBy / OrderByDescending  → sort (ThenBy/ThenByDescending for secondary keys)
GroupBy(keySelector)          → bucket into IGrouping<TKey, TElement>
Join / GroupJoin                → inner join / left-outer-style join
Sum/Average/Min/Max/Count        → aggregate, IMMEDIATE execution
Any/All                            → short-circuiting boolean checks
Aggregate(seed, func)                → general fold/reduce
First/FirstOrDefault/Single/SingleOrDefault → pick one element, IMMEDIATE
ToList/ToArray/ToDictionary/ToLookup    → materialize, IMMEDIATE execution

Deferred (build a pipeline, run on enumeration): Where, Select, OrderBy, GroupBy, Join
Immediate (run right now, return a value):        ToList, Count, Sum, First, Any

IEnumerable<T> → runs delegates in-process (LINQ-to-Objects)
IQueryable<T>  → builds an expression tree, translated by a provider (e.g. to SQL)

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is LINQ?core▾

Language Integrated Query: query operators (Where, Select, GroupBy, OrderBy, Join, Aggregate) over collections, databases and more, in method or query syntax.

Follow-ups interviewers ask
  • IEnumerable vs IQueryable? IEnumerable runs in memory; IQueryable builds expression trees translated by providers like EF Core to SQL.
Q2What is deferred execution?core▾

Most LINQ operators don't run until enumerated (foreach, ToList, Count). Enumerating twice runs the query twice, which can mean two database calls.

Follow-ups interviewers ask
  • How do you materialize? ToList/ToArray/ToDictionary.
Q3What LINQ mistakes hurt EF Core performance?core▾

Calling ToList() too early (filtering in memory), client-evaluated methods, N+1 queries from lazy loading, selecting whole entities instead of projections, and missing AsNoTracking for read-only queries.

Follow-ups interviewers ask
  • Check generated SQL? ToQueryString() or logging.
Q4First vs FirstOrDefault vs Single vs SingleOrDefault?core▾

First returns the first match or throws; FirstOrDefault returns default if none; Single expects exactly one (throws if zero or many); SingleOrDefault allows zero but throws on many.

Follow-ups interviewers ask
  • Which for primary key lookups in EF? FindAsync or SingleOrDefaultAsync.
Q5How does GroupBy differ between LINQ to Objects and EF Core?senior▾

In memory, GroupBy returns groups of elements. EF Core translates GroupBy to SQL only when followed by aggregates (Count, Sum); returning groups of entities may not translate.

Follow-ups interviewers ask
  • Workaround? Aggregate in SQL or group after materializing small results.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.