C# LINQ

📖 10 min read

What is LINQ

LINQ (Language Integrated Query) brings query capabilities directly into C#. It provides a consistent way to query any data source: collections, databases, XML, and more.

// Query syntax - declarative, SQL-like
var adults = from p in people
             where p.Age >= 18
             orderby p.Name
             select p;

// Method syntax - functional, chainable
var adults = people
    .Where(p => p.Age >= 18)
    .OrderBy(p => p.Name);

// Both compile to the same thing

Deferred vs Immediate Execution

LINQ queries don't execute when you write them—they execute when you enumerate them. This deferred execution changes source data, re-execution behavior, and performance characteristics.

Understanding when queries execute is crucial for performance and correctness.

Deferred Execution

Deferred Execution

  • Where, Select, OrderBy
  • Executes when enumerated
  • Re-executes on each enumeration
  • Sees data changes

Immediate Execution

  • ToList, ToArray, Count, First
  • Executes right away
  • Materializes results
  • Snapshot of data

Most LINQ operations don’t execute immediately. The query is built and executed only when results are enumerated.

var numbers = new List<int> { 1, 2, 3, 4, 5 };

// Query is defined but not executed
var query = numbers.Where(n => n > 2);

// Add more data
numbers.Add(6);

// Query executes NOW, includes 6
foreach (var n in query)
{
    Console.WriteLine(n);  // 3, 4, 5, 6
}

// Each enumeration re-executes
var count1 = query.Count();  // Executes query
var count2 = query.Count();  // Executes again

Immediate Execution

Methods that return a single value or materialize results execute immediately.

// Immediate - single value
int count = numbers.Count();
int first = numbers.First();
bool any = numbers.Any(n => n > 10);
int sum = numbers.Sum();

// Immediate - materialization
List<int> list = numbers.Where(n => n > 2).ToList();
int[] array = numbers.Where(n => n > 2).ToArray();
Dictionary<int, string> dict = people.ToDictionary(p => p.Id, p => p.Name);

// After ToList(), changes to source don't affect result
numbers.Add(100);  // list doesn't change

Filtering

Where

Filter elements based on a predicate.

var adults = people.Where(p => p.Age >= 18);

var activeAdmins = users
    .Where(u => u.IsActive)
    .Where(u => u.Role == "Admin");

// With index
var evenIndexed = items.Where((item, index) => index % 2 == 0);

OfType

Filter by type.

object[] mixed = { 1, "hello", 2, "world", 3 };

var strings = mixed.OfType<string>();  // "hello", "world"
var ints = mixed.OfType<int>();        // 1, 2, 3

Distinct and DistinctBy

Remove duplicates.

var unique = numbers.Distinct();

// DistinctBy specific property (C# 10 / .NET 6)
var uniqueByName = people.DistinctBy(p => p.Name);

// Custom equality comparer
var uniqueEmails = people.Distinct(new EmailComparer());

Projection

Select

Transform each element.

var names = people.Select(p => p.Name);

var dtos = orders.Select(o => new OrderDto
{
    Id = o.Id,
    CustomerName = o.Customer.Name,
    Total = o.Items.Sum(i => i.Price)
});

// With index
var indexed = items.Select((item, i) => new { Index = i, Item = item });

// Anonymous types
var summaries = people.Select(p => new { p.Name, p.Age });

SelectMany

Flatten nested collections.

var allOrders = customers.SelectMany(c => c.Orders);

// With result selector
var orderDetails = customers.SelectMany(
    c => c.Orders,
    (customer, order) => new { customer.Name, order.Total });

// Flatten then filter
var expensiveItems = orders
    .SelectMany(o => o.Items)
    .Where(i => i.Price > 100);

// Query syntax
var items = from order in orders
            from item in order.Items
            where item.Price > 100
            select item;

Ordering

OrderBy and OrderByDescending

var byName = people.OrderBy(p => p.Name);
var byAgeDesc = people.OrderByDescending(p => p.Age);

// Multiple sort criteria
var sorted = people
    .OrderBy(p => p.LastName)
    .ThenBy(p => p.FirstName)
    .ThenByDescending(p => p.Age);

// Query syntax
var sorted = from p in people
             orderby p.LastName, p.FirstName, p.Age descending
             select p;

Reverse

var reversed = numbers.Reverse();

Grouping

GroupBy

Group elements by a key.

var byDepartment = employees.GroupBy(e => e.Department);

foreach (var group in byDepartment)
{
    Console.WriteLine($"{group.Key}: {group.Count()} employees");
    foreach (var employee in group)
    {
        Console.WriteLine($"  - {employee.Name}");
    }
}

// With element selector
var namesByDept = employees.GroupBy(
    e => e.Department,
    e => e.Name);

// With result selector
var deptSummaries = employees.GroupBy(
    e => e.Department,
    (dept, emps) => new
    {
        Department = dept,
        Count = emps.Count(),
        AverageSalary = emps.Average(e => e.Salary)
    });

// Query syntax
var byDepartment = from e in employees
                   group e by e.Department into g
                   select new { Department = g.Key, Count = g.Count() };

ToLookup

Like GroupBy but executes immediately and supports repeated lookups.

var lookup = employees.ToLookup(e => e.Department);

var engineering = lookup["Engineering"];
var hr = lookup["HR"];
var missing = lookup["NonExistent"];  // Empty, not null

Joining

Join

Inner join two sequences.

var joined = orders.Join(
    customers,
    order => order.CustomerId,
    customer => customer.Id,
    (order, customer) => new
    {
        OrderId = order.Id,
        CustomerName = customer.Name
    });

// Query syntax
var joined = from o in orders
             join c in customers on o.CustomerId equals c.Id
             select new { o.Id, c.Name };

GroupJoin

Left outer join with grouping.

var customerOrders = customers.GroupJoin(
    orders,
    c => c.Id,
    o => o.CustomerId,
    (customer, orderGroup) => new
    {
        Customer = customer.Name,
        OrderCount = orderGroup.Count(),
        TotalSpent = orderGroup.Sum(o => o.Total)
    });

// Query syntax
var customerOrders = from c in customers
                     join o in orders on c.Id equals o.CustomerId into orderGroup
                     select new { c.Name, Orders = orderGroup };

Zip

Combine elements by position.

var names = new[] { "Alice", "Bob", "Charlie" };
var ages = new[] { 25, 30, 35 };

var people = names.Zip(ages, (name, age) => new { name, age });
// { Alice, 25 }, { Bob, 30 }, { Charlie, 35 }

// Tuple result (C# 10 / .NET 6)
var pairs = names.Zip(ages);
// ("Alice", 25), ("Bob", 30), ("Charlie", 35)

// Three sequences (C# 10 / .NET 6)
var scores = new[] { 100, 95, 88 };
var combined = names.Zip(ages, scores);
// ("Alice", 25, 100), ...

Aggregation

Count, Sum, Average, Min, Max

int count = numbers.Count();
int countFiltered = numbers.Count(n => n > 5);
long longCount = numbers.LongCount();

int sum = numbers.Sum();
decimal totalPrice = orders.Sum(o => o.Price);

double average = numbers.Average();
double avgAge = people.Average(p => p.Age);

int min = numbers.Min();
int maxAge = people.Max(p => p.Age);

// MinBy/MaxBy (C# 10 / .NET 6)
var youngest = people.MinBy(p => p.Age);
var oldest = people.MaxBy(p => p.Age);

Aggregate

Custom aggregation.

// Product of all numbers
int product = numbers.Aggregate((a, b) => a * b);

// With seed
int sumPlusTen = numbers.Aggregate(10, (acc, n) => acc + n);

// With result selector
string sentence = words.Aggregate(
    new StringBuilder(),
    (sb, word) => sb.Append(word).Append(" "),
    sb => sb.ToString().Trim());

// Running total
var runningTotal = numbers.Aggregate(
    new List<int>(),
    (list, n) =>
    {
        list.Add(list.LastOrDefault() + n);
        return list;
    });

Element Operations

First, FirstOrDefault

var first = numbers.First();                    // Throws if empty
var firstEven = numbers.First(n => n % 2 == 0); // First matching

var firstOrNull = numbers.FirstOrDefault();     // 0 if empty
var firstOrDefault = numbers.FirstOrDefault(n => n > 100); // 0 if none

// With explicit default (C# 10 / .NET 6)
var firstOrMinus1 = numbers.FirstOrDefault(n => n > 100, -1);

Last, LastOrDefault

var last = numbers.Last();
var lastOrDefault = numbers.LastOrDefault();

Single, SingleOrDefault

Returns exactly one element; throws if zero or multiple.

var onlyAdmin = users.Single(u => u.Role == "SuperAdmin");
var maybeAdmin = users.SingleOrDefault(u => u.Role == "SuperAdmin");

ElementAt, ElementAtOrDefault

Access by index.

var third = numbers.ElementAt(2);        // Throws if out of range
var thirdOrDefault = numbers.ElementAtOrDefault(2);

// With Index (C# 10 / .NET 6)
var last = numbers.ElementAt(^1);

Quantifiers

Any, All, Contains

bool hasAny = numbers.Any();
bool hasEven = numbers.Any(n => n % 2 == 0);

bool allPositive = numbers.All(n => n > 0);

bool containsFive = numbers.Contains(5);
bool containsPerson = people.Contains(person, new PersonComparer());

Set Operations

var set1 = new[] { 1, 2, 3, 4 };
var set2 = new[] { 3, 4, 5, 6 };

var union = set1.Union(set2);         // 1, 2, 3, 4, 5, 6
var intersect = set1.Intersect(set2); // 3, 4
var except = set1.Except(set2);       // 1, 2

// UnionBy, IntersectBy, ExceptBy (C# 10 / .NET 6)
var allPeopleByName = people1.UnionBy(people2, p => p.Name);

Partitioning

Take and Skip

var firstFive = numbers.Take(5);
var skipFive = numbers.Skip(5);
var page = numbers.Skip(20).Take(10);  // Pagination

// TakeLast and SkipLast (C# 10 / .NET 6)
var lastThree = numbers.TakeLast(3);
var allButLastThree = numbers.SkipLast(3);

// Range-based (C# 10 / .NET 6)
var slice = numbers.Take(3..7);
var fromEnd = numbers.Take(^5..);

TakeWhile and SkipWhile

var ascending = new[] { 1, 2, 3, 5, 4, 2 };

var takeWhileAsc = ascending.TakeWhile(n => n < 5);  // 1, 2, 3
var skipWhileAsc = ascending.SkipWhile(n => n < 5);  // 5, 4, 2

Chunk (C# 10 / .NET 6)

Split into fixed-size chunks.

var numbers = Enumerable.Range(1, 10);
var chunks = numbers.Chunk(3);
// [1, 2, 3], [4, 5, 6], [7, 8, 9], [10]

Generation

// Range
var oneToTen = Enumerable.Range(1, 10);

// Repeat
var fiveZeros = Enumerable.Repeat(0, 5);

// Empty
var empty = Enumerable.Empty<int>();

Conversion

// To collections
var list = query.ToList();
var array = query.ToArray();
var hashSet = query.ToHashSet();

// To dictionary
var dict = people.ToDictionary(p => p.Id);
var dictWithValue = people.ToDictionary(p => p.Id, p => p.Name);

// To lookup (allows duplicate keys)
var lookup = people.ToLookup(p => p.Department);

// Cast and OfType
IEnumerable nonGeneric = GetItems();
var typed = nonGeneric.Cast<string>();     // Throws if wrong type
var filtered = nonGeneric.OfType<string>(); // Skips wrong types

// AsEnumerable (forces LINQ to Objects)
var result = dbContext.People
    .Where(p => p.Age > 18)        // Runs in database
    .AsEnumerable()
    .Where(p => ComplexLogic(p));  // Runs in memory

Common Patterns

Null-Safe Enumeration

// Coalesce null to empty
var items = GetItems() ?? Enumerable.Empty<Item>();

foreach (var item in items)
{
    Process(item);
}

// Or with null-conditional
foreach (var item in GetItems() ?? Array.Empty<Item>())
{
    Process(item);
}

Batched Processing

public static IEnumerable<IEnumerable<T>> Batch<T>(
    this IEnumerable<T> source, int size)
{
    var batch = new List<T>(size);
    foreach (var item in source)
    {
        batch.Add(item);
        if (batch.Count == size)
        {
            yield return batch;
            batch = new List<T>(size);
        }
    }
    if (batch.Count > 0)
        yield return batch;
}

// Or use Chunk in .NET 6+
foreach (var batch in items.Chunk(100))
{
    await ProcessBatchAsync(batch);
}

Index with ForEach

// LINQ doesn't have ForEach; use foreach or extension
foreach (var (item, index) in items.Select((x, i) => (x, i)))
{
    Console.WriteLine($"{index}: {item}");
}

// Or create extension
public static void ForEach<T>(this IEnumerable<T> source, Action<T, int> action)
{
    int index = 0;
    foreach (var item in source)
        action(item, index++);
}

Conditional Query Building

IQueryable<Product> query = dbContext.Products;

if (!string.IsNullOrEmpty(searchTerm))
    query = query.Where(p => p.Name.Contains(searchTerm));

if (categoryId.HasValue)
    query = query.Where(p => p.CategoryId == categoryId);

if (minPrice.HasValue)
    query = query.Where(p => p.Price >= minPrice);

var results = await query.OrderBy(p => p.Name).ToListAsync();

Performance Tips

Avoid Multiple Enumeration

Multiple Enumeration Performance Trap

Each enumeration of a deferred query re-executes the entire query. If you need the results more than once, materialize with ToList() or ToArray().

// BAD - enumerates twice
var filtered = items.Where(i => i.IsActive);
if (filtered.Any())  // First enumeration
{
    foreach (var item in filtered)  // Second enumeration
    {
    }
}

// GOOD - materialize once
var filtered = items.Where(i => i.IsActive).ToList();
if (filtered.Count > 0)
{
    foreach (var item in filtered)
    {
    }
}

Use Count Wisely

// BAD - counts all elements
if (items.Count() > 0)

// GOOD - stops at first element
if (items.Any())

// BAD - counts all
if (items.Count() > 5)

// GOOD - stops after 6
if (items.Skip(5).Any())
// Or (C# 10 / .NET 6)
if (items.TryGetNonEnumeratedCount(out var count) ? count > 5 : items.Skip(5).Any())

Prefer Method Syntax for Complex Queries

Query syntax shines for simple queries with joins. Method syntax is more flexible for complex transformations.

Version History

Feature Version Significance
LINQ C# 3.0 Core LINQ operators
PLINQ .NET 4.0 Parallel LINQ
Index/Range C# 8.0 Slicing support
Chunk, *By methods .NET 6 Modern convenience methods
DistinctBy, MinBy, MaxBy .NET 6 Property-based operations

Key Takeaways

Understand deferred execution: Queries don’t run until enumerated. Materialize with ToList() when you need to reuse results.

Use the right method: Any() vs Count() > 0, FirstOrDefault() vs First(), etc. Choose based on intent and performance.

Method syntax for flexibility: Query syntax is readable for simple queries, but method syntax handles complex scenarios better.

Avoid multiple enumeration: Materializing results prevents re-executing expensive queries.

Leverage new .NET 6+ methods: Chunk(), DistinctBy(), MinBy(), MaxBy() solve common patterns elegantly.

Found this guide helpful? Share it with your team:

Share on LinkedIn