What LINQ is

LINQ (Language Integrated Query) is the part of C# and .NET for querying data: filtering, transforming, sorting, grouping and summarizing it. It arrived with C# 3.0 in 2007, together with lambda expressions and extension methods, which it is built on. The same query style works on arrays and lists in memory, on XML, and — through libraries such as Entity Framework Core — on databases.

Without LINQ, "the total resolution time of all network tickets" means a loop, an if and a running total. With LINQ you describe the result, much like a SQL query:

Ticket[] tickets =
[
    new("INC-10231", "network", 2, 95),
    new("INC-10232", "billing", 3, 40),
    new("INC-10233", "network", 1, 210),
    new("INC-10234", "portal", 2, 55),
];

// Without LINQ
int total = 0;
foreach (Ticket t in tickets)
{
    if (t.Category == "network")
    {
        total += t.Minutes;
    }
}
Console.WriteLine(total);

// With LINQ
Console.WriteLine(tickets.Where(t => t.Category == "network").Sum(t => t.Minutes));

record Ticket(string Id, string Category, int Priority, int Minutes);
305
305

The LINQ methods live in the namespace System.Linq, which every new project and file-based app imports automatically. They are extension methods on IEnumerable<T>, the interface that every array, list, set and dictionary implements (see Loops), so they are available on all of them. Each method takes a lambda — a Func delegate, see Delegates, Lambdas and Events — that is called for every element.

Method syntax: chaining calls

Most LINQ methods return a new sequence, so calls can be chained into a pipeline in which each step feeds the next. Writing queries as chained method calls with lambdas is called method syntax. It is the form you will see most in modern C# code. One step per line keeps it readable:

Ticket[] tickets =
[
    new("INC-10231", "network", 2, 95),
    new("INC-10232", "billing", 3, 40),
    new("INC-10233", "network", 1, 210),
    new("INC-10234", "portal", 2, 55),
    new("INC-10235", "billing", 2, 30),
    new("INC-10236", "network", 3, 25),
];

var urgentSlow = tickets
    .Where(t => t.Priority <= 2)
    .Where(t => t.Minutes > 50)
    .OrderByDescending(t => t.Minutes)
    .Select(t => $"{t.Id} ({t.Category}, {t.Minutes} min)")
    .ToList();

foreach (string line in urgentSlow)
{
    Console.WriteLine(line);
}

record Ticket(string Id, string Category, int Priority, int Minutes);
INC-10233 (network, 210 min)
INC-10231 (network, 95 min)
INC-10234 (portal, 55 min)
A LINQ pipeline. The source array of six tickets flows into Where, which keeps priority 1 and 2, then into a second Where that keeps tickets over 50 minutes, then OrderByDescending by minutes, then Select, which turns each ticket into a string, and finally ToList, which runs the whole pipeline and stores three strings in a list. source 6 tickets Where priority ≤ 2 Where > 50 min OrderBy… minutes Select string ToList runs deferred: nothing runs until ToList (or foreach) asks for elements
Each step wraps the previous one; the final ToList() pulls the elements through the whole pipeline.

Filtering and projecting

Where keeps the elements for which the lambda returns true. Its lambda can also receive the element's index as a second parameter. OfType<T>() filters by type and Distinct() drops duplicates.

Select: projection

Select transforms every element into something else — one output per input. This is called projection, a word borrowed from databases, where selecting columns "projects" a table onto fewer columns. The result can be a single property, a calculated value, a tuple, a record, or an anonymous type created with new { … }, which is handy for a shape you need only inside one method.

Reading[] readings =
[
    new("GW-07", 12.5m),
    new("GW-08", 48.0m),
    new("GW-09", 7.25m),
];
const decimal Tariff = 0.32m;

IEnumerable<string> ids = readings.Select(r => r.MeterId);
Console.WriteLine(string.Join(", ", ids));

var costs = readings.Select(r => new { r.MeterId, Cost = r.Kwh * Tariff });
foreach (var c in costs)
{
    Console.WriteLine($"{c.MeterId}: {c.Cost:F2}");
}

var numbered = readings.Select((r, index) => $"{index + 1}. {r.MeterId}");
Console.WriteLine(string.Join(" | ", numbered));

record Reading(string MeterId, decimal Kwh);
GW-07, GW-08, GW-09
GW-07: 4.00
GW-08: 15.36
GW-09: 2.32
1. GW-07 | 2. GW-08 | 3. GW-09

SelectMany projects every element to a sequence and flattens the results into one sequence — for example all meters of all customers:

Customer[] customers =
[
    new("Amara Okafor", ["GW-07", "GW-08"]),
    new("Mei Chen", ["GW-11"]),
];

var allMeters = customers.SelectMany(c => c.Meters);
Console.WriteLine(string.Join(", ", allMeters));

var pairs = customers.SelectMany(c => c.Meters, (c, meter) => $"{meter} → {c.Name}");
Console.WriteLine(string.Join("; ", pairs));

record Customer(string Name, string[] Meters);
GW-07, GW-08, GW-11
GW-07 → Amara Okafor; GW-08 → Amara Okafor; GW-11 → Mei Chen

Sorting

OrderBy and OrderByDescending sort by a key that the lambda returns. For a second key, chain ThenBy or ThenByDescending — calling OrderBy twice would sort the whole sequence again and throw the first order away. LINQ's sort is stable: elements with equal keys keep their original order. For simple values, Order() and OrderDescending() (.NET 7 and later) sort by the value itself.

Ticket[] tickets =
[
    new("INC-10231", "network", 2, 95),
    new("INC-10232", "billing", 3, 40),
    new("INC-10233", "network", 1, 210),
    new("INC-10234", "portal", 2, 55),
    new("INC-10235", "billing", 2, 30),
];

var sorted = tickets
    .OrderBy(t => t.Priority)
    .ThenByDescending(t => t.Minutes);

foreach (Ticket t in sorted)
{
    Console.WriteLine($"P{t.Priority} {t.Minutes,4} min  {t.Id}");
}

int[] temperatures = [17, 14, 21, 19];
Console.WriteLine(string.Join(" ", temperatures.Order()));

record Ticket(string Id, string Category, int Priority, int Minutes);
P1  210 min  INC-10233
P2   95 min  INC-10231
P2   55 min  INC-10234
P2   30 min  INC-10235
P3   40 min  INC-10232
14 17 19 21

Strings are sorted with the current culture's rules by default. For identifiers, codes and anything that must sort the same on every computer, pass a comparer: OrderBy(t => t.Id, StringComparer.Ordinal).

Aggregating and single elements

Some methods reduce a whole sequence to one value. They run the query immediately.

Ticket[] tickets =
[
    new("INC-10231", "network", 2, 95),
    new("INC-10232", "billing", 3, 40),
    new("INC-10233", "network", 1, 210),
    new("INC-10234", "portal", 2, 55),
];

Console.WriteLine($"Count:   {tickets.Length}, urgent: {tickets.Count(t => t.Priority == 1)}");
Console.WriteLine($"Total:   {tickets.Sum(t => t.Minutes)} min");
Console.WriteLine($"Average: {tickets.Average(t => t.Minutes):F1} min");
Console.WriteLine($"Longest: {tickets.Max(t => t.Minutes)} min, by {tickets.MaxBy(t => t.Minutes)!.Id}");
Console.WriteLine($"Any P3?  {tickets.Any(t => t.Priority == 3)}");
Console.WriteLine($"All under 4 hours? {tickets.All(t => t.Minutes < 240)}");
Console.WriteLine($"First network ticket: {tickets.First(t => t.Category == "network").Id}");

string ids = tickets.Select(t => t.Id[4..]).Aggregate((acc, id) => acc + "+" + id);
Console.WriteLine(ids);

record Ticket(string Id, string Category, int Priority, int Minutes);
Count:   4, urgent: 1
Total:   400 min
Average: 100.0 min
Longest: 210 min, by INC-10233
Any P3?  True
All under 4 hours? True
First network ticket: INC-10231
10231+10232+10233+10234

Max returns the largest key; MaxBy and MinBy (.NET 6 and later) return the element with the largest or smallest key. Aggregate is the general form, like reduce; reach for it only when no specific method fits. For an array, Length is cheaper than Count(); for a List<T>, use Count.

Empty sequences

Methods that must return an element cannot do so for an empty sequence, and throw an InvalidOperationException: First, Last, Single, and for value types such as int also Min, Max and Average (for reference and nullable types Min and Max return null). Sum and Count return 0.

int[] outagesToday = [];

Console.WriteLine(outagesToday.Sum());
Console.WriteLine(outagesToday.FirstOrDefault());
Console.WriteLine(outagesToday.FirstOrDefault(-1));
Console.WriteLine(outagesToday.Average());
0
0
-1
Unhandled exception. System.InvalidOperationException: Sequence contains no elements
  • FirstOrDefault(), LastOrDefault() and SingleOrDefault() return default — 0 or null — when nothing matches; since .NET 6 you can pass your own fallback value, as in FirstOrDefault(-1).
  • Single() also throws when there is more than one element: use it when exactly one match is the only valid outcome, such as looking up a ticket by its unique id.
  • Check Any() before Average(), or average a nullable projection, if the sequence may be empty.

Grouping

Grouping collects the elements that share a key into groups. GroupBy returns a sequence of groups; each group is itself a sequence of elements (IGrouping<TKey, TElement>) with a Key property. You can loop over a group or aggregate it, much like SQL's GROUP BY. The groups come out in the order in which each key first appears.

Ticket[] tickets =
[
    new("INC-10231", "network", 2, 95),
    new("INC-10232", "billing", 3, 40),
    new("INC-10233", "network", 1, 210),
    new("INC-10234", "portal", 2, 55),
    new("INC-10235", "billing", 2, 30),
    new("INC-10236", "network", 3, 25),
];

var perCategory = tickets
    .GroupBy(t => t.Category)
    .Select(g => new { Category = g.Key, Count = g.Count(), Minutes = g.Sum(t => t.Minutes) })
    .OrderByDescending(x => x.Minutes);

foreach (var row in perCategory)
{
    Console.WriteLine($"{row.Category,-8} tickets: {row.Count}  minutes: {row.Minutes,3}");
}

foreach (var group in tickets.GroupBy(t => t.Priority).OrderBy(g => g.Key))
{
    Console.WriteLine($"P{group.Key}: {string.Join(", ", group.Select(t => t.Id))}");
}

record Ticket(string Id, string Category, int Priority, int Minutes);
network  tickets: 3  minutes: 330
billing  tickets: 2  minutes:  70
portal   tickets: 1  minutes:  55
P1: INC-10233
P2: INC-10231, INC-10234, INC-10235
P3: INC-10232, INC-10236

Two shortcuts help with grouped data. CountBy (.NET 9 and later) counts per key without building the groups, and ToDictionary turns a sequence into a dictionary for fast lookups by key (it throws if a key occurs twice):

string[] categories = ["network", "billing", "network", "portal", "billing", "network"];

foreach (KeyValuePair<string, int> pair in categories.CountBy(c => c).OrderBy(p => p.Key))
{
    Console.WriteLine($"{pair.Key}: {pair.Value}");
}

Dictionary<string, decimal> tariffByCode = new[]
{
    (Code: "standard", Price: 0.32m),
    (Code: "night", Price: 0.24m),
}.ToDictionary(t => t.Code, t => t.Price);

Console.WriteLine(tariffByCode["night"]);
billing: 2
network: 3
portal: 1
0.24

Query syntax

C# also has keywords for LINQ that read like SQL. This query syntax starts with from and ends with select or group; in between come where, orderby, let (a local name inside the query) and join. The compiler translates it into the same method calls, so both forms run identically.

Ticket[] tickets =
[
    new("INC-10231", "C-1042", 2, 95),
    new("INC-10232", "C-2001", 3, 40),
    new("INC-10233", "C-1042", 1, 210),
];
Customer[] customers =
[
    new("C-1042", "Amara Okafor"),
    new("C-2001", "Mei Chen"),
];

var query =
    from t in tickets
    where t.Priority <= 2
    let hours = t.Minutes / 60.0
    orderby hours descending
    select $"{t.Id}: {hours:F1} h";

foreach (string line in query) Console.WriteLine(line);

var withNames =
    from t in tickets
    join c in customers on t.CustomerId equals c.Id
    select new { t.Id, c.Name };

foreach (var row in withNames) Console.WriteLine($"{row.Id} → {row.Name}");

// The first query in method syntax:
var same = tickets
    .Where(t => t.Priority <= 2)
    .Select(t => new { t, hours = t.Minutes / 60.0 })
    .OrderByDescending(x => x.hours)
    .Select(x => $"{x.t.Id}: {x.hours:F1} h");
Console.WriteLine(same.SequenceEqual(query));

record Ticket(string Id, string CustomerId, int Priority, int Minutes);
record Customer(string Id, string Name);
INC-10233: 3.5 h
INC-10231: 1.6 h
INC-10231 → Amara Okafor
INC-10232 → Mei Chen
INC-10233 → Amara Okafor
True

Which to use is mostly taste. Method syntax is more common and can do everything; many methods (Count, Sum, First, Take, Distinct) have no keyword, so a query often ends in parentheses and a method call anyway. Query syntax is easier to read for joins and for queries that need let.

Deferred execution

A LINQ query is a recipe, not a result. Methods such as Where, Select and OrderBy return an object that describes the query; nothing is computed until something enumerates it — a foreach, or a method such as ToList(), ToArray(), Count() or Sum(). This is deferred execution. The elements then stream through the pipeline one at a time:

int[] readings = [12, 48, 7, 63];

var high = readings.Where(r =>
{
    Console.WriteLine($"  checking {r}");
    return r > 40;
});
Console.WriteLine("Query defined, nothing checked yet");

foreach (int r in high)
{
    Console.WriteLine($"High reading: {r}");
}
Query defined, nothing checked yet
  checking 12
  checking 48
High reading: 48
  checking 7
  checking 63
High reading: 63

Two consequences follow. First, the query sees the data as it is when it runs, not when it was written:

var openTickets = new List<string> { "INC-10231", "INC-10232" };

var query = openTickets.Where(id => id.StartsWith("INC-"));
List<string> snapshot = query.ToList();      // runs now and stores the result

openTickets.Add("INC-10233");

Console.WriteLine($"query:    {string.Join(", ", query)}");
Console.WriteLine($"snapshot: {string.Join(", ", snapshot)}");
query:    INC-10231, INC-10232, INC-10233
snapshot: INC-10231, INC-10232

Second, every enumeration runs the query again. Calling Count() and then looping does the work twice — harmless for a small array, expensive when each step reads a file or queries a database:

int checks = 0;
int[] readings = [12, 48, 7, 63];
var high = readings.Where(r => { checks++; return r > 40; });

Console.WriteLine($"{high.Count()} high readings");
foreach (int r in high) Console.WriteLine(r);
Console.WriteLine($"Filter ran {checks} times for {readings.Length} readings");

checks = 0;
List<int> highList = high.ToList();           // run once, keep the result
Console.WriteLine($"{highList.Count} high readings");
foreach (int r in highList) Console.WriteLine(r);
Console.WriteLine($"Filter ran {checks} times");
2 high readings
48
63
Filter ran 8 times for 4 readings
2 high readings
48
63
Filter ran 4 times

LINQ and databases

Everything above is LINQ to Objects: the lambdas run in your program, on data in memory. Entity Framework Core (EF Core), the .NET object-relational mapper, exposes tables as IQueryable<T>. On that type, the LINQ methods do not run your lambdas: they collect the query as an expression tree, and EF Core translates it into SQL that the database executes. Values from your variables are sent as parameters, not pasted into the SQL text, so a LINQ query is not open to SQL injection.

// EF Core (not run here: needs a database and the EF Core package)
List<Ticket> open = await db.Tickets
    .Where(t => t.Category == category && t.Priority <= 2)
    .OrderBy(t => t.Id)
    .Take(20)
    .ToListAsync();

Roughly, the database receives a SELECT … WHERE … ORDER BY … with a limit of 20 and category as a parameter; the exact SQL depends on the database provider. Deferred execution matters even more here: the query is sent when ToListAsync() runs, and enumerating the same IQueryable twice sends it twice. Not every C# method can be translated to SQL; when EF Core cannot translate a filter, sort or join, it throws an InvalidOperationException instead of silently loading the whole table; only the final Select may run partly in your program. The await is explained in Asynchronous Programming with async and await.