Articles › C#

LINQ in C#: The Complete Practical Guide

Master LINQ in C#: filtering, projection, sorting, grouping, joins, deferred execution, IEnumerable vs IQueryable and the performance traps to avoid.

LINQ (Language Integrated Query) is one of the features that makes C# a joy to write. It gives you a single, expressive syntax for querying in-memory collections, databases (through EF Core), XML and more. This guide covers the operators you'll use daily, the concept that confuses most developers — deferred execution — and the performance traps that matter in real projects.

Two syntaxes, one engine

LINQ comes in two flavors that compile to the same thing:

var numbers = new[] { 5, 12, 8, 21, 3, 17 };

// Method syntax (most common)
var big = numbers.Where(n => n > 10).OrderBy(n => n);

// Query syntax (SQL-like)
var big2 = from n in numbers
           where n > 10
           orderby n
           select n;

Method syntax is used far more in modern code; query syntax can be more readable for complex joins and let clauses. We'll use method syntax below.

Our sample data:

public record Employee(int Id, string Name, string Department, decimal Salary, DateTime HiredAt);

var employees = new List<Employee>
{
    new(1, "Sara",   "Engineering", 4200, new(2021, 3, 1)),
    new(2, "Reza",   "Engineering", 3800, new(2022, 7, 15)),
    new(3, "Mina",   "Design",      3100, new(2020, 1, 10)),
    new(4, "Ali",    "Engineering", 5100, new(2019, 9, 5)),
    new(5, "Neda",   "Marketing",   2900, new(2023, 2, 20)),
};

Filtering: Where

var seniorEngineers = employees
    .Where(e => e.Department == "Engineering" && e.HiredAt.Year < 2022);

Where also has an overload with the element index: .Where((e, i) => i % 2 == 0).

Projection: Select and SelectMany

Select transforms each element:

var names = employees.Select(e => e.Name.ToUpper());

var cards = employees.Select(e => new { e.Name, Years = DateTime.Now.Year - e.HiredAt.Year });

SelectMany flattens nested collections:

var orders = customers.SelectMany(c => c.Orders);           // all orders of all customers
var tags = posts.SelectMany(p => p.Tags).Distinct();          // every unique tag

Sorting

var sorted = employees
    .OrderBy(e => e.Department)
    .ThenByDescending(e => e.Salary);

Always use ThenBy for secondary keys. Calling OrderBy twice discards the first sort.

Element operators

Operator Returns When empty When multiple
First() first match throws returns first
FirstOrDefault() first match default returns first
Single() the only match throws throws
SingleOrDefault() the only match default throws
Last() / LastOrDefault() last match throws / default returns last

Use Single when there must be exactly one result (lookup by unique key) — it documents intent and catches data bugs early. Since .NET 6 you can specify a fallback: FirstOrDefault(defaultValue).

Quantifiers and aggregates

bool anyDesigners = employees.Any(e => e.Department == "Design");
bool allPaid      = employees.All(e => e.Salary > 0);
int engineers     = employees.Count(e => e.Department == "Engineering");
decimal payroll   = employees.Sum(e => e.Salary);
decimal average   = employees.Average(e => e.Salary);
var topEarner     = employees.MaxBy(e => e.Salary);   // .NET 6+

Tip: To check if a collection has items, use Any(), not Count() > 0. Any stops at the first element; Count may enumerate everything (or run a COUNT(*) on the whole table).

Grouping

var byDepartment = employees
    .GroupBy(e => e.Department)
    .Select(g => new
    {
        Department = g.Key,
        Headcount = g.Count(),
        AverageSalary = g.Average(e => e.Salary),
        Newest = g.MaxBy(e => e.HiredAt)!.Name
    })
    .OrderByDescending(x => x.Headcount);

foreach (var d in byDepartment)
    Console.WriteLine($"{d.Department}: {d.Headcount} people, avg {d.AverageSalary:N0}");

ToLookup is the eager cousin of GroupBy: it builds a dictionary-like structure you can query many times — lookup["Engineering"].

Joining

var departments = new[]
{
    new { Name = "Engineering", Floor = 3 },
    new { Name = "Design",      Floor = 2 },
};

// Inner join
var withFloor = employees.Join(
    departments,
    e => e.Department,
    d => d.Name,
    (e, d) => new { e.Name, d.Floor });

// Left join: keep employees without a matching department
var leftJoin =
    from e in employees
    join d in departments on e.Department equals d.Name into gj
    from d in gj.DefaultIfEmpty()
    select new { e.Name, Floor = d?.Floor };

When querying EF Core, you rarely need explicit joins: navigation properties (order.Customer.Name) generate the joins for you.

Set operations and partitioning

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

a.Union(b);      // 1 2 3 4 5
a.Intersect(b);  // 3 4
a.Except(b);     // 1 2
a.Concat(b);     // 1 2 3 4 3 4 5

employees.DistinctBy(e => e.Department);   // .NET 6+
employees.Skip(10).Take(10);                // paging
employees.Chunk(100);                       // batches of 100 (.NET 6+)

Deferred execution: the most important concept

Most LINQ operators don't run when you call them. They build a pipeline that runs only when you enumerate the result:

var list = new List<int> { 1, 2, 3 };
var query = list.Where(n => n > 1);   // nothing executed yet

list.Add(4);

foreach (var n in query)               // executed now: 2, 3, 4
    Console.WriteLine(n);

This has two consequences:

  1. Re-enumeration repeats the work. Enumerating the same query twice runs it twice — against a database, that's two round trips.
  2. The data may change between definition and execution.

When you need a snapshot, or will use the results more than once, materialize them with ToList(), ToArray() or ToDictionary().

Operators like Count(), First(), Sum() and ToList() execute immediately.

IEnumerable vs IQueryable

This distinction is crucial with EF Core:

  • IEnumerable<T> runs operators in memory, as C# delegates.
  • IQueryable<T> builds an expression tree that a provider (EF Core) translates to SQL.
// ✅ Filter runs in SQL Server: WHERE Salary > 4000
var rich = db.Employees.Where(e => e.Salary > 4000).ToList();

// ❌ Loads the whole table, then filters in memory
IEnumerable<Employee> all = db.Employees;
var rich2 = all.Where(e => e.Salary > 4000).ToList();

Keep queries as IQueryable until the last moment, and only call ToList() once the query is fully shaped.

Performance traps

  1. Calling ToList() too early — everything after it runs in memory.
  2. Multiple enumeration of an expensive IEnumerable — materialize once.
  3. Count() > 0 instead of Any().
  4. N+1 queries — looping over results and querying related data inside the loop. Use Include or projection.
  5. Untranslatable expressions — calling your own C# methods inside an EF Core Where throws at runtime because SQL can't run them.
  6. LINQ in very hot paths — for tight loops over arrays in performance-critical code, a plain for loop can be faster. Measure before optimizing.

Summary

LINQ gives you a consistent vocabulary — Where, Select, OrderBy, GroupBy, Join, Any — across collections and databases. Understand deferred execution, keep EF Core queries as IQueryable until they're complete, and materialize deliberately. With those habits LINQ code is both elegant and efficient.

FAQ

Is LINQ slower than loops?

For in-memory collections there's a small overhead from delegates and enumerators. In most business code it's negligible compared to I/O; optimize only proven hot paths.

When should I use query syntax?

When a query has several joins, let variables or groupings, query syntax can read more naturally. Mixing both styles is perfectly fine.

What does "could not be translated" mean in EF Core?

Part of your LINQ expression has no SQL equivalent (often a custom C# method). Rewrite it with translatable operations, or move that part after AsEnumerable() once the data set is small.