← All Quick Lessons
C# Collections & LINQ10 min read · 18 August 2026

C# Collections and LINQ Performance: Choose the Right Data Shape

Choose between List<T>, Dictionary<TKey,TValue>, HashSet<T> and frozen collections, then avoid hidden LINQ costs from unnecessary materialisation and repeated enumeration.

Performance work does not always begin with profiling tools or complicated code changes. Sometimes the most valuable improvement is choosing a collection that matches how the application will use its data.

Code-review question

What will this code do with the data most often?

Will it iterate through every item, find an item by its key, check whether a value already exists, or build the collection once and search it repeatedly? The answers should influence the shape of the data before you optimise the code that processes it.

1. Use List<T> for an ordered, growable sequence

csharp
var customers = new List<Customer>
{
    new(1, "John"),
    new(2, "Sarah"),
    new(3, "David")
};

For everyday business code, List<T> is often a sensible starting point. It provides a strongly typed, ordered and growable collection, access by numeric index, efficient addition at the end in typical usage, and straightforward sequential iteration.

Generic collections provide compile-time type safety and avoid the boxing that older non-generic collections can introduce when storing value types. Use a list when the main requirement is to keep items in order and process them sequentially. A list may be the wrong shape when the application repeatedly searches for individual items.

2. A repeated lookup may need a dictionary

csharp
var customer = customers
    .FirstOrDefault(x => x.Id == 765432);

The code is readable, but the collection does not know that Id is important. It may need to examine customers one at a time until it finds the match. If lookup by ID is a common operation, represent that requirement in the collection.

csharp
var customersById = new Dictionary<int, Customer>
{
    [1] = new Customer(1, "John"),
    [2] = new Customer(2, "Sarah"),
    [3] = new Customer(3, "David")
};

if (customersById.TryGetValue(2, out var customer))
{
    Console.WriteLine(customer.Name);
}

A list says: search through my customers until you find the matching ID. A dictionary says: customer ID is the key, so use it to locate the customer. That design decision can matter more than a collection of small code-level optimisations.

3. Big O as a practical growth warning

Big O notation describes how the amount of work changes as the amount of data grows. A list search is commonly described as O(n): as the number of items grows, the potential amount of searching grows with it. Searching ten customers may require up to ten comparisons; searching one million may require up to one million.

A dictionary lookup is designed to be approximately O(1) on average. That does not mean every lookup takes exactly the same number of processor instructions. It means lookup does not normally require a walk through the entire collection as the collection grows.

Code-review question

If the volume becomes ten or one hundred times larger, does this line perform ten or one hundred times more work?

Big O does not replace measurement, but it helps identify designs that may struggle as data grows.

4. Use HashSet<T> for uniqueness and membership checks

csharp
var processedIds = new HashSet<int>();

processedIds.Add(123);

if (processedIds.Contains(123))
{
    Console.WriteLine("Already processed");
}

A HashSet<T> expresses a membership requirement more accurately than repeatedly searching a growing list. It is designed for uniqueness and fast membership testing.

List<T>
Ordered, growable sequence

Dictionary<TKey, TValue>
Values located by unique keys

HashSet<T>
Unique values and fast membership checks

Choosing among these types should begin with behaviour, not habit.

5. Understand when a LINQ query actually runs

csharp
var activeCustomers = customers
    .Where(customer => customer.IsActive);

Many LINQ operators use deferred execution. This code describes the query, but the work normally begins when the result is enumerated.

csharp
foreach (var customer in activeCustomers)
{
    Console.WriteLine(customer.Name);
}

IEnumerable<T> is best understood as something that can provide its items one at a time. It does not necessarily represent a completed list already stored in memory. Deferred execution can avoid unnecessary work and intermediate collections, but it can also hide repeated work if you do not recognise it.

6. ToList() changes the lifetime of the query

csharp
var activeCustomers = customers
    .Where(customer => customer.IsActive)
    .ToList();

ToList() tells LINQ to execute the query now and create a List<Customer> containing the results. This is materialisation. Materialising a result is not inherently bad; the important question is whether the new collection serves a clear purpose.

csharp
var result = customers
    .Where(customer => customer.IsActive)
    .ToList()
    .Where(customer => customer.CreditLimit > 10_000)
    .ToList();

The first ToList() creates an intermediate collection before filtering is complete. If that list is not needed, compose the filters and materialise once.

csharp
var result = customers
    .Where(customer => customer.IsActive)
    .Where(customer => customer.CreditLimit > 10_000)
    .ToList();

7. Deferred execution can repeat expensive work

csharp
var matchingCustomers = customers
    .Where(customer => ExpensiveCheck(customer));

var count = matchingCustomers.Count();

foreach (var customer in matchingCustomers)
{
    Console.WriteLine(customer.Name);
}

Because the query is deferred, ExpensiveCheck may run during Count() and then run again during the foreach. If the complete result will genuinely be reused, deliberate materialisation may be better.

csharp
var matchingCustomers = customers
    .Where(customer => ExpensiveCheck(customer))
    .ToList();

var count = matchingCustomers.Count;

foreach (var customer in matchingCustomers)
{
    Console.WriteLine(customer.Name);
}

The application pays for one allocation but avoids repeating the expensive query. Fewer allocations do not automatically mean faster code. One purposeful allocation can prevent repeated computation, database access or calls to another system.

8. Consider frozen collections for read-heavy data

Some data, such as country codes, feature definitions, category mappings or validation rules, is created once and queried throughout the life of an application. Modern .NET provides FrozenDictionary<TKey,TValue> and FrozenSet<T> for this pattern.

csharp
using System.Collections.Frozen;

var countriesByCode = countries
    .ToFrozenDictionary(country => country.Code);

A frozen collection has a relatively high creation cost, but it is immutable and optimised for repeated lookup and enumeration. It is most appropriate when the collection is built infrequently and read many times afterward.

Created once
      ↓
Never modified
      ↓
Read repeatedly
      ↓
Consider a frozen collection

Do not replace every dictionary with a frozen dictionary. If data changes regularly or is used only a few times, the additional creation cost may provide no useful benefit. Collection choice follows the lifecycle of the data.

9. Express common aggregations clearly

csharp
var customerCountsByCountry = customers
    .CountBy(customer => customer.Country);

Modern LINQ includes CountBy, which expresses the intention to count items using a selected key. It can be clearer than building an intermediate grouping solely to count each group. The same discipline still applies: understand when the query executes, know whether it creates a new result, avoid repeated enumeration, and measure important paths using realistic data.

A practical collection and LINQ review checklist

  • Primary operation: What will this code do with the data most often?
  • Sequence: Do I need ordered iteration or access by numeric position?
  • Key lookup: Am I repeatedly searching by ID or another unique key?
  • Membership: Do I mainly need to know whether a value exists?
  • Uniqueness: Should duplicate values be prevented?
  • Mutation: Will the collection continue changing after it is built?
  • Materialisation: Does this ToList() or ToArray() create a result I genuinely need?
  • Enumeration: Could a deferred query be executed more than once?
  • Scale: How will the operation behave when the data grows?
  • Evidence: Is this path important enough to benchmark or profile?

The data-shape model to remember

Fixed-size ordered data
        ↓
Array

Growable ordered sequence
        ↓
List<T>

Repeated lookup by key
        ↓
Dictionary<TKey, TValue>

Uniqueness or membership checks
        ↓
HashSet<T>

Built rarely and searched repeatedly
        ↓
Consider FrozenDictionary or FrozenSet

LINQ query reused after expensive work
        ↓
Consider deliberate materialisation

There is no collection that is always fastest. A list is excellent for a sequence, a dictionary for key-based lookup, a hash set for uniqueness or membership testing, and a frozen collection for appropriate read-heavy data.

Once the data structure matches the dominant operation, the surrounding code often becomes both faster and easier to understand.

C# collections performanceLINQ performanceList<T>Dictionary<TKey,TValue>HashSet<T>FrozenDictionarydeferred executionToListBig O notation.NET performance

Want to go deeper?

Continue with the detailed C# and .NET performance guides in the Journal.