Using Lookup in C# LINQ for One-to-Many Maps
Learn how to create and use a C# LINQ Lookup for one-to-many key mappings, and when it fits better than Dictionary or GroupBy.
A LINQ Lookup<TKey, TElement> in C# is a one-to-many map: one key can be associated with a sequence of elements. This article shows the ToLookup syntax, how to access the resulting groups, and when a Lookup is a better fit than Dictionary or GroupBy.
What a Lookup Is in LINQ
A Lookup<TKey, TElement> is a one-to-many key mapping. Where a Dictionary<TKey, TValue> maps a key to a single value, a Lookup maps a key to a sequence of elements. The class implements ILookup<TKey, TElement>, which exposes an indexer and a Contains method but no way to add or remove entries after construction.
You create a Lookup with the ToLookup extension method rather than with a public constructor:
var ordersByCustomer = orders.ToLookup(o => o.CustomerId);
Each key in the resulting Lookup corresponds to one or more elements. If a customer has no orders, that customer does not appear as a key at all.
Creating a Lookup with ToLookup
ToLookup executes immediately. When you call it, the entire source sequence is enumerated and the internal hash table is built. This is different from GroupBy, which defers execution until the result is enumerated.
The simplest form takes a key selector:
var byRegion = customers.ToLookup(c => c.Region);
An overload accepts an element selector, so you can project the values stored under each key:
var namesByRegion = customers.ToLookup(c => c.Region, c => c.Name);
There is also an overload that takes a custom IEqualityComparer<TKey>. Use it when keys need case-insensitive comparison or when the default comparer does not fit the type:
var byName = products.ToLookup(p => p.Code, StringComparer.OrdinalIgnoreCase);
Because the source is fully consumed at the call site, a Lookup is a snapshot of the data. If the source is an IEnumerable backed by a database query, the query runs once at this point, not on each access.
Accessing Values by Key
The indexer returns an IEnumerable<TElement> for the requested key. The important behavior is that a missing key returns an empty sequence rather than throwing:
var customerOrders = ordersByCustomer[42]; Console.WriteLine(customerOrders.Count()); // 0 when customer 42 has no orders
This makes the indexer safe to use without a TryGetValue pattern. To check whether a key exists at all, use Contains:
if (ordersByCustomer.Contains(42)) { // key exists }
Iterating over the Lookup yields IGrouping<TKey, TElement> objects, so you can process each key and its values together:
foreach (var group in ordersByCustomer) { Console.WriteLine($"{group.Key}: {group.Count()} orders"); }
Lookup vs Dictionary
The two types serve different purposes. A Dictionary maps a key to a single value and throws KeyNotFoundException when a key is absent. A Lookup maps a key to a collection and returns an empty sequence for a missing key.
| Behavior | Dictionary<TKey, TValue> | Lookup<TKey, TElement> |
|---|---|---|
| Duplicate keys | Not allowed | Allowed |
| Missing key access | Throws | Returns empty sequence |
| Mutability | Mutable | Immutable after creation |
| Creation | Constructor or collection initializer | ToLookup() |
Use a Dictionary when each key must map to exactly one value and you need to add or update entries. Use a Lookup when the source data already contains repeated keys and you only need read access.
Lookup vs GroupBy
GroupBy and ToLookup produce similar groupings, but they differ in execution timing. GroupBy is deferred; the grouping is computed when you enumerate the result. ToLookup is immediate; the grouping is computed when you call it.
This matters when the source sequence is expensive or changes between calls. With GroupBy, each enumeration re-runs the grouping logic. With a Lookup, the data is materialized once and subsequent access is cheap.
A Lookup also supports direct key access through its indexer. GroupBy results do not provide an indexer; you must iterate or use First with a predicate to find a specific group.
Performance and Memory Considerations
Building a Lookup requires one pass over the source and memory for the internal hash table plus the stored elements. The cost is proportional to the number of elements. Once built, key access is O(1) on average, using the same hash-based mechanism as a Dictionary.
The tradeoff is that the Lookup holds references to all elements for the lifetime of the object. If the source is large and you only need a few keys, building a Lookup may waste memory. In that case, filtering the source first or using a Dictionary with lists may be more appropriate.
Repeated access is where a Lookup pays off. If code performs many lookups by the same key, materializing once with ToLookup avoids re-scanning the source for each query.
Limitations and Edge Cases
A Lookup is immutable. There is no Add or Remove method, so you cannot modify it after creation. If you need to update the mapping, build a new Lookup from the changed source or use a Dictionary<TKey, List<TElement>> and manage the lists yourself.
Keys are compared by the default equality comparer for TKey or by a comparer you supply to ToLookup. Choose that comparer carefully when keys have formatting variations, such as different casing. Null-key behavior can vary by LINQ implementation, so if your key selector can return null, verify how the .NET runtime you target handles that case.
Thread safety follows the same rule as other collection types: a Lookup is safe for concurrent reads as long as it is not modified, and since it is immutable, concurrent reads are safe after construction.