Pick the right collection for the job, then let LINQ express your intent — not a hand-rolled loop.
Collections
List, Dictionary, HashSet & when to use each
Choose by access pattern: ordered/indexed → List<T>, key lookup → Dictionary<K,V>, uniqueness → HashSet<T>.
// ── List<T> — ordered, O(1) index, O(n) search ──
var tags = new List<string> { "dotnet", "csharp", "blazor" };
tags.Add("efcore");
tags.Remove("blazor");
tags.Sort();
int idx = tags.BinarySearch("csharp"); // O(log n) on sorted list
// ── Dictionary<K,V> — O(1) lookup by key ────
var stockById = new Dictionary<int, int>
{
[1] = 100,
[2] = 50,
};
stockById.TryAdd(3, 75);
if (stockById.TryGetValue(99, out var qty))
Console.WriteLine(qty); // won't execute — key missing
// Safe update: GetValueOrDefault + []
stockById[1] = stockById.GetValueOrDefault(1) + 10;
// ── HashSet<T> — O(1) contains, no duplicates ─
var seen = new HashSet<int>();
foreach (var n in new[] { 1, 2, 2, 3, 1 })
if (!seen.Add(n))
Console.WriteLine($"Duplicate: {n}");
// Set operations
var a = new HashSet<int> { 1, 2, 3 };
var b = new HashSet<int> { 2, 3, 4 };
a.IntersectWith(b); // a = { 2, 3 }
// ── Immutable collections (read-only snapshots) ─
var frozen = tags.ToFrozenSet(); // .NET 8 — zero-alloc lookup
LINQ
LINQ fundamentals — filter, project, aggregate
LINQ is lazy — the query doesn't execute until you materialise it with ToList(), First(), Count(), etc. Keep queries deferred as long as possible when hitting a database.
record Product(int Id, string Name, string Category, decimal Price);
var products = new List<Product>
{
new(1, "Coffee", "Beverages", 3.50m),
new(2, "Tea", "Beverages", 2.00m),
new(3, "Laptop", "Electronics", 999m),
new(4, "Headphones", "Electronics", 149m),
new(5, "Notebook", "Stationery", 5.99m),
};
// Filter + order + project (deferred)
var cheapBev = products
.Where(p => p.Category == "Beverages" && p.Price < 3m)
.OrderBy(p => p.Price)
.Select(p => new { p.Name, p.Price }); // anonymous type
// Aggregate operators — execute immediately
decimal total = products.Sum(p => p.Price);
decimal avg = products.Average(p => p.Price);
var priciest = products.MaxBy(p => p.Price)!; // .NET 6+
// Quantifiers
bool hasElec = products.Any(p => p.Category == "Electronics");
bool allPositive = products.All(p => p.Price > 0);
int bevCount = products.Count(p => p.Category == "Beverages");
// Safe first/single
var item = products.FirstOrDefault(p => p.Id == 3);
if (item is not null) Console.WriteLine(item.Name);
LINQ
GroupBy, Join & SelectMany
GroupBy, Join and SelectMany cover 95% of the "harder" LINQ queries. Master them and you rarely need raw SQL for reporting.
// ── GroupBy ───────────────────────────────────
var byCategory = products
.GroupBy(p => p.Category)
.Select(g => new {
Category = g.Key,
Count = g.Count(),
Total = g.Sum(p => p.Price),
Cheapest = g.MinBy(p => p.Price)!.Name
})
.OrderByDescending(x => x.Total);
// ── Join ──────────────────────────────────────
record OrderLine(int OrderId, int ProductId, int Qty);
var lines = new[] {
new OrderLine(1, 1, 3),
new OrderLine(2, 3, 1),
new OrderLine(3, 2, 5),
};
var orderDetails = lines.Join(
products,
l => l.ProductId,
p => p.Id,
(l, p) => new {
l.OrderId,
p.Name,
l.Qty,
LineTotal = p.Price * l.Qty
});
// ── SelectMany — flatten nested sequences ─────
var categories = new[] {
new { Name = "Beverages", Items = new[]{"Coffee","Tea"} },
new { Name = "Electronics", Items = new[]{"Laptop","Headphones"} },
};
IEnumerable<string> allItems = categories
.SelectMany(c => c.Items); // "Coffee","Tea","Laptop","Headphones"
// With result selector (item + parent)
var withCategory = categories
.SelectMany(c => c.Items, (c, item) => $"{c.Name}/{item}");
Performance
Span<T>, Memory<T> & ArrayPool — zero-alloc patterns
Span<T> is a zero-allocation window into any contiguous memory — array, stack, or native. Use Memory<T> when you need to cross an await boundary. Rent from ArrayPool<T>.Shared to stop hammering the GC in hot paths.
// ── ReadOnlySpan<char> — parse without allocating ─
string csv = "10,20,30,40,50";
ReadOnlySpan<char> span = csv.AsSpan();
int total = 0, start = 0;
for (int i = 0; i <= span.Length; i++)
{
if (i == span.Length || span[i] == ',')
{
total += int.Parse(span[start..i]); // no ToString()
start = i + 1;
}
}
// 110 — zero intermediate string allocations
// ── stackalloc — allocate a buffer on the stack ─
Span<int> buf = stackalloc int[16];
for (int i = 0; i < buf.Length; i++) buf[i] = i * i;
// automatically freed when the method returns
// ── Memory<T> — Span that survives await ──────
// Span is a ref struct — cannot be captured in async state machines
// Memory<T> wraps the same memory but is a plain struct
async Task CopyAsync(Memory<byte> data, Stream dest, CancellationToken ct)
=> await dest.WriteAsync(data, ct); // WriteAsync accepts Memory<byte>
// ── ArrayPool<T> — rent and return ────────────
var pool = System.Buffers.ArrayPool<byte>.Shared;
byte[] rented = pool.Rent(4096); // may return > 4096 bytes
try
{
int n = await _stream.ReadAsync(rented.AsMemory(0, 4096), ct);
ProcessBytes(rented.AsSpan(0, n));
}
finally
{
pool.Return(rented, clearArray: false); // must always return!
}
// ── string.Create — build a string in-place ───
int id = 42;
string tag = string.Create(7, id, (span, state) =>
{
"item-".AsSpan().CopyTo(span);
state.TryFormat(span[5..], out _);
});
Console.WriteLine(tag); // "item-42" — zero extra allocations