C# LINQ Examples on .NET 10: LeftJoin, CountBy and Pitfalls
Last updated: October 12, 2026 · Tested with .NET 10 (SDK 10.0.401, runtime 10.0.12), C# 14, EF Core 10.0.12 with SQLite, Windows 11
Short answer: LINQ is a set of extension methods in System.Linq for filtering, shaping, sorting, grouping and joining any IEnumerable<T>. Against a database (IQueryable<T>), EF Core turns the same methods into SQL. This post runs the everyday operators on one small data set and shows the real output. It also covers the operators added in .NET 6, 9 and 10 (LeftJoin and RightJoin are new in .NET 10), the exact exceptions LINQ throws, deferred execution, and the EF Core could not be translated error. Every block below was compiled and run on .NET 10, and the output is copied from the console.
The Sample Data
All examples use these two lists. ImplicitUsings (on by default in .NET 6+ templates) already imports System.Linq:
var products = new List<Product>
{
new(1, "Laptop", "Electronics", 1200m, 5),
new(2, "Mouse", "Electronics", 25m, 40),
new(3, "Desk", "Furniture", 300m, 0),
new(4, "Chair", "Furniture", 150m, 12),
new(5, "Monitor", "Electronics", 400m, 8),
new(6, "Notebook", "Stationery", 3m, 200),
};
var orders = new List<Order>
{
new(101, 1, 2),
new(102, 2, 5),
new(103, 2, 1),
new(104, 5, 1),
new(105, 9, 3), // product 9 doesn't exist
};
public record Product(int Id, string Name, string Category, decimal Price, int Stock);
public record Order(int Id, int ProductId, int Quantity);
Prices are formatted with :C; I set CultureInfo.CurrentCulture = new("en-US") so the output below doesn’t depend on the machine.
Where, Select, OrderBy, GroupBy and Aggregates
These cover most LINQ in real code. Filter with Where, shape with Select, sort with OrderBy/ThenBy, bucket with GroupBy, then aggregate:
var inStock = products.Where(p => p.Stock > 0);
Console.WriteLine(string.Join(", ", inStock.Select(p => p.Name)));
var labels = products.Select(p => $"{p.Name} ({p.Price:C0})");
Console.WriteLine(string.Join(", ", labels));
var sorted = products.OrderBy(p => p.Category).ThenByDescending(p => p.Price);
Console.WriteLine(string.Join(", ", sorted.Select(p => $"{p.Category}/{p.Name}")));
foreach (var g in products.GroupBy(p => p.Category))
{
Console.WriteLine($"{g.Key}: {g.Count()} items, stock value {g.Sum(p => p.Price * p.Stock):C0}");
}
Console.WriteLine($"Total stock: {products.Sum(p => p.Stock)}, average price: {products.Average(p => p.Price):C2}");
Console.WriteLine($"Any furniture under $200? {products.Any(p => p.Category == "Furniture" && p.Price < 200)}");
Console.WriteLine($"First over $1,000: {products.FirstOrDefault(p => p.Price > 1000m)?.Name ?? "none"}");
Console.WriteLine($"First over $5,000: {products.FirstOrDefault(p => p.Price > 5000m)?.Name ?? "none"}");
Laptop, Mouse, Chair, Monitor, Notebook
Laptop ($1,200), Mouse ($25), Desk ($300), Chair ($150), Monitor ($400), Notebook ($3)
Electronics/Laptop, Electronics/Monitor, Electronics/Mouse, Furniture/Desk, Furniture/Chair, Stationery/Notebook
Electronics: 3 items, stock value $10,200
Furniture: 2 items, stock value $1,800
Stationery: 1 items, stock value $600
Total stock: 265, average price: $346.33
Any furniture under $200? True
First over $1,000: Laptop
First over $5,000: none
Things to notice. ThenByDescending sorts inside each category, so within Electronics it’s Laptop, Monitor, Mouse. GroupBy keeps the order in which keys first appear. FirstOrDefault returns null for a reference type when nothing matches, so the ?. is needed. Any() stops at the first match, which makes it the right way to ask “is there at least one?” (not Count() > 0).
Query Syntax vs Method Syntax
The query syntax is translated by the compiler into the same method calls, so pick whichever reads better. Method syntax is what you’ll see in most codebases. Query syntax is easier to read for joins and let.
var cheapElectronics =
from p in products
where p.Category == "Electronics" && p.Price < 500m
orderby p.Price
select p.Name;
var same = products
.Where(p => p.Category == "Electronics" && p.Price < 500m)
.OrderBy(p => p.Price)
.Select(p => p.Name);
Console.WriteLine(string.Join(", ", cheapElectronics));
Console.WriteLine(cheapElectronics.SequenceEqual(same));
Mouse, Monitor
True
Newer Operators: MaxBy, DistinctBy, Chunk (.NET 6) and CountBy, AggregateBy, Index (.NET 9)
Many older LINQ tutorials still write these by hand with GroupBy or OrderByDescending(...).First():
// .NET 6
Console.WriteLine($"MaxBy price: {products.MaxBy(p => p.Price)!.Name}");
Console.WriteLine($"DistinctBy category: {string.Join(", ", products.DistinctBy(p => p.Category).Select(p => p.Name))}");
foreach (var page in products.Chunk(4))
{
Console.WriteLine($"Chunk: {string.Join(", ", page.Select(p => p.Name))}");
}
// .NET 9
foreach (var (category, count) in products.CountBy(p => p.Category))
{
Console.WriteLine($"CountBy: {category} = {count}");
}
foreach (var (category, value) in products.AggregateBy(p => p.Category, 0m, (sum, p) => sum + p.Price * p.Stock))
{
Console.WriteLine($"AggregateBy: {category} = {value:C0}");
}
foreach (var (i, p) in products.Take(2).Index())
{
Console.WriteLine($"Index: {i} {p.Name}");
}
MaxBy price: Laptop
DistinctBy category: Laptop, Desk, Notebook
Chunk: Laptop, Mouse, Desk, Chair
Chunk: Monitor, Notebook
CountBy: Electronics = 3
CountBy: Furniture = 2
CountBy: Stationery = 1
AggregateBy: Electronics = $10,200
AggregateBy: Furniture = $1,800
AggregateBy: Stationery = $600
Index: 0 Laptop
Index: 1 Mouse
MaxBy/MinByreturn the element, not the value.Max(p => p.Price)would return1200;MaxByreturns the Laptop.DistinctBykeeps the first element per key.Chunksplits into arrays of at most N items, which is handy for batching API calls or inserts.CountByandAggregateByreturn key/value pairs directly. They replaceGroupBy(...).Select(g => new { g.Key, Count = g.Count() })without building the groups.Indexgives you(index, item)tuples, so you no longer needSelect((p, i) => ...).
LeftJoin and RightJoin in .NET 10
Before .NET 10, a left outer join in LINQ meant GroupJoin + SelectMany + DefaultIfEmpty, a pattern almost everyone had to look up. .NET 10 adds LeftJoin and RightJoin:
// Inner join: order 105 (unknown product) is dropped
var inner = orders.Join(products, o => o.ProductId, p => p.Id,
(o, p) => $"{o.Id}:{p.Name}x{o.Quantity}");
Console.WriteLine("Join: " + string.Join(", ", inner));
// .NET 10: LeftJoin keeps every order; the product is null when there is no match
var left = orders.LeftJoin(products, o => o.ProductId, p => p.Id,
(o, p) => $"{o.Id}:{p?.Name ?? "?"}x{o.Quantity}");
Console.WriteLine("LeftJoin: " + string.Join(", ", left));
// .NET 10: RightJoin keeps every product, even those never ordered
var right = orders.RightJoin(products, o => o.ProductId, p => p.Id,
(o, p) => $"{p.Name}:{o?.Id.ToString() ?? "-"}");
Console.WriteLine("RightJoin: " + string.Join(", ", right));
// Before .NET 10: GroupJoin + SelectMany + DefaultIfEmpty
var oldLeft = orders
.GroupJoin(products, o => o.ProductId, p => p.Id, (o, ps) => new { o, ps })
.SelectMany(x => x.ps.DefaultIfEmpty(), (x, p) => $"{x.o.Id}:{p?.Name ?? "?"}x{x.o.Quantity}");
Console.WriteLine("Old left: " + string.Join(", ", oldLeft));
Join: 101:Laptopx2, 102:Mousex5, 103:Mousex1, 104:Monitorx1
LeftJoin: 101:Laptopx2, 102:Mousex5, 103:Mousex1, 104:Monitorx1, 105:?x3
RightJoin: Laptop:101, Mouse:102, Mouse:103, Desk:-, Chair:-, Monitor:104, Notebook:-
Old left: 101:Laptopx2, 102:Mousex5, 103:Mousex1, 104:Monitorx1, 105:?x3
Order 105 points at a product that doesn’t exist. Join drops it; LeftJoin keeps it with a null product, giving the same result as the old three-method pattern. RightJoin keeps every product, including Desk, Chair and Notebook, which nobody ordered. The inner side of the result selector is nullable (p?.Name). Write p.Name instead and, with nullable reference types on, the build reports warning CS8602: Dereference of a possibly null reference. At runtime it would be a NullReferenceException on order 105. EF Core 10 translates LeftJoin to a SQL LEFT JOIN (shown in the EF section below).
Sequence Contains No Elements and Other LINQ Exceptions
These are the messages people search for. Here is each one with what triggers it:
var empty = new List<Product>();
Show("First on empty", () => empty.First());
Show("First no match", () => products.First(p => p.Price > 5000m));
Show("Single, 3 matches", () => products.Single(p => p.Category == "Electronics"));
Show("SingleOrDefault, 3", () => products.SingleOrDefault(p => p.Category == "Electronics"));
Show("Max on empty", () => empty.Max(p => p.Price));
Show("Average on empty", () => empty.Average(p => p.Price));
Show("Max on empty, nullable", () => empty.Max(p => (decimal?)p.Price));
Show("Sum int overflow", () => new[] { int.MaxValue, 1 }.Sum());
Show("ElementAt(10)", () => products.ElementAt(10));
static void Show(string label, Func<object?> action)
{
try { Console.WriteLine($"{label,-24} {action() ?? "null"}"); }
catch (Exception ex) { Console.WriteLine($"{label,-24} {ex.GetType().Name}: {ex.Message}"); }
}
First on empty InvalidOperationException: Sequence contains no elements
First no match InvalidOperationException: Sequence contains no matching element
Single, 3 matches InvalidOperationException: Sequence contains more than one matching element
SingleOrDefault, 3 InvalidOperationException: Sequence contains more than one matching element
Max on empty InvalidOperationException: Sequence contains no elements
Average on empty InvalidOperationException: Sequence contains no elements
Max on empty, nullable null
Sum int overflow OverflowException: Arithmetic operation resulted in an overflow.
ElementAt(10) ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')
- Sequence contains no elements:
First(),Single(),Last(),Max(),Min()orAverage()on an empty sequence. With a predicate,Firstsays no matching element instead. UseFirstOrDefaultwhen “none” is a normal case. - Sequence contains more than one matching element:
Singleand alsoSingleOrDefault. The OrDefault only covers zero matches, not several. UseSingleonly when two matches really are a bug, such as a lookup by a unique key. Max/Averageon an empty list: cast the selector to a nullable type ((decimal?)p.Price) and you getnullinstead of an exception. On .NET 6+ you can also useDefaultIfEmpty()orMaxBy.Sumofintis checked: it throwsOverflowExceptioninstead of wrapping. Sum intolong(Sum(x => (long)x)) for big totals.
Deferred Execution and Multiple Enumeration
A LINQ query over a collection is a recipe, not a result. Nothing runs until you enumerate it, and it runs again every time you do. A counter in the predicate makes that visible:
int calls = 0;
var expensive = products.Where(p => { calls++; return p.Price > 100m; });
Console.WriteLine($"After defining the query: {calls} calls");
Console.WriteLine($"Count: {expensive.Count()}, calls so far: {calls}");
Console.WriteLine($"Any: {expensive.Any()}, calls so far: {calls}");
Console.WriteLine($"Names: {string.Join(", ", expensive.Select(p => p.Name))}, calls so far: {calls}");
calls = 0;
var materialized = expensive.ToList();
Console.WriteLine($"ToList: {calls} calls; materialized.Count afterwards: {materialized.Count}, calls still {calls}");
products.Add(new Product(7, "Webcam", "Electronics", 120m, 9));
Console.WriteLine($"Query sees the new item: {expensive.Count()}, the list doesn't: {materialized.Count}");
After defining the query: 0 calls
Count: 4, calls so far: 6
Any: True, calls so far: 7
Names: Laptop, Desk, Chair, Monitor, calls so far: 13
ToList: 6 calls; materialized.Count afterwards: 4, calls still 6
Query sees the new item: 5, the list doesn't: 4
Defining the query cost 0 calls. Count() ran the predicate over all 6 products. Any() ran it once more (it stopped at the Laptop). Building the names ran all 6 again. So three uses of one query meant 13 predicate calls. After ToList() the results are fixed: no more calls, and the list doesn’t see the Webcam added later, while the query does. If a query is expensive (a database call, a regex, an HTTP request inside Select) and you use the result more than once, materialize it with ToList() or ToArray(). Rider flags this. So does the .NET analyzer once you turn it on with dotnet_diagnostic.CA1851.severity = warning in .editorconfig: on this code it reported warning CA1851: Possible multiple enumerations of ‘IEnumerable’ collection three times.
Collection was modified; enumeration operation may not execute
The deferred query is still reading the list while you change it:
try
{
foreach (var p in products.Where(p => p.Stock == 0))
{
products.Remove(p);
}
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
products.RemoveAll(p => p.Stock == 0); // or: foreach over .ToList()
Console.WriteLine($"Out of stock left: {products.Count(p => p.Stock == 0)}");
InvalidOperationException: Collection was modified; enumeration operation may not execute.
Out of stock left: 0
Use List<T>.RemoveAll with a predicate, or loop over a copy (foreach (var p in products.Where(...).ToList())).
LINQ with EF Core: IQueryable, ToQueryString and “Could Not Be Translated”
On a DbSet, the same LINQ methods build an expression tree that EF Core turns into SQL. ToQueryString() shows the SQL without running it. The full program (EF Core 10.0.12, SQLite):
using Microsoft.EntityFrameworkCore;
System.Globalization.CultureInfo.CurrentCulture = new("en-US");
using var db = new ShopContext();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
db.Products.AddRange(
new Product { Name = "Laptop", Category = "Electronics", Price = 1200m },
new Product { Name = "Mouse", Category = "Electronics", Price = 25m },
new Product { Name = "Desk", Category = "Furniture", Price = 300m });
db.Orders.AddRange(new Order { ProductId = 1, Quantity = 2 }, new Order { ProductId = 99, Quantity = 1 });
db.SaveChanges();
Console.WriteLine("--- 1. IQueryable: filter runs in SQL");
var q = db.Products.Where(p => p.Price > 100m).Select(p => p.Name);
Console.WriteLine(q.ToQueryString());
Console.WriteLine(string.Join(", ", q));
Console.WriteLine("--- 2. Method the provider can't translate");
try
{
var bad = db.Products.Where(p => Rules.IsPremium(p)).ToList();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
Console.WriteLine("--- 3. AsEnumerable: works, but every row is loaded");
IEnumerable<Product> all = db.Products.AsEnumerable();
var premium = all.Where(p => Rules.IsPremium(p)).Select(p => p.Name);
Console.WriteLine(db.Products.ToQueryString());
Console.WriteLine(string.Join(", ", premium));
Console.WriteLine("--- 4. .NET 10 LeftJoin translated to SQL");
var lj = db.Orders.LeftJoin(db.Products, o => o.ProductId, p => p.Id,
(o, p) => new { o.Id, Name = p == null ? "?" : p.Name });
Console.WriteLine(lj.ToQueryString());
Console.WriteLine(string.Join(", ", lj.Select(x => $"{x.Id}:{x.Name}")));
public static class Rules
{
public static bool IsPremium(Product p) => p.Price > 1000m;
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Category { get; set; } = "";
public decimal Price { get; set; }
}
public class Order
{
public int Id { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
}
public class ShopContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseSqlite("Data Source=shop.db");
}
--- 1. IQueryable: filter runs in SQL
SELECT "p"."Name"
FROM "Products" AS "p"
WHERE ef_compare("p"."Price", '100.0') > 0
Laptop, Desk
--- 2. Method the provider can't translate
The LINQ expression 'DbSet<Product>()
.Where(p => Rules.IsPremium(p))' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.
--- 3. AsEnumerable: works, but every row is loaded
SELECT "p"."Id", "p"."Category", "p"."Name", "p"."Price"
FROM "Products" AS "p"
Laptop
--- 4. .NET 10 LeftJoin translated to SQL
SELECT "o"."Id", CASE
WHEN "p"."Id" IS NULL THEN '?'
ELSE "p"."Name"
END AS "Name"
FROM "Orders" AS "o"
LEFT JOIN "Products" AS "p" ON "o"."ProductId" = "p"."Id"
1:Laptop, 2:?
What this shows:
- Filtering runs in the database. Only the
Namecolumn of the matching rows comes back. (SQLite has no decimal type, so EF Core 10 compares decimals with its ownef_comparefunction.) - Any C# method EF Core doesn’t know fails at runtime with The LINQ expression … could not be translated. It compiles fine, so you only find out when the query runs. Put the condition in the query itself (
p.Price > 1000m), or reuse it as anExpression<Func<Product, bool>>. - The “fix” the message suggests,
AsEnumerable(), loads every column of every row and filters in memory. On a 3-row table that’s fine; on a large table it isn’t. Use it only after aWherethat the database can run. LeftJoinbecomes a realLEFT JOINin EF Core 10, and the null check in the selector becomes a SQLCASE.
One more compile error I hit while writing this: with IsPremium as a local function in a top-level program, the build fails with CS8110: An expression tree may not contain a reference to a local function. Expression trees can only call ordinary methods, so the method has to live in a class, like Rules above.
LINQ Cheat Sheet
| You want | Use | Watch out for |
|---|---|---|
| Filter | Where | Runs again on every enumeration |
| “Is there any?” | Any() | Count() > 0 walks everything on a plain IEnumerable |
| First match or nothing | FirstOrDefault | Returns null/0, check it |
| Exactly one | Single | SingleOrDefault still throws on 2+ |
| Element with max value | MaxBy (.NET 6) | Max returns the value, not the element |
| Count per key | CountBy (.NET 9) | Returns KeyValuePairs |
| Sum per key | AggregateBy (.NET 9) | Seed type sets the result type |
| Batches of N | Chunk (.NET 6) | Each chunk is an array |
| Left outer join | LeftJoin (.NET 10) | Inner value is nullable |
| Results you reuse | ToList() | A snapshot: later changes aren’t seen |
| Database query | IQueryable + ToQueryString() | Custom methods → could not be translated |
FAQ
Is LINQ slower than a for loop? For in-memory collections, a hand-written loop usually allocates less, because LINQ creates an enumerator and calls a delegate per element. How much depends on the operator and the collection, so measure with BenchmarkDotNet before rewriting anything. Use LINQ for clarity and change hot paths only after a profiler points at them. For collection choice itself, see List vs Dictionary vs HashSet performance.
What’s the difference between IEnumerable and IQueryable? IEnumerable<T> runs your lambdas in memory. IQueryable<T> collects them as an expression tree that a provider (EF Core) translates, into SQL for example. Once you call AsEnumerable() or ToList(), everything after runs in memory.
Do I need LeftJoin if I use EF Core navigation properties? Usually not. Include or a projection over a navigation property gives you the outer join already. LeftJoin helps when there’s no navigation property, or for in-memory data.
Related: C# delegates and Func/Action (what the lambdas in LINQ are) · C# extension methods (how LINQ is built) · Nullable reference types
References: LINQ overview (Microsoft Learn) · Enumerable.LeftJoin API · EF Core client vs server evaluation
This article is part of the C# tutorials guide (language features, patterns, performance and testing).