C# Fundamentals: From Your First Program to Async and JSON · C# Basics: Types, Collections, Classes and Pattern Matching

Collections and LINQ

Use List, Dictionary and HashSet, and query data declaratively with LINQ: Where, Select, OrderBy, GroupBy, Join and aggregation.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

An order report. You'll load orders into collections, filter and sort them with LINQ, group sales by customer and country, join orders to customers, and compute totals.

The idea

The core collections live in System.Collections.Generic:

  • List<T>: a resizable array (Add, Remove, Contains, indexing).
  • Dictionary<TKey, TValue>: key/value lookup. Use TryGetValue to read safely.
  • HashSet<T>: unique items with fast Contains.
  • Collection expressions (C# 12): List<int> a = [1, 2, 3]; and spread [..a, 4].

LINQ (Language Integrated Query) is a set of extension methods on IEnumerable<T> for querying any sequence:

  • Where filters, Select transforms, OrderBy/ThenBy sort.
  • GroupBy groups, Join combines two sequences by key.
  • Sum, Average, Min, Max, Count, Any, All, First/FirstOrDefault.
  • ToList(), ToArray(), ToDictionary() materialize results.

LINQ is lazy: a query is not executed until you enumerate it (foreach, ToList(), Count()). Enumerating a query twice runs it twice, so call ToList() when you need a snapshot.

The code

Filtering, grouping, joining and aggregating with LINQ
var customers = new List<Customer>
{
    new(1, "Ada", "UK"),
    new(2, "Grace", "US"),
    new(3, "Linus", "FI"),
};

var orders = new List<Order>
{
    new(100, 1, 250.00m), new(101, 2, 99.50m), new(102, 1, 40.00m),
    new(103, 3, 610.25m), new(104, 2, 15.75m), new(105, 1, 120.00m),
};

// Filter, sort and project
var bigOrders = orders.Where(o => o.Amount >= 100).OrderByDescending(o => o.Amount).Select(o => o.Id).ToList();
Console.WriteLine("big orders: " + string.Join(", ", bigOrders));

// Aggregates
Console.WriteLine($"total {orders.Sum(o => o.Amount):N2}, average {orders.Average(o => o.Amount):N2}, max {orders.Max(o => o.Amount):N2}");

// Join orders to customers, then group by customer
var perCustomer = orders
    .Join(customers, o => o.CustomerId, c => c.Id, (o, c) => new { c.Name, c.Country, o.Amount })
    .GroupBy(x => x.Name)
    .Select(g => new { Name = g.Key, Orders = g.Count(), Total = g.Sum(x => x.Amount) })
    .OrderByDescending(x => x.Total);

foreach (var row in perCustomer)
    Console.WriteLine($"{row.Name,-6} {row.Orders} orders, total {row.Total,8:N2}");

// Any / All / FirstOrDefault
Console.WriteLine("any order over 600? " + orders.Any(o => o.Amount > 600));
Console.WriteLine("all orders positive? " + orders.All(o => o.Amount > 0));
Console.WriteLine("first order for customer 9: " + (orders.FirstOrDefault(o => o.CustomerId == 9)?.Id.ToString() ?? "none"));

// Dictionary and HashSet
var byId = customers.ToDictionary(c => c.Id);
if (byId.TryGetValue(3, out var found)) Console.WriteLine($"customer 3 is {found.Name}");

var countries = orders.Join(customers, o => o.CustomerId, c => c.Id, (_, c) => c.Country).ToHashSet();
Console.WriteLine("countries: " + string.Join(", ", countries.OrderBy(c => c)));

// LINQ is lazy: the query below sees items added after it was written
var evens = orders.Where(o => o.Id % 2 == 0);
orders.Add(new Order(106, 3, 5.00m));
Console.WriteLine("even-numbered orders: " + evens.Count());

record Customer(int Id, string Name, string Country);
record Order(int Id, int CustomerId, decimal Amount);

Output

big orders: 103, 100, 105
total 1,135.50, average 189.25, max 610.25
Linus  1 orders, total   610.25
Ada    3 orders, total   410.00
Grace  2 orders, total   115.25
any order over 600? True
all orders positive? True
first order for customer 9: none
customer 3 is Linus
countries: FI, UK, US
even-numbered orders: 4

Run this on your computer — see “Get Started” for setup.

The last two lines show LINQ's laziness: evens was defined before order 106 was added, but it is evaluated when Count() runs, so the new order is included.

Common mistakes

  • Enumerating a query repeatedly. Each foreach, Count() or First() re-runs it. Call ToList() when you need one stable result.
  • Using First() on possibly empty sequences. It throws; use FirstOrDefault() and handle null.
  • Modifying a list while a query over it is being enumerated. This throws or yields confusing results.
  • Doing database or heavy work in Where lambdas on in-memory collections. Filter early and keep lambdas cheap.
  • Using Count() > 0 when Any() expresses intent and can stop early.

Key points

  • List<T>, Dictionary<TKey,TValue> and HashSet<T> cover most needs; collection expressions ([1, 2, 3]) build them concisely.
  • LINQ queries are lazy chains of Where, Select, OrderBy, GroupBy and Join.
  • Materialize with ToList(), ToArray() or ToDictionary() when you need a snapshot.
  • Use TryGetValue and FirstOrDefault to handle missing data safely.

Further reading: LINQ overview.

Practice questions

What does it mean that LINQ queries are lazy?

Answer

A query defined with Where or Select isn't executed when it is declared; it runs each time you enumerate it (foreach, ToList, Count). It therefore sees the data as it is at that moment, and enumerating twice runs it twice.

Why prefer `FirstOrDefault()` to `First()` when a match might not exist?

Answer

First() throws InvalidOperationException if the sequence is empty or nothing matches. FirstOrDefault() returns null (or the type's default) so you can handle absence without an exception.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?