Levelwise
English
C# and .NET basics

LINQ and Collections

A LINQ query does not run until someone asks for the result. On IQueryable, the query is translated to SQL and runs in the database; on IEnumerable, it runs in the app's memory. Choose a collection based on the job you do with it most.

Not reviewedWritten with AI helpReading time: 14 minOnline shop product catalog exampleC# and .NET 10 code

Author: bezzad

The problem: a page that reads a million rows

Our shop has one million products. The “products in a category” page is slow, and the server’s memory goes up. This is the code:

public IEnumerable<Product> GetAll() => db.Products;

// somewhere else
var phones = repository.GetAll()
    .Where(p => p.CategoryId == categoryId)
    .ToList();

The code looks right. The Where condition is there. So why is the whole table read? The answer is in two ideas: deferred execution and the difference between IEnumerable and IQueryable.

Deferred Execution

A LINQ query is like a recipe, not the food itself. When you write Where and Select, you only write the recipe. Nothing happens.

productsProduct listWhereOnly cheap onesSelectOnly the nameToList / foreachRunning starts here1. "Give me one" goes from the end to the start2. Each product passes all steps, one by oneNothing happens until someone asks for the result
The ToList method or a foreach loop asks the end of the chain to 'give me one'. Each product goes through all the steps, one by one.

The query runs only when someone asks for the result:

  • A foreach loop over it.
  • Methods like ToList, ToArray, ToDictionary.
  • Methods that return a single value, like Count, First, Any, Sum.
var prices = new List<decimal> { 900, 5 };

var cheap = prices.Where(p => p < 100); // nothing runs here

prices.Add(8);

Console.WriteLine(cheap.Count()); // 2: the query runs now and sees the new price

Danger: running more than once

Because the query is only a recipe, it runs again from the start every time you ask for the result.

var newProducts = db.Products.Where(p => p.CreatedAt > lastWeek);

if (newProducts.Any())                 // query 1 to the database
{
    foreach (var p in newProducts)     // query 2 to the database
        Notify(p);
}

Here we go to the database twice. If a product is added between the two, the results of the two queries are also different. The right way: get the result once with ToListAsync, then work with that list.

IEnumerable or IQueryable?

These two interfaces look alike, but they have a big difference:

  1. The IEnumerable type runs the query with C# code. The Where condition is a normal method. The data must first be in the app’s memory.
  2. The IQueryable type keeps the query as a tree (Expression Tree). EF Core reads this tree and translates it to SQL. The condition runs in the database itself.
IQueryableApp.Where(p => p.CategoryId == 7)DatabaseWHERE CategoryId = 7Translated to the DB's language20 rows come backIEnumerableAppFilter in memoryDatabaseSELECT * FROM ProductsNo conditionOne million rows come back, then 20 remain
The same Where condition, but two very different results.

Now look again at the code at the start of the lesson. The GetAll method returns IEnumerable. So, step by step:

  1. The Where method called after it is the IEnumerable Where, not the IQueryable one.
  2. This Where cannot be translated to SQL. So EF Core sends a query with no condition.
  3. One million rows come over the network, and one million objects are created in memory.
  4. Then, in memory, all but a few hundred rows are thrown away.

The same thing happens with the AsEnumerable method. Everything after it runs in memory.

The right code

Write the condition and the column selection before the query runs. Read only the columns you need:

public sealed record ProductRow(int Id, string Name, decimal Price);

public Task<List<ProductRow>> GetByCategoryAsync(int categoryId, CancellationToken ct) =>
    db.Products
        .Where(p => p.CategoryId == categoryId)
        .OrderBy(p => p.Price)
        .Select(p => new ProductRow(p.Id, p.Name, p.Price))
        .Take(50)
        .ToListAsync(ct);
Not all C# code translates to SQL. If you call one of your own normal methods inside Where, EF Core cannot turn it into SQL and throws an error. The fix is not to bring everything into memory with AsEnumerable. Write the condition with things the database understands.

Choosing the right collection

Now the data is in memory. Which collection should we choose? The main question is: what do you do with it most?

List.Contains("P-907")P-101P-233P-318P-450P-512P-777P-850P-907Eight comparisons. With a million products, up to a million comparisonsHashSet.Contains("P-907")hash("P-907") → 501234567One jumpAbout one step, whatever the number of products
The Contains method on a List checks every item one by one. In a HashSet, the hash goes directly to the right place.
Type Main job Lookup Shop example
List Order matters, access by index One by one (slow for a lot of data) Shopping cart lines
Dictionary Find by key About one step Product by id
HashSet Only “is it there or not?”, no duplicates About one step Ids of out-of-stock products
Queue First in, first out None Queue of orders to process
SortedDictionary Keys are always sorted Fast, but slower than Dictionary Prices in order
FrozenDictionary Built once, only read Very fast Settings and the shipping cost table
ConcurrentDictionary Several threads write together About one step A cache in a Singleton

A real example. From 10 thousand order lines, we want to find the ones whose item is out of stock:

// Slow: for each line, Contains checks the whole list
List<int> outOfStock = await LoadOutOfStockIdsAsync(ct);
var blocked = lines.Where(l => outOfStock.Contains(l.ProductId)).ToList();

// Fast: one hash lookup for each line
HashSet<int> outOfStockSet = [.. await LoadOutOfStockIdsAsync(ct)];
var blocked2 = lines.Where(l => outOfStockSet.Contains(l.ProductId)).ToList();

With 10 thousand lines and 10 thousand ids, the first version does up to a hundred million comparisons. The second version does about 10 thousand.

Method parameter and return types

  • For a method’s input, take the most general type. If you only loop over the data, IEnumerable is enough.
  • For a method’s output, return a read-only type. Like IReadOnlyList or IReadOnlyCollection. The caller knows the data is fully in memory, and can get its count without running anything again.
  • Never return IEnumerable over a DbSet from a Repository. That is the same problem as at the start of the lesson.

A few useful methods

  • The Any method instead of comparing Count with zero. The Any method stops at the first match. On a database, it also builds a lighter query.
  • The ToDictionary or ToLookup method for many lookups. If in a loop you search for an item many times with Where or First, build a Dictionary first.
  • The CountBy method, from .NET 9. Counting by a key, without building full groups.
// How many orders does each customer have?
foreach (var (customerId, count) in orders.CountBy(o => o.CustomerId))
    Console.WriteLine($"{customerId}: {count}");

Key rules

  1. A LINQ query is a recipe, not a result. It does not run until someone asks for the result.
  2. Get the result once. If you use the result more than once, call ToList first.
  3. On a database, write the condition, Select and Take before running the query. So all of them are in the SQL.
  4. Know the IQueryable boundary. After AsEnumerable, or after returning IEnumerable, everything is in memory.
  5. For many lookups, not a List. Use a Dictionary or a HashSet.
  6. In hot code, LINQ has a cost. Each lambda and each step has a small allocation. In normal code it does not matter. In hot code, measure.

Common mistakes

Mistake Result Right way
Returning IEnumerable over a DbSet The whole table comes into memory. The condition on IQueryable, then run it.
Calling AsEnumerable before Where Filtering in memory, not in the database. Where first, then run it.
Using one query several times Several database queries, maybe with different results. ToList once.
The Contains method on a big List inside a loop Time grows with the square. HashSet or Dictionary.
Comparing Count with zero instead of Any Counting everything, just for a yes or no. The Any method.
Reading all columns for a simple list Extra data over the network and in memory. Choose columns with Select.

Summary in six lines

  1. A LINQ query does not run until someone asks for the result.
  2. Every time you ask for the result, the query runs again from the start.
  3. On IQueryable, the query is translated to SQL. On IEnumerable, it runs in memory.
  4. Write the condition and the column selection before running, on IQueryable.
  5. For lookup by key or “is it there or not?”, choose Dictionary and HashSet, not List.
  6. Return read-only types from methods.