LINQ and lambda expressions in C# for beginners
LINQ lets you filter, sort, project and group C# collections without writing manual loops, and lambda expressions are the small inline functions you pass into it to describe what you want.
Filtering with Where
Where is a LINQ method, and the filter you pass into it is the lambda expression. Here it filters a list of player surnames down to the ones longer than five characters.
var playersSurnames = new List<string>
{
"Kane",
"Messi",
"Mbappé",
"Yamal",
"Ronaldo"
};
var playersWithLongNames = playersSurnames.Where(x => x.Length > 5);
foreach (var player in playersWithLongNames)
{
Console.WriteLine($"Player with long name = {player}");
}x.Length > 5 is the lambda expression here, a small inline function that gets applied to every item in the list. LINQ also works with other types, including classes and records.
// Player.cs
public record Player(string FirstName, string Surname, DateOnly DateOfBirth);This example uses a record rather than a class. Records work well for immutable types like this, and they compare by their property values rather than by reference.
var playersList = new List<Player>
{
new("Harry", "Kane", new DateOnly(1993, 7, 28)),
new("Lionel", "Messi", new DateOnly(1987, 6, 24)),
new("Kylian", "Mbappé", new DateOnly(1998, 12, 20)),
new("Lamine", "Yamal", new DateOnly(2007, 7, 13)),
new("Cristiano", "Ronaldo", new DateOnly(1985, 2, 5)),
};
var playersBornIn21stCent = playersList.Where(x => x.DateOfBirth.Year >= 2000);
foreach (var player in playersBornIn21stCent)
{
Console.WriteLine($"{player.FirstName} {player.Surname} was born in 21st century");
}This filters playersList down to the players born in the 21st century, comparing the year of the DateOfBirth property to see if it's greater than or equal to 2000.
Projecting data with Select
Select transforms each item in a collection into another type. This is especially useful with Entity Framework Core, where projecting an entity into a DTO stops you exposing extra or sensitive properties in an API response.
// PlayerName.cs
public record PlayerName(string FirstName, string Surname);var playerNames = playersList.Select(x => new PlayerName(x.FirstName, x.Surname));This creates a new PlayerName instance for each player in the list, containing only the first name and surname, and not the date of birth. The result is converted to an IEnumerable<PlayerName>.
Chaining LINQ methods together
LINQ methods can be chained together. Each one returns a new sequence that the next method can work on. This is particularly useful with Entity Framework Core, since the resulting query only selects the properties you actually need.
// Chaining methods
var playerNamesBornIn21stCent = playersList
.Where(x => x.DateOfBirth.Year >= 2000)
.Select(x => new PlayerName(x.FirstName, x.Surname));
foreach (var playerName in playerNamesBornIn21stCent)
{
Console.WriteLine($"{playerName.FirstName} {playerName.Surname} was born in 21st century");
}Here, playersList is filtered down to the players born in the 21st century, and only the first name and surname are selected for each one. Filtering the list down before selecting the properties you need is good for performance, since fewer records and fewer columns are returned from the query.
First, Single and their OrDefault methods
First
var messiRecord = playerNames.First(x => x.Surname == "Messi");
Console.WriteLine($"{messiRecord.FirstName} {messiRecord.Surname} is the messiRecord");
var salahRecord = playerNames.FirstOrDefault(x => x.Surname == "Salah");
if (salahRecord == null)
{
Console.WriteLine("Salah doesn't exist");
}Use
Firstwhen you know the record is definitely thereUse
FirstOrDefaultwhen the record could be null
Calling First on a record that does not exist throws:
System.InvalidOperationException: 'Sequence contains no matching element'If more than one record matches the query, First gets the first record based on the ordering you apply. Here, the list is ordered by date of birth before finding the first player born in the 1990s, which returns Harry Kane.
var firstPlayerBornIn90s = playersList.OrderBy(x => x.DateOfBirth)
.First(x => x.DateOfBirth.Year >= 1990
&& x.DateOfBirth.Year <= 1999);
Console.WriteLine($"{firstPlayerBornIn90s.FirstName} {firstPlayerBornIn90s.Surname} was the first player on the list born in the 90s");Single
Single works in a similar way to First, but it is designed to return exactly one record.
var messiSingleRecord = playersList.Single(x => x.Surname == "Messi");If more than one record is returned by the query, it throws:
System.InvalidOperationException: 'Sequence contains more than one element'There is also a SingleOrDefault method, which returns null instead of throwing when no record is found, in the same way that FirstOrDefault does.
Ordering results
OrderBy orders records in ascending order, and was already used above to find the first player born in the 1990s.
OrderByDescending
To order records in descending order, call OrderByDescending. This returns the players from youngest to oldest.
var youngestToOldestPlayers = playersList.OrderByDescending(x => x.DateOfBirth);
foreach (var player in youngestToOldestPlayers)
{
Console.WriteLine($"{player.FirstName} {player.Surname} - Youngest to oldest");
}ThenBy
Chaining multiple OrderBy calls together does not add a secondary sort. Instead, each call overrides the one before it. To add a secondary sort, use ThenBy. Here, players are ordered by their surname length, and then by date of birth.
var shortestSurnameThenOldest = playersList.OrderBy(x => x.Surname.Length)
.ThenBy(x => x.DateOfBirth);
foreach (var player in shortestSurnameThenOldest)
{
Console.WriteLine($"{player.FirstName} {player.Surname} - Shortest surname then oldest");
}ThenByDescending
OrderBy and ThenBy also have descending equivalents. This orders players by the longest surname, and then by the youngest.
var longestSurnameThenYoungest = playersList.OrderByDescending(x => x.Surname.Length)
.ThenByDescending(x => x.DateOfBirth);
foreach (var player in longestSurnameThenYoungest)
{
Console.WriteLine($"{player.FirstName} {player.Surname} - Longest surname then youngest");
}Paging with Skip and Take
Returning every record in a large table can cause serious performance issues, so it is worth paging results with Skip and Take.
Take
Take returns a fixed number of records. Here, it returns the youngest three players.
var youngest3Players = playersList.OrderByDescending(x => x.DateOfBirth).Take(3);
foreach (var player in youngest3Players)
{
Console.WriteLine($"{player.FirstName} {player.Surname} is in the top 3 of youngest players");
}Skip
Skip is combined with Take to view records on a different page. Skipping three records and taking three more can return less than three results, since there might not be enough results returned.
var youngestPage2Players = playersList.OrderByDescending(x => x.DateOfBirth).Skip(3).Take(3);
foreach (var player in youngestPage2Players)
{
Console.WriteLine($"{player.FirstName} {player.Surname} is on page 2 of the youngest players");
}Removing duplicates with Distinct and DistinctBy
Distinct removes duplicate values from a list. This numbers list has several duplicate entries, so calling Distinct on it outputs 11, 10 and 9.
var numbers = new List<int> { 11, 11, 10, 9, 9 };
foreach (var number in numbers.Distinct())
{
Console.WriteLine($"{number} is in the list");
}DistinctBy removes duplicates based on a key selector, rather than the whole object. Here, the list is ordered by date of birth, then made distinct by dividing the birth year by ten, which returns the oldest player from each decade.
var oldestPlayerPerDecade = playersList
.OrderBy(x => x.DateOfBirth)
.DistinctBy(x => x.DateOfBirth.Year / 10);
foreach (var player in oldestPlayerPerDecade)
{
Console.WriteLine($"{player.FirstName} {player.Surname} is the oldest player found from their decade");
}Aggregating data with Count, Sum and Average
Count
Calling Count without any filtering counts every record in the list, but it also accepts a condition, just like Where, except it returns a number instead of a filtered list.
var totalPlayers = playersList.Count();
var playersBornIn90s = playersList.Count(x => x.DateOfBirth.Year >= 1990 && x.DateOfBirth.Year <= 1999);
Console.WriteLine($"Total players = {totalPlayers}");
Console.WriteLine($"Players born in the 90s = {playersBornIn90s}");Sum
Sum adds up a numeric value across the list. This adds up the length of every surname.
var totalSurnameLength = playersList.Sum(x => x.Surname.Length);
Console.WriteLine($"Total surname length = {totalSurnameLength}");Average
Average works in the same way, but returns the average value instead of the total.
var averageSurnameLength = playersList.Average(x => x.Surname.Length);
Console.WriteLine($"Average surname length = {averageSurnameLength}");Checking records with Any, All and Contains
Any
If you only need to check whether a record exists, use Any rather than Count, since counting every matching record is a more expensive query than simply checking for one. Any returns true if at least one record matches, and false if the list is empty.
var anyBornIn21stCent = playersList.Any(x => x.DateOfBirth.Year >= 2000);
Console.WriteLine($"Any player born in 21st century = {anyBornIn21stCent}");All
All only returns true if every record in the list matches the condition.
var allBornAfter1980 = playersList.All(x => x.DateOfBirth.Year > 1980);
Console.WriteLine($"All players born after 1980 = {allBornAfter1980}");Since every player in the list was born after 1980, this returns true. Changing the year to 1990 would return false, since Ronaldo and Messi were born in the 1980s.
Contains
Contains checks for an exact match against a value in the list.
var hasMessi = playersSurnames.Contains("Messi");
Console.WriteLine($"List contains Messi = {hasMessi}");Finding the smallest and largest values with Min, Max, MinBy and MaxBy
Min and Max
Min and Max return the smallest or largest value itself, not the whole record. This example gets the oldest and youngest player in the list.
var oldestDateOfBirth = playersList.Min(x => x.DateOfBirth);
var youngestDateOfBirth = playersList.Max(x => x.DateOfBirth);
Console.WriteLine($"Oldest date of birth = {oldestDateOfBirth}");
Console.WriteLine($"Youngest date of birth = {youngestDateOfBirth}");Unlike Min and Max, MinBy and MaxBy return the whole record rather than just the value being compared.
var oldestPlayer = playersList.MinBy(x => x.DateOfBirth);
var youngestPlayer = playersList.MaxBy(x => x.DateOfBirth);
Console.WriteLine($"{oldestPlayer.FirstName} {oldestPlayer.Surname} is the oldest player");
Console.WriteLine($"{youngestPlayer.FirstName} {youngestPlayer.Surname} is the youngest player");It is worth being careful with Min and Max if there is a chance no results are returned, since calling them on an empty sequence throws an exception. Filtering the list down to players born from 2010 onwards leaves no results:
var oldestDateOfBirth = playersList.Where(x => x.DateOfBirth.Year >= 2010)
.Min(x => x.DateOfBirth);In this example, it would throw:
System.InvalidOperationException: 'Sequence contains no elements'Getting a record by index with ElementAt and ElementAtOrDefault
ElementAt gets the record at a given index, starting at 0, and throws if the index is out of range. ElementAtOrDefault returns null instead of throwing when the index does not exist, which is useful as a substitute for arrays when you need to get the element at a certain index number.
var thirdPlayer = playersList.ElementAt(2);
var maybeTenthPlayer = playersList.ElementAtOrDefault(9);Index 2 is the third record in the list, which is Mbappé. There is no tenth player in this list, so maybeTenthPlayer returns null rather than throwing.
Reversing a list with Reverse
The order of a list is dependent on the order the records were added in. In playersList, Harry Kane is the first record and Cristiano Ronaldo is the last. Calling Reverse flips the order of the list, so it starts with Ronaldo and finishes with Kane.
playersList.Reverse();
foreach (var player in playersList)
{
Console.WriteLine($"{player.FirstName} {player.Surname} - Reversed order");
}Generating numbers with Enumerable.Range
Enumerable.Range generates a sequence of numbers without needing an existing list or array. You give it a starting number and a count, and it adds each number in that range to the sequence. Here it starts at 1 and counts to 11, generating an IEnumerable<int> from 1 to 11.
var shirtNumbers = Enumerable.Range(1, 11);
foreach (var shirtNumber in shirtNumbers)
{
Console.WriteLine($"Shirt number {shirtNumber}");
}Handling empty lists with DefaultIfEmpty
If a list has no records, you can add a default record to it with DefaultIfEmpty, rather than looping through an empty result.
var noPlayerSurnames = new List<string>();
var safeSurnames = noPlayerSurnames.DefaultIfEmpty("Nothing in the list");
foreach (var surname in safeSurnames)
{
Console.WriteLine(surname);
}Since noPlayerSurnames is empty, looping through safeSurnames returns one record, containing the default message passed into DefaultIfEmpty.
Adding and removing items from a list
Concat
Adding a lot of individual records to a list one at a time means a lot of extra code. A better way is to create a new list, then join it to the original list by calling Concat.
var morePlayersSurnames = new List<string> { "Salah", "De Bruyne" };
var allSurnames = playersSurnames.Concat(morePlayersSurnames);
foreach (var surname in allSurnames)
{
Console.WriteLine(surname);
}This prints out all the surnames from the original list, as well as the extra surnames in the new one.
Append
Calling Add on a list adds the record to that original list. Append works differently, since it creates a new IEnumerable with a record added at the end, rather than changing the original list.
var withOneMore = playersSurnames.Append("Saka");
foreach (var surname in withOneMore)
{
Console.WriteLine($"Append = {surname}");
}Prepend
Prepend works the same way as Append, but adds the record to the start of the new sequence rather than the end.
var withOneAtStart = playersSurnames.Prepend("Foden");
foreach (var surname in withOneAtStart)
{
Console.WriteLine($"Prepend = {surname}");
}AddRange, Remove and RemoveRange
List<T> also has methods for changing the list directly. AddRange adds multiple records to a list in one call, Remove removes a specific record, and RemoveRange removes a number of records starting from a given index.
playersSurnames.AddRange(["Salah"]);
playersSurnames.Remove("Ronaldo");
playersSurnames.RemoveRange(0, 1);
foreach(var surname in playersSurnames)
{
Console.WriteLine(surname);
}Grouping records with GroupBy
GroupBy buckets records together by a key, similar to a GROUP BY in SQL. Here, players are grouped by the decade they were born in.
var groupedByDecade = playersList
.OrderBy(x => x.DateOfBirth.Year)
.GroupBy(x => x.DateOfBirth.Year / 10 * 10);
foreach (var group in groupedByDecade)
{
Console.WriteLine($"{group.Key}s:");
Console.WriteLine($"-----------");
foreach (var player in group)
{
Console.WriteLine($"{player.FirstName} {player.Surname}");
}
Console.WriteLine("");
}Each group has a Key, which represents the value the records were grouped by, and is itself a list you can loop through. This outputs the players born in the 1980s, the 1990s and the 2000s.
Converting a list to a dictionary with ToDictionary
ToDictionary turns a list into a dictionary, keyed by whatever selector you give it. Here, players are keyed by their surname which is done using ToDictionary(x => x.Surname).
var playersBySurname = playersList.ToDictionary(x => x.Surname);
var kane = playersBySurname["Kane"];
Console.WriteLine($"{kane.FirstName} {kane.Surname} found by dictionary lookup");The key must be unique. If a duplicate key is added, such as another player with the surname Kane, it throws an exception.
IEnumerable, IList, List and friends
There are several different collection types, and they differ in what you are allowed to do with them.
IEnumerable
IEnumerable<T> is the most basic collection type. There is no adding or removing items, and no count property available. Most LINQ methods return this type.
IEnumerable<Player> enumerablePlayers = playersList;
foreach (var player in enumerablePlayers)
{
Console.WriteLine($"{player.FirstName} {player.Surname}");
}ICollection
ICollection<T> adds a Count property, as well as Add and Remove, on top of IEnumerable<T>. You cannot index it by position.
ICollection<Player> collectionPlayers = playersList; Console.WriteLine(collectionPlayers.Count);
collectionPlayers.Add(new Player("Erling", "Haaland",
new DateOnly(2000, 7, 21)));
var firstRecord = collectionPlayers[0]; // Throws exception. Cannot apply indexing with [] to an expression of type ...IList
IList<T> adds indexing by position on top of ICollection<T>.
IList<Player> listPlayers = playersList;
var firstPlayer = listPlayers[0];List
List<T> is the concrete class used throughout this tutorial. It implements IList<T>, ICollection<T> and IEnumerable<T>, and also has extra methods of its own, like Sort and AddRange, that are not part of any of those interfaces.
var concretePlayers = new List<Player>();
concretePlayers.Add(new Player("Bukayo", "Saka", new DateOnly(2001, 9, 5)));IReadOnlyCollection and IReadOnlyList
IReadOnlyCollection<T> and IReadOnlyList<T> have the same shape as ICollection<T> and IList<T>, but without Add, Remove, or anything else that changes the collection.
IReadOnlyList<Player> readOnlyPlayers = playersList;
var secondPlayer = readOnlyPlayers[1];ReadOnlyCollection
ReadOnlyCollection<T> is a concrete wrapper around an existing list. Unlike casting to IReadOnlyList<T>, this actually stops the list being changed through it.
var trulyReadOnlyPlayers = new ReadOnlyCollection<Player>(playersList);It still reflects changes made to playersList itself, since it wraps the same underlying list rather than copying it. As a rule of thumb, accept IEnumerable<T> or IReadOnlyList<T> as a method parameter when you only need to read the collection, and use List<T> when you need to change it.
Converting to a List or array with ToList and ToArray
Most LINQ methods return an IEnumerable<T>, a sequence you can loop through, rather than a concrete list or array. Calling ToList or ToArray runs the query immediately and stores the results in an actual list or array.
var longSurnamesList = playersSurnames.Where(x => x.Length > 5).ToList();
var longSurnamesArray = playersSurnames.Where(x => x.Length > 5).ToArray();Handling null collections with the null conditional operator
The null conditional operator is important if your collection type might return null. Without it, calling a LINQ method on a null collection throws an exception.
List<Player>? maybePlayersList = null;
var maybeSurnames = maybePlayersList?.Select(x => x.Surname);Adding the ? operator before Select means this does not throw, even though maybePlayersList is null. Without it, it would throw:
System.ArgumentNullException: 'Value cannot be null.'Method syntax vs query syntax
So far, every query has been written using method syntax, but LINQ also supports query syntax, which reads more like SQL.
var methodSyntax = playersList
.Where(x => x.DateOfBirth.Year >= 2000)
.OrderBy(x => x.Surname);
// Query syntax
var querySyntax = from x in playersList
where x.DateOfBirth.Year >= 2000
orderby x.Surname
select x;Both produce the same result. Query syntax gets translated into method syntax by the compiler behind the scenes. Method syntax tends to be the more natural choice day to day, though query syntax can be worth reaching for in Entity Framework Core once a query needs more complicated joins.
Watch the video
Watch the video where we walk through each of the LINQ methods described in this tutorial and show working examples for each of them.
Related tutorials
Learn exactly when the try, catch and finally blocks execute in C#, including edge cases in console apps and ASP.NET Core when exceptions are thrown.
Primary constructors is a C# 12 feature that allows to add parameters to a class and includes dependency injection support.