C# LINQ

📖 15 min read

What LINQ Is

LINQ (Language Integrated Query) is a set of query operators, such as Where, Select, OrderBy, and GroupBy, that work on any sequence. The operators are ordinary extension methods. System.Linq.Enumerable defines them for IEnumerable<T>, which covers every in-memory collection, and System.Linq.Queryable defines a parallel set for IQueryable<T>, which a provider such as Entity Framework Core translates into another query language. The same query shape works over a List<T> and over a database table, but the two run in completely different places, and most of this guide is about telling them apart.

Query Syntax Is Method Calls

C# offers two ways to write a query:

// Query syntax
var adults = from p in people
             where p.Age >= 18
             orderby p.Name
             select p;

// Method syntax
var adults = people
    .Where(p => p.Age >= 18)
    .OrderBy(p => p.Name);

These produce the same code. The compiler rewrites query syntax into method calls before it type-checks anything, so where becomes a call to whatever Where method is in scope for the source’s type. Query syntax has no keyword for most operators (Count, First, Distinct, Take, ToList and many more), so real queries tend to end in method calls either way. Its advantage is readability for joins, multiple from clauses, and let, where method syntax would need nested lambdas and anonymous types to carry intermediate values. The choice is style, not behavior.

Deferred and Immediate Execution

Calling Where does not filter anything. It returns an object that remembers the source and the predicate, and the filtering happens later, when something enumerates that object. Operators that return a sequence work this way and are called deferred. Operators that return a single value or a materialized collection, such as Count, First, Sum, ToList, and ToDictionary, run the query on the spot and are called immediate.

  Deferred Immediate
Examples Where, Select, OrderBy, GroupBy, Take, Skip ToList, ToArray, ToDictionary, Count, First, Any, Sum
Returns Another sequence A value or a new collection
Runs Each time the result is enumerated Once, at the call
Sees later changes to the source Yes No, the result is a copy
var numbers = new List<int> { 1, 2, 3, 4, 5 };

var query = numbers.Where(n => n > 2);   // nothing runs yet
numbers.Add(6);

foreach (var n in query)
    Console.WriteLine(n);                // 3, 4, 5, 6: the query runs now, and sees the 6

var snapshot = numbers.Where(n => n > 2).ToList();   // runs now
numbers.Add(100);                                     // snapshot still has four items

A materialized list copies the elements, not the objects they refer to. A List<Customer> from ToList() still points at the same Customer instances, so a change to a customer’s properties shows up through both.

How a Deferred Query Runs

A chain of deferred operators is a stack of wrappers, each holding the one before it. Enumerating the outermost wrapper pulls one element at a time up through the chain:

C4 · Dynamic

How a Deferred Query Pulls Elements

Each element travelling the whole chain before the next is read.

A foreach pulling elements one at a time through Select and Where The foreach asks Select for the next element, Select asks Where, and Where asks the source. The source yields 1, Where accepts it, Select projects it to 10, and foreach receives 10. Then the source yields 2, Where rejects it and asks the source again, and the source yields 3, which Where accepts and Select projects to 30. No list is built between stages, and each element travels the whole chain before the next is read. foreach Select(x => x * 10) Where(x => x % 2 == 1) source next? 1 1 10 ELEMENT 1 2 rejected, asks again ELEMENT 2 3 3 30 ELEMENT 3 request for the next element element handed back

Each element travels the whole chain before the next one is read. Run on .NET 10 with logging in every stage, the order is: read 1, test 1, project 1, deliver 10, read 2, test 2 (rejected), read 3, test 3, project 3, deliver 30. No intermediate list is built between Where and Select, which is why a deferred pipeline over a large or endless source uses almost no memory, and why Take(10) over an endless source finishes.

Some operators can’t work one element at a time. OrderBy can’t yield the smallest element until it has seen all of them, so on the first request it reads the entire source into a buffer, sorts it, and only then starts yielding. GroupBy and Reverse behave the same way. A buffering operator in the middle of a chain is where a lazy query stops being lazy. Everything upstream runs to completion the first time anything downstream asks for an element.

What a Deferred Query Captures

Because the query runs later, it sees the state of the world at enumeration time, not at definition time. That applies to the variables its lambdas capture as well as to the source:

int minimum = 2;
var large = numbers.Where(n => n > minimum);
minimum = 4;

Console.WriteLine(string.Join(",", large));   // 5,6,100: uses minimum = 4

The lambda captured the variable minimum, not its value, so the query reads whatever minimum holds when it runs. Changing the source while a query enumerates it is a different case. Adding to a List<T> inside a foreach over list.Where(...) throws InvalidOperationException, because the query is enumerating that same list underneath.

Errors are split the same way. Argument checks run at the call, so Where on a null source throws ArgumentNullException immediately. An exception thrown inside a lambda surfaces only when the query runs, which can be far from the line that defined it. A stack trace pointing at a foreach or a ToList() is often a predicate failing that was written somewhere else.

Multiple Enumeration Runs the Query Again

Every enumeration of a deferred query repeats the whole query:

// Runs the filter twice
var active = items.Where(i => i.IsActive);
if (active.Any())
{
    foreach (var item in active) { /* ... */ }
}

// Runs it once
var active = items.Where(i => i.IsActive).ToList();
if (active.Count > 0)
{
    foreach (var item in active) { /* ... */ }
}

Over an in-memory list the cost is repeated CPU work. Over a database query each enumeration is another round trip, and over an iterator that reads a file or a network stream, the second pass may not see the same data or may not be possible at all. When a result will be used more than once, materialize it once with ToList() or ToArray(). The same applies to a method that accepts IEnumerable<T> and walks it twice. It can’t know whether its argument is a list or an expensive query, so it should materialize first or accept a collection type instead.

IEnumerable<T> Versus IQueryable<T>

The two interfaces carry the same operators with different parameter types, and that difference decides where the query runs:

  IEnumerable<T> (LINQ to Objects) IQueryable<T> (a provider)
Operators come from System.Linq.Enumerable System.Linq.Queryable
A Where predicate is a Func<T, bool> delegate: compiled code Expression<Func<T, bool>>: a data structure describing the code
The query runs In your process, by calling the delegates Wherever the provider sends it, usually a database, after translating the expression tree
A lambda may call Any C# method Only what the provider knows how to translate
An untranslatable call fails Never, it’s just code At run time, when the query executes

When a lambda is passed where an Expression<Func<...>> is expected, the compiler builds an expression tree describing the lambda instead of compiling it. Each Queryable operator adds its call to a growing tree, and nothing leaves the process until enumeration, when the provider translates the whole tree at once. With EF Core, that means one Where, one OrderBy, and a Take become a single SQL statement with a WHERE, an ORDER BY, and a row limit, and only the matching rows cross the network.

Flow

Where the Filtering Happens

An IQueryable sending one query, against an IEnumerable pulling every row.

IQueryable translates the query to SQL while IEnumerable filters in memory Top, IQueryable: Where, OrderBy, and Take build one expression tree in your process, the provider translates it into one SQL query, and the database returns only the 10 matching rows. Bottom, IEnumerable: the database sends every row into your process, where Where, OrderBy, and Take run in memory to produce the same 10 rows. Both produce the same result; the difference is how much data crosses the network and where the filtering runs. IQUERYABLE System.Linq.Queryable Your process Where OrderBy Take Database runs WHERE, ORDER BY, and the row limit one SQL query 10 rows IENUMERABLE System.Linq.Enumerable Database returns the whole table Your process Where OrderBy Take every row 10 rows, after the filtering work in memory

Both paths produce the same ten rows. The difference is how much data moved and where the filtering work happened.

The Static-Type Trap

Extension methods bind at compile time, by the variable’s static type, not by the object it holds at run time. IQueryable<T> inherits from IEnumerable<T>, so an IQueryable<T> stored in an IEnumerable<T> variable is still a valid IEnumerable<T>, and Where on that variable binds to Enumerable.Where:

IQueryable<Product> products = db.Products;
var cheap = products.Where(p => p.Price < 10);   // Queryable.Where: filtered in SQL

IEnumerable<Product> all = db.Products;
var cheapAgain = all.Where(p => p.Price < 10);   // Enumerable.Where: every product is
                                                 // loaded, then filtered in memory

Both lines compile, both return the right products, and the second one reads the whole table. The same thing happens through a method that returns IEnumerable<Product> while building an IQueryable internally, and through a predicate stored as a delegate:

Func<Product, bool> isCheap = p => p.Price < 10;
var viaDelegate = db.Products.Where(isCheap);    // no Queryable.Where takes a Func,
                                                 // so this binds to Enumerable.Where

Expression<Func<Product, bool>> isCheapExpr = p => p.Price < 10;
var viaExpression = db.Products.Where(isCheapExpr);   // stays in SQL

The tell in each case is the result type. viaDelegate and cheapAgain are IEnumerable<Product>, and once a query is IEnumerable, every operator after it runs in memory too.

Switching to Memory on Purpose

Sometimes part of a query can’t be translated, such as a call into your own C# method, a regex, or a formatting routine. AsEnumerable() is the deliberate switch. Everything before it goes to the provider, and everything after it runs in memory:

var report = db.Orders
    .Where(o => o.PlacedAt >= since)          // translated to SQL
    .Select(o => new { o.Id, o.Total, o.Notes })
    .AsEnumerable()                           // switch to LINQ to Objects here
    .Where(o => NotesLookSuspicious(o.Notes)); // runs in memory, on the filtered rows

Put the switch as late as possible, after every filter and projection the provider can do, so the smallest possible set of rows crosses over. EF Core refuses to silently do this for you. A lambda it can’t translate throws when the query executes, except in the final Select, where it evaluates the untranslatable part in memory.

AsQueryable() goes the other way only in name. Called on an in-memory collection, it wraps the collection in an IQueryable whose provider compiles the expression tree and runs it in memory. It lets code that expects IQueryable<T> accept a list, often in tests, but no database is involved, so a test passing against it says nothing about whether a real provider can translate the query.

Composing Queries

Because deferred operators only build up a query, a query can be assembled in pieces and run once at the end.

Building a Query Conditionally

Each Where narrows the query further, and with IQueryable<T> all of them land in the same WHERE clause:

IQueryable<Product> query = db.Products;

if (!string.IsNullOrEmpty(searchTerm))
    query = query.Where(p => p.Name.Contains(searchTerm));

if (categoryId.HasValue)
    query = query.Where(p => p.CategoryId == categoryId);

if (minPrice.HasValue)
    query = query.Where(p => p.Price >= minPrice);

var results = await query.OrderBy(p => p.Name).ToListAsync();

Declare the variable as IQueryable<Product> explicitly. With var it would be inferred as DbSet<Product>, and assigning the result of Where back to it wouldn’t compile. Declared as IEnumerable<Product>, it would compile and fall into the static-type trap above.

Writing Reusable Operators

A query fragment used in several places can become an extension method. For an in-memory sequence it extends IEnumerable<T>, usually as an iterator with yield return so it stays deferred like the built-in operators. For a provider query it has to extend IQueryable<T> and pass expressions, not delegates, or it silently drops into memory:

public static class ProductQueries
{
    // The lambda is converted to an expression because Queryable.Where expects one
    public static IQueryable<Product> InStock(this IQueryable<Product> products) =>
        products.Where(p => p.Stock > 0);

    public static IQueryable<Product> PricedBetween(
        this IQueryable<Product> products, decimal min, decimal max) =>
        products.Where(p => p.Price >= min && p.Price <= max);
}

var page = await db.Products
    .InStock()
    .PricedBetween(10m, 50m)
    .OrderBy(p => p.Name)
    .Skip(20).Take(10)
    .ToListAsync();

The fragment itself can’t call a C# helper method inside its lambda for the same reason any provider query can’t. The provider sees a method call it has no translation for.

Batching and Indexing

Chunk(size) (.NET 6) splits a sequence into arrays of at most size elements, with a shorter final chunk. It is the standard way to send work in batches without writing a batching iterator by hand:

foreach (var batch in orderIds.Chunk(100))
    await SubmitAsync(batch);   // batch is an int[]

Index() (.NET 9) pairs each element with its position. Before .NET 9, the Select((item, i) => ...) overload did the same job:

foreach (var (index, name) in names.Index())
    Console.WriteLine($"{index}: {name}");

LINQ has no ForEach operator. Its operators are meant to compute results without side effects, and a foreach loop is the clearer way to act on each element. (List<T>.ForEach exists, but it is a method on the list, not a LINQ operator.)

Newer Operators That Replace Old Workarounds

.NET 6 onward added operators for patterns that used to need a GroupBy or a hand-written loop. They are all deferred or immediate by the same rules as the older operators.

Operator Since Replaces
DistinctBy, UnionBy, IntersectBy, ExceptBy .NET 6 A GroupBy and First, or a custom comparer, to deduplicate by a key
MinBy, MaxBy .NET 6 OrderBy(...).First() to get the element with the smallest or largest key
Order, OrderDescending .NET 7 OrderBy(x => x)
CountBy, AggregateBy .NET 9 GroupBy(...).Select(g => (g.Key, g.Count())) and similar, without building each group
LeftJoin, RightJoin .NET 10 The GroupJoin plus SelectMany plus DefaultIfEmpty pattern for an outer join

Where LINQ Performance Goes Wrong

LINQ’s overhead over a hand-written loop is usually small next to what the query does. The costly mistakes are structural: running a query more often than intended (covered above), reading more than needed, or doing work in the wrong complexity class.

Counting When You Only Need to Know Whether

Count() on a List<T>, an array, or any other ICollection<T> reads the collection’s stored count without enumerating it. On a deferred query it has no stored count, so it walks every element. Any() stops at the first one:

if (query.Count() > 0)     // walks the whole query
if (query.Any())           // stops at the first match

if (query.Count() > 5)     // walks the whole query
if (query.Skip(5).Any())   // stops after the sixth element

On a type that already exposes Count or Length, use the property and skip LINQ altogether. The CA1829 and CA1860 code-analysis rules flag Count() and Any() calls on such types. TryGetNonEnumeratedCount (.NET 6) returns a count only when the source can supply one without enumerating, which lets a method taking IEnumerable<T> size a buffer when that’s cheap and skip it when it isn’t.

Lookups Inside a Query

A Contains on a list inside a Where scans the list once per element, so the query’s cost is the product of the two sizes:

List<int> vipIds = GetVipIds();                              // 10,000 ids
var vipOrders = orders.Where(o => vipIds.Contains(o.CustomerId));   // up to 10,000 comparisons per order

var vipSet = GetVipIds().ToHashSet();
var vipOrders = orders.Where(o => vipSet.Contains(o.CustomerId));   // one hash lookup per order

The same shape appears as a FirstOrDefault inside a Select to find a matching record in another list. Build a HashSet<T> or Dictionary<TKey, TValue> once, or use Join, which builds a hash lookup of the inner sequence internally. Against a database this doesn’t apply. EF Core translates Contains on a local list inside an IQueryable query into a membership test that the database runs.

Reading More Than You Need

With IQueryable, a Select that projects only the needed columns is a smaller query, and a filter placed after ToList() or AsEnumerable() is a filter the database never saw. With IEnumerable, a buffering operator early in a chain forces every upstream element through, even when a later Take wants only a few. Put filters first and buffering operators as late as the logic allows.

Hot Paths

Each operator in a chain allocates an iterator object, and each lambda call is a delegate invocation. Recent .NET releases have removed much of this cost for common chains over arrays and lists, but in a loop that runs millions of times, a plain for or foreach can still be measurably faster. Measure before rewriting. Outside hot paths, the readability of a query is worth far more than the difference.

Key Takeaways

A query is a description until something enumerates it. Deferred operators run each time the result is enumerated, see changes to the source and to captured variables, and throw from inside their lambdas at that point, not where they were written.

Buffering operators end the laziness. OrderBy, GroupBy, and Reverse read their whole source before yielding anything.

Materialize anything you use twice. Each enumeration of a database query is another round trip.

The static type decides where a query runs. IQueryable<T> hands the expression tree to a provider to translate. The same query through an IEnumerable<T> variable, a method returning IEnumerable<T>, or a Func predicate runs in memory after loading everything.

Cross into memory deliberately. Use AsEnumerable() after every filter the provider can apply, and write reusable provider fragments as IQueryable<T> extensions that take expressions.

Fix the structure before the syntax. Any() over Count() > 0, a HashSet instead of List.Contains inside a query, and projecting before materializing matter far more than query versus method syntax.

Found this guide helpful? Share it with your team:

Share on LinkedIn