Concepts
What you will learn: When a method accepts a collection, its type determines whether callers can read, add or search efficiently. We will compare common collections, use to transform, filter and group data, and see when a query actually runs and how that affects performance.
Before you start: A collection holds elements; an array and List are examples. A loop visits elements one by one, while LINQ expresses operations such as Where and Select. If these are new, start with the first card and read the examples as alternatives to loops.
Select a term with a dotted underline for its meaning, then continue reading from the same place.
#Choose the collection contract you need
A method that only iterates should not require callers to supply a List. Choose an interface by the operations needed, without promising that the interface does not enforce.
IEnumerable<T>
+-- ICollection<T> Count, Add, Remove, IsReadOnly
| +-- IList<T> indexed get/set, Insert, RemoveAt
| +-- ISet<T> set operations
+-- IReadOnlyCollection<T> Count
| +-- IReadOnlyList<T> indexed get
+-- IQueryable<T> Expression, Provider, ElementType
IReadOnlyList<T> does not inherit IList<T>.
static decimal Total(IEnumerable<decimal> prices) => prices.Sum();
var mutable = new List<int> { 1, 2 };
IReadOnlyList<int> view = mutable; // (1)
mutable.Add(3);
Console.WriteLine(view.Count); // (2) 3
- (1) The view exposes no Add method but still refers to the same List.
- (2) Changes through another reference are visible; a read-only interface is neither a snapshot nor deep immutability.
does not guarantee lazy, cheap or repeatable enumeration. An ICollection implementation may be fixed-size or read-only, making mutation throw NotSupportedException. Avoid changing a List during foreach; use RemoveAll or a deliberate iteration strategy.
#Array, List and LinkedList costs
Choosing a collection because “insertion is faster” is incomplete without specifying the position and the cost of finding it.
| Type | Indexed access | Insertion and removal | Trade-off |
|---|---|---|---|
| T[] | O(1) | Fixed length; shifting or copying is O(n) | Contiguous storage for known sizes |
| List<T> | O(1) | Append O(1) amortized; middle changes O(n) | Spare capacity and copying on resize |
| LinkedList<T> | O(n) to reach a position | O(1) around a known node; search O(n) | Per-node and weaker locality |
var ids = new List<int>(capacity: 1000); // (1)
ids.Add(10);
ids.Add(20);
ids.RemoveAll(id => id < 15); // (2)
var queue = new LinkedList<int>();
var node = queue.AddLast(20);
queue.AddBefore(node, 10); // (3)
- (1) Capacity is not Count; it reserves storage when the approximate size is known.
- (2) Predicate-based removal avoids mutation during foreach.
- (3) Having the node already makes insertion O(1); searching first adds its own cost.
Start with List for general use. Queue and Stack express or requirements more directly than LinkedList.
#Dictionary and HashSet: index repeated lookups
Instead of scanning every product for each order line, build an index once. Dictionary maps a key to a value; HashSet represents membership and uniqueness according to a comparer.
foreach (var line in lines)
{
var product = products.First(p => p.Id == line.ProductId);
Console.WriteLine(product.Name);
}var byId = products.ToDictionary(p => p.Id); // (1)
foreach (var line in lines)
{
if (byId.TryGetValue(line.ProductId, out var p)) // (2)
Console.WriteLine(p.Name);
}var emails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool added = emails.Add("buyer@example.com");
bool duplicate = emails.Add("BUYER@example.com"); // false
- (1) Building the index takes average O(m); ToDictionary rejects duplicate keys.
- (2) Average O(1) lookups make expected total work O(m + n) instead of O(m × n), at a memory cost.
Collisions can degrade hash operations to O(n) in the worst case. List.Contains is O(n); SortedDictionary provides O(log n) operations with key ordering. Equal keys must have equal hashes. Do not mutate hash-participating fields while a key is stored, or rely on Dictionary enumeration order as a contract.
#IEnumerable, IQueryable and generated SQL
- Assume 10,000 orders, with only 20 above 1,000. The screen needs just those orders' IDs and totals.
- Translatable Where and Select operations before AsEnumerable can be sent by to the database, returning only the required shape.
- After switching to Enumerable, that filter executes in the application during enumeration and may transfer unnecessary orders. An describes operations for translation; a is executable behavior.
With EF Core, is a request sent to the store: “return these columns for large orders.” describes iteration; Enumerable operators after the boundary run .NET code over returned elements.
var big = db.Orders
.AsEnumerable() // (1)
.Where(o => o.Total > 1000m)
.Select(o => new { o.Id, o.Total })
.ToList();var query = db.Orders
.Where(o => o.Total > 1000m) // (2)
.Select(o => new { o.Id, o.Total });
Console.WriteLine(query.ToQueryString()); // (3)
var big = await query.ToListAsync(ct);-- Representative SQL Server output; aliases vary by provider/version.
-- Before: all mapped order columns, no Total predicate.
SELECT [o].[Id], [o].[CustomerId], [o].[Total], [o].[Status]
FROM [Orders] AS [o];
-- After:
SELECT [o].[Id], [o].[Total]
FROM [Orders] AS [o]
WHERE [o].[Total] > 1000.0;
- (1) AsEnumerable neither executes nor buffers by itself; it changes subsequent operator binding. This filter runs client-side during iteration.
- (2) Queryable.Where receives an expression tree for provider translation; Enumerable.Where receives a delegate.
- (3) ToQueryString diagnoses ; ToListAsync executes. These snippets require EF Core and a exposing Orders.
Not every IQueryable is SQL-backed, and not every expression translates. Modern EF Core generally rejects untranslatable Where expressions; top-level projection can use client evaluation. Keep query composition within the DbContext lifetime, inspect SQL and examine the execution plan.
#Deferred execution and repeated enumeration
- Start with 1, 2 and 3, then describe a deferred query selecting values greater than 2. Defining it neither copies the source nor starts filtering.
- Add 4 before consuming the query: it yields 3 and 4, reflecting the source at execution time.
- Calling ToList before adding 4 would retain the result at that time: just 3. This is materialization; retaining the result elements does not deep-clone objects within them.
A query is a recipe, not a finished meal. Where and Select are deferred and streaming. In -to-Objects, OrderBy and GroupBy are deferred but need buffering before yielding their first result.
var ids = new List<int> { 1, 2, 3 };
var query = ids.Where(id => id > 1); // (1)
ids.Add(4);
Console.WriteLine(query.Count()); // (2) 3
var snapshot = query.ToArray(); // (3)
ids.Add(5);
Console.WriteLine(snapshot.Length); // 3
var paid = db.Orders.Where(o => o.Status == "Paid");
bool exists = await paid.AnyAsync(ct); // (4) query one
decimal total = await paid.SumAsync(o => o.Total, ct); // query two
- (1) No filtering has run yet.
- (2) Count executes the recipe against the modified source.
- (3) ToArray executes immediately and fixes the elements, but does not deep-copy their objects.
- (4) Each terminal operation issues its own query; data can change between them.
once when reusing a reasonably sized result. If only a total is needed, remove the Any call and in the database instead of loading rows. does not guarantee that a file or network source can be read twice. Immediate operators include ToList, ToDictionary, First, Any and Sum.
#Projection, flattening and grouping
Decide the result shape first: one result per order, all lines in one sequence, or a total per customer. These are different transformations.
var totals = orders.Select(o => o.Total); // (1)
var lines = orders.SelectMany(o => o.Lines); // (2)
var detail = orders.SelectMany(o => o.Lines,
(o, line) => new { OrderId = o.Id, line.ProductId });
var revenue = orders.GroupBy(o => o.CustomerId) // (3)
.Select(g => new
{
CustomerId = g.Key,
Count = g.Count(),
Total = g.Sum(o => o.Total)
});
- (1) Select produces one result per input, even when that result is itself a collection.
- (2) SelectMany flattens nested collections; its result-selector overload preserves the order context.
- (3) GroupBy creates keyed groups, followed here by aggregation.
In memory, GroupBy retains elements in groups. Consider CountBy for counts alone. In , scalar group aggregates commonly translate to GROUP BY; returning entire groups has a different shape. SelectMany translation depends on the expression and provider.
#Join and .NET 10 LeftJoin
An inner join excludes unmatched rows. A customer report may need customers with no orders; a left join preserves them and allows a missing right-side value.
var matched = customers.Join(orders, // (1)
c => c.Id, o => o.CustomerId,
(c, o) => new { c.Name, OrderId = o.Id });
var all = customers.LeftJoin(orders, // (2) .NET 10
c => c.Id, o => o.CustomerId,
(c, o) => new { c.Name, OrderId = o == null ? (int?)null : o.Id });
// Before .NET 10:
var old = from c in customers
join o in orders on c.Id equals o.CustomerId into matches
from o in matches.DefaultIfEmpty() // (3)
select new { c.Name, OrderId = o == null ? (int?)null : o.Id };
- (1) Each matching pair produces a row; three orders repeat a customer three times.
- (2) LeftJoin retains customers without orders. The example assumes Order is a class with an int Id.
- (3) GroupJoin with DefaultIfEmpty is the older equivalent.
| Older | Modern |
|---|---|
| GroupJoin, SelectMany and DefaultIfEmpty | 10 LeftJoin and RightJoin make join direction explicit |
For , use 10 with a compatible provider and inspect . Joins do not remove duplicates. A later WHERE predicate on the right side can remove unmatched rows unless it explicitly permits missing values.
#Element selection and quantifiers
The operator expresses a contract: “the first match” differs from “exactly one match.” Do not hide duplicate-data bugs with First for convenience.
| Operator | No matches | Multiple matches |
|---|---|---|
| First | Exception | First match |
| FirstOrDefault | default(T) | First match |
| Single | Exception | Exception |
| SingleOrDefault | default(T) | Exception |
bool hasPaid = orders.Any(o => o.Status == "Paid"); // (1)
bool allPaid = orders.All(o => o.Status == "Paid"); // (2)
bool nonEmptyAndPaid = orders.Count > 0 && allPaid; // List<Order>
int absent = Array.Empty<int>().FirstOrDefault(); // (3) 0
- (1) Any with a predicate stops at the first match; commonly translates it to EXISTS.
- (2) All stops at the first violation and returns true for an empty sequence.
- (3) default is not always null; for int it is zero, which may also be valid data.
List.Count is O(1), and Count() may use collection counts; not every Count scans. Single does not replace a database unique constraint. Specify ordering when “first” has business meaning.
#Ordering and pagination: offset versus keyset
Order pages need a deterministic, unique ordering or items can repeat or disappear between pages. OrderBy establishes a new primary ordering; ThenBy adds a tie-breaker.
int pageSize = 20;
var offsetPage = await db.Orders
.OrderBy(o => o.CreatedAt).ThenBy(o => o.Id) // (1)
.Skip((pageNumber - 1) * pageSize).Take(pageSize)
.Select(o => new { o.Id, o.CreatedAt, o.Total })
.ToListAsync(ct);
var nextPage = await db.Orders
.Where(o => o.CreatedAt > lastTime || // (2)
(o.CreatedAt == lastTime && o.Id > lastId))
.OrderBy(o => o.CreatedAt).ThenBy(o => o.Id)
.Take(pageSize).ToListAsync(ct);
- (1) Id breaks CreatedAt ties; the example assumes a numeric Id. Validate pageNumber and cap pageSize.
- (2) The cursor contains both timestamp and Id. Its predicate must match sort direction, with an index starting on those columns.
Offset supports numbered jumps but deep offsets require more work and rows shift under inserts or deletes. Keyset suits next/previous and avoids traversing earlier pages, but arbitrary jumps are harder. Changes to sort keys can still affect results; a cursor is not a snapshot.
#Batching, deduplication and per-key aggregates
For batches or counts, full groups and nested loops may be unnecessary. These are -to-Objects examples; an Enumerable operator does not guarantee translation.
foreach (var batch in orders.Chunk(100)) // (1) .NET 6
Console.WriteLine(batch.Length);
var unique = products.DistinctBy(p => p.Sku,
StringComparer.OrdinalIgnoreCase); // (2) .NET 6
var counts = orders.CountBy(o => o.CustomerId); // (3) .NET 9
var totals = orders.AggregateBy(o => o.CustomerId,
seed: 0m, func: (sum, order) => sum + order.Total); // (4) .NET 9
- (1) Chunk yields arrays; the final batch may be smaller. It does not provide bulk insertion or .
- (2) DistinctBy uses key equality; explicitly order or select when a particular representative matters.
- (3) CountBy yields a KeyValuePair for each key and count without retaining every group element.
- (4) AggregateBy holds an accumulator per customer. Memory grows with distinct keys, and the source must be read before results are available.
The older equivalents are GroupBy followed by Count or another aggregate. Chunk after AsEnumerable is not database pagination; materializing the whole table first also loses streaming benefits.
#Frozen, immutable and concurrent collections
A read-heavy, rarely updated catalog can use a suitable snapshot instead of locking readers around a changing dictionary. Define how the new snapshot is safely published.
using System.Collections.Frozen;
using System.Collections.Immutable;
var prices = new Dictionary<int, decimal> { [10] = 120m };
FrozenDictionary<int, decimal> frozen = prices.ToFrozenDictionary(); // (1)
var original = ImmutableDictionary<int, decimal>.Empty.Add(10, 120m);
var changed = original.SetItem(10, 125m); // (2)
Console.WriteLine(original[10]); // 120
Console.WriteLine(changed[10]); // 125
- (1) Frozen collections, introduced in 8, spend more work at construction to optimize repeated lookups and cannot then be updated.
- (2) ImmutableDictionary returns a new logical version using structural sharing, leaving the original unchanged.
Frozen suits build-once/read-many data; collections suit versioned updates. Neither makes mutable contained values immutable. For shared mutable collections, see ConcurrentDictionary and Channels in topic 04: Async and multithreading. Publishing an immutable snapshot still needs coordination when multiple writers update it.
Interview questions
No results
Check your reasoning
Why does this choice fit? When would it fail? What changes under higher load or a dependency failure?
5.1What is LINQ, and are method and query syntax different execution engines?Junior
I use to compose strongly typed filtering, , grouping and aggregation. Method syntax calls operators directly, while query syntax is translated by the compiler into operator calls. They are not separate execution engines. The source and selected operators determine behavior: Enumerable normally executes over objects, while Queryable builds expressions for a provider. I choose the syntax that makes the query readable and inspect provider behavior when the source is a database.
5.2How do IEnumerable, ICollection and IList differ?Junior
exposes iteration. ICollection adds a count and collection operations such as Add and Remove, although a read-only implementation can reject mutations. IList adds indexed access and position-based operations. I choose the smallest contract that expresses what the method needs. IReadOnlyCollection and IReadOnlyList expose reading without mutation members, but they do not guarantee an object: another reference may still change the backing collection, so I document snapshot requirements separately.
5.3When would you choose Array, List or LinkedList?Junior
I use an array when the size is fixed and indexed storage is appropriate, and a List for a growable sequence with frequent reads. List append is amortized constant time, but middle insertion shifts elements. LinkedList can insert or remove in constant time when I already hold the node; finding that node is linear. Its and poor locality also matter. I would usually choose Queue or Stack directly for simple or requirements.
5.4Why are Dictionary and HashSet useful for repeated lookups?Junior
I use Dictionary for key-to-value lookup and HashSet for membership and uniqueness. Their usual lookup cost is constant time on average, compared with a linear scan through a List. Building one index can replace repeated nested scans and reduce expected total work substantially. That trades extra memory and construction cost for faster reuse. I also choose the equality comparer deliberately and avoid mutable keys, because collisions and inconsistent hashing can undermine performance or correctness.
5.5What is the difference between Select and SelectMany?Junior
Select maps each input to one result. If I select an order's Lines property, the result is a sequence of line collections. SelectMany maps each input to a sequence and flattens those sequences, so I get all order lines together. An order with no lines contributes no elements. When I also need the order identifier, I use the result-selector overload to combine the outer order with each inner line.
5.6How do First and Single express different requirements?Junior
First asks for the first match and throws when none exists. Single requires exactly one match and also throws when duplicates exist. Their OrDefault forms permit an empty result, but SingleOrDefault still rejects multiple matches. I use Single when duplicate data violates the operation's contract, while also enforcing uniqueness in the database. I remember that default is type-dependent, and I specify ordering if the identity of the first row matters.
5.7Why does All return true on an empty sequence?Junior
All returns true because there is no element that violates the predicate. I would not use it alone to mean that an order has at least one line and every line is valid. That rule needs both non-emptiness and the universal condition. On a materialized List I can inspect Count and then call All. On an expensive deferred source, I consider a single-pass implementation or an appropriate database expression to avoid repeated enumeration.
5.8What changes when IQueryable becomes IEnumerable before filtering?Mid
The later Where binds to Enumerable and receives a rather than an for translation. With an source, the database query before that boundary still runs on enumeration, but the later filter runs in over returned rows. AsEnumerable itself does not execute or buffer. I keep translatable filters and projections before the boundary, inspect generated , and move to client processing only after deliberately limiting the server result.
5.9What is deferred execution, and is every deferred operator streaming?Mid
means constructing a query does not immediately process its source. Enumeration or a terminal operator triggers the work. It does not necessarily mean streaming: Where and Select can yield progressively, while -to-Objects OrderBy must buffer before producing sorted results. I consider both timing and memory behavior. Re-enumerating can repeat expensive work or see changed data, so I a reasonably sized result when I need a stable, reusable snapshot.
5.10How would you fix Any followed by Sum on the same EF query?Mid
I first ask what result is actually needed. If the caller only needs the total, I issue the directly and remove the existence query. If it needs the rows several times and their size is bounded, I once and reuse them. I would not load an entire table just to save one round trip. Separate count and page queries can be intentional, but I make their cost and possible consistency differences explicit.
5.11When is Any preferable to Count greater than zero?Mid
When I need existence, Any expresses that intent and can stop at the first matching element; commonly translates it to EXISTS. Counting matching rows asks for more information than needed. I avoid saying that every Count scans everything, though: List.Count is a constant-time property and some Enumerable.Count calls use a known collection count. The choice should follow the required result, the source type and, for databases, the generated and execution plan.
5.12What happens if you call OrderBy twice instead of ThenBy?Mid
A second OrderBy establishes a new primary ordering rather than adding a secondary key. I use ThenBy or ThenByDescending for tie-breaking, such as ordering orders by creation time and then identifier. -to-Objects sorting is stable, so previous order can still affect ties, but I do not rely on that to express a composite sort. For database paging I explicitly include a unique tie-breaker because row order is otherwise not deterministic.
5.13How does LeftJoin differ from Join in .NET 10?Mid
Join produces matching pairs and excludes unmatched outer elements. LeftJoin, introduced in 10, retains every left element and supplies a default right value when nothing matches. For reference-type orders that means handling null in the . The older form uses GroupJoin followed by SelectMany and DefaultIfEmpty. I also remember that multiple matches produce multiple rows and that a later right-side filter can accidentally exclude the unmatched rows I intended to preserve.
5.14What do Chunk, DistinctBy, CountBy and AggregateBy solve?Mid
I use Chunk to consume a sequence in arrays of bounded batch size and DistinctBy to deduplicate by a selected key. CountBy and AggregateBy, added in 9, maintain per-key counts or accumulators without retaining all group elements as GroupBy does. They still need memory proportional to distinct keys. I treat these as source-specific tools: using an Enumerable operator does not guarantee database translation, and Chunk alone does not create bulk or durable processing.
5.15How would you design stable pagination for a large changing order list?Senior
I use a deterministic ordering, for example CreatedAt followed by a unique numeric Id, with a matching index. A keyset cursor carries both values and the next-page predicate compares them lexicographically in the same sort direction. This avoids deep offset scans and many shifts caused by new rows. The trade-off is harder random page access. I also define behavior when sort keys change, because a cursor does not provide snapshot across separate requests.
5.16How do frozen and immutable collections differ?Senior
I choose frozen collections for data built infrequently and read repeatedly, because they optimize lookup after construction and offer no update path. collections support producing new logical versions while preserving old ones, often with structural sharing. Both protect collection structure, not necessarily mutable objects stored inside it. When replacing a shared snapshot, I still coordinate writers and publish the new reference safely; immutability does not automatically make a multi-step read-modify-write operation atomic.
5.17What can make an apparently correct LINQ query fail against EF Core?Senior
An expression that executes perfectly over objects may have no translation in the database provider. A custom method in Where is a common example. Provider support, null semantics and database collation can also affect behavior. I inspect ToQueryString, test against the real provider and review the execution plan rather than relying only on in-memory tests. If client processing is necessary, I first apply server-side restrictions and so the boundary does not pull excessive data.
5.18Does a read-only collection interface make sharing across threads safe?Senior
No. IReadOnlyList only restricts operations through that reference; another owner can still mutate the underlying List, and contained objects can remain mutable. For stable shared data I prefer an or frozen snapshot with deliberate publication. For changing shared state I use synchronization or a concurrent collection whose guarantees match the operation. I also distinguish atomic collection methods from a larger business workflow, because several individually safe calls are not automatically one atomic .
Test yourself
Quiz answers and completed-topic marks are saved in this browser; they do not sync across devices.
What does AsEnumerable do by itself?
What does an empty sequence's All(predicate) return?
What does SingleOrDefault do when two items match?
Which operation is O(1) for LinkedList when the node is already known?
Average Dictionary lookup and worst-case collision-heavy lookup are respectively...
Which expression adds a secondary sort key?
A cursor sorts by CreatedAt and Id. What must it carry?
Which .NET 9 operator counts elements per key without retaining full groups?
What does a left join return for a customer with no matching Order class instance?
Which collection suits a catalog built once and queried many times?