When I first started writing C# I used foreach loops for everything. Filter a list — foreach loop. Find one item — foreach loop. Transform every element — foreach loop. The code worked but it was verbose, repetitive, and hard to read at a glance.
Lambda expressions and LINQ changed everything. The same operations became one line. The intent became immediately readable. And when I started using Entity Framework Core to query Azure SQL in the TechStack Blog API, LINQ became the way I wrote every single database query without touching raw SQL.
These are not beginner concepts you learn once and forget. They are the foundation of every modern C# codebase. This post covers both deeply — with analogies, real code, and the mistakes that only become clear in production.
Part 1 — Lambda Expressions
The Analogy
Think of a regular method like a full recipe card with a name, an ingredients list, and step-by-step instructions. You write it once, give it a name, and call it by name whenever you need it.
A lambda expression is like a sticky note with a quick instruction written on it — no name, used once right where you need it, then gone. It is a method without a name, written inline exactly where it is used.
What a Lambda Expression Is
A lambda expression has two sides separated by the => arrow operator (pronounced "goes to"):
- Left side: the input parameter
- Right side: the expression that returns a value
So post => post.IsPublished reads as:
"given a post, return whether IsPublished is true"
The equivalent named method would be:
public bool IsPublished(Post post)
{
return post.IsPublished == true;
}
The lambda is not better or worse — it is shorter and used inline rather than declared separately and called by name.
Lambda Syntax Types
Expression lambda for a single expression:
x => x * 2
Statement lambda for multiple lines:
x =>
{
var doubled = x * 2;
return doubled;
}
No parameters use empty parentheses:
() => DateTime.UtcNow
Multiple parameters use parentheses:
(x, y) => x + y
Func and Action
Lambdas can be stored in variables using two built-in delegate types.
Func has a return value. Func takes the input types first and the return type last:
Func<int, int> doubler = x => x * 2;
Action has no return value (void):
Action<string> printer = message => Console.WriteLine(message);
Once stored in a variable, you call them like regular
methods: doubler(5) returns 10, printer("Hello") prints Hello.
Closures — Captured Variables
Lambdas can capture variables from the surrounding scope. This is called a closure and it is both powerful and a source of subtle bugs.
string techFilter = "Azure";
var azurePosts = posts.Where(p => p.Tech == techFilter);
The lambda captures techFilter from the outer scope. It remembers the variable — not a copy of its value at the time of capture, but the variable itself. If techFilter changes before the lambda executes, the lambda sees the new value.
The classic bug is closures in loops. When you create a lambda inside a loop and use the loop variable, all lambdas end up referencing the same variable — which has its final value after the loop ends. Fix it by capturing a copy:
for (int i = 0; i < 3; i++)
{
int captured = i; // capture a copy of i right now
actions.Add(() => Console.WriteLine(captured));
}
Without the int captured = i line, all three lambdas print the same final value of i. With it, each captures its own copy and prints 0, 1, 2 as expected.
Part 2 — LINQ
The Analogy
LINQ is SQL for your C# collections. Just as SQL lets you query a database with SELECT, WHERE, ORDER BY and GROUP BY — LINQ lets you query any list, array, or collection in C# using the same concepts but in C# syntax.
The magic is that LINQ works on everything — in-memory lists, Entity Framework database queries, XML documents, JSON collections. The same syntax, completely different data sources.
Two Syntax Styles
Method syntax uses dot chaining and is the most common in modern C#:
var result = posts
.Where(p => p.IsPublished)
.OrderByDescending(p => p.CreatedAt)
.Select(p => p.Title)
.ToList();
Query syntax looks like SQL and reads naturally for developers coming from a database background:
var result2 =
(from p in posts
where p.IsPublished
orderby p.CreatedAt descending
select p.Title)
.ToList();
Both produce identical results. Method syntax is more flexible and more commonly written in production code. Query syntax can be easier to read or complex joins.
The Essential LINQ Methods
Where filters elements — keeps only those where the predicate returns true:
posts.Where(p => p.IsPublished)
posts.Where(p => p.Tech == "Azure" && p.ReadingTime > 10)
Select transforms each element — projects to a new shape:
posts.Select(p => p.Title)
posts.Select(p => new { p.Title, p.Excerpt })
OrderBy and OrderByDescending sort the results:
posts.OrderBy(p => p.Title)
posts.OrderByDescending(p => p.CreatedAt)
FirstOrDefault finds one element safely — returns null if nothing matches rather than throwing an exception:
posts.FirstOrDefault(p => p.Slug == "my-post")
Always use FirstOrDefault over First in production. Production data is never as clean as you expect and First throws when nothing matches.
Any and All return booleans:
posts.Any(p => p.Tech == "Azure") — true if at least one
posts.All(p => p.IsPublished) — true if every single one
Count with a predicate counts matching elements:
posts.Count(p => p.Tech == "Azure")
Take and Skip enable pagination:
posts.Skip((page - 1) * pageSize).Take(pageSize)
GroupBy groups elements by a key:
posts.GroupBy(p => p.Tech)
.Select(g => new { Tech = g.Key, Count = g.Count() })
Deferred Execution — The Most Important Concept
This is the source of most LINQ bugs and the concept that trips up even experienced developers.
LINQ queries do not execute when you define them. They execute when you iterate the results.
var query = posts.Where(p => p.Tech == "Azure");
// Nothing has happened yet. No filtering. No looping.
var list = query.ToList();
// NOW the query executes. The filtering happens here.
The practical implication is that data can change between when you define a query and when you execute it. If you add a new Azure post after defining the query but before calling ToList, the new post appears in the results.
With Entity Framework Core, deferred execution means no SQL is generated until you call ToListAsync, FirstOrDefaultAsync, CountAsync, or any other materializing method. This is why every EF Core query ends with one of these calls.
The Most Important Performance Rule
Never call ToList before Where.
This loads your entire database table into memory as a C# list, then filters in C# instead of in SQL. For a table with a million rows this is catastrophic.
Always filter first, materialize last:
Wrong: _context.Posts.ToList().Where(p => p.Tech == "Azure")
Right: _context.Posts.Where(p => p.Tech == "Azure").ToList()
The right version generates a SQL WHERE clause and only returns matching rows from the database. The wrong version loads every row then throws most of them away in memory.
LINQ with Entity Framework Core
This is where LINQ becomes truly powerful. Every LINQ query on a DbSet translates to SQL that runs against your database. You never write raw SQL for standard operations.
The query that powers the TechStack Blog API homepage:
var posts = await _context.Posts
.AsNoTracking()
.Where(p => p.IsPublished)
.OrderByDescending(p => p.CreatedAt)
.Select(p => new PostDto {
Id = p.Id,
Title = p.Title,
Slug = p.Slug,
Tech = p.Tech
})
.ToListAsync();
EF Core translates this to:
SELECT Id, Title, Slug, Tech
FROM Posts
WHERE IsPublished = 1
ORDER BY CreatedAt DESC
AsNoTracking tells EF Core this is read-only — it skips change tracking and significantly improves performance for queries that will not update data.
Selecting into a DTO inside the query means only the columns you need come from the database. Without the Select, EF Core fetches every column even if you only use two of them.
GroupBy in Practice
GroupBy is the most underused LINQ method. It replaces entire blocks of code that build dictionaries manually with a clean readable pipeline.
Before GroupBy (old style):
var techCounts = new Dictionary<string, int>();
foreach (var post in posts)
{
if (!techCounts.ContainsKey(post.Tech))
techCounts[post.Tech] = 0;
techCounts[post.Tech]++;
}
After GroupBy:
var techCounts = posts
.GroupBy(p => p.Tech)
.ToDictionary(g => g.Key, g => g.Count());
Both produce the same dictionary. The GroupBy version is four lines shorter and immediately readable.
SelectMany — Flattening Nested Collections
SelectMany is LINQ's way of flattening a collection of collections into one flat collection.
If each post has a list of tags, SelectMany gives you all tags from all posts as one flat list:
var allTags = posts
.SelectMany(p => p.Tags)
.Select(t => t.Name)
.Distinct()
.OrderBy(n => n);
Without SelectMany you would need a nested foreach loop to collect all tags from all posts. SelectMany does it in one step.
Key Lessons From Production
Use FirstOrDefault not First everywhere. First throws an exception when nothing matches. In production data is messy and things that should always exist sometimes do not.
Put ToList or ToListAsync at the very end. Deferred execution means the query runs where you materialize the results. Moving ToList earlier changes where the work happens and usually for the worse.
Use AsNoTracking for every read-only query. EF Core tracks every object it loads by default so it can detect changes. For queries where you will never update the data this is wasted overhead. AsNoTracking is a free performance improvement.
Learn GroupBy deeply. Once you see how it replaces manual dictionary building the pattern appears everywhere and you will use it constantly.
Understand the closure-in-loop bug before using lambdas in loops. It does not come up every day but when it does and you do not know about it the debugging is genuinely confusing.
Summary
Lambda expressions give you unnamed inline functions that make code shorter and more expressive. LINQ gives you a consistent query language that works on any collection — lists, arrays, databases, XML. Together
they are the two features that separate readable modern C# from verbose loop-heavy code.
Every endpoint in the TechStack Blog API uses both. The database queries are LINQ. The data transformations are lambdas with Select. The filtering is Where with lambda predicates. Master these two concepts and every other C# pattern becomes easier to understand and implement.
Originally published at TechStack Blog:
https://calm-island-0a7b4b30f.7.azurestaticapps.net/post.html?slug=csharp-lambda-linq-deep-dive
Follow for weekly posts on C#, Azure integration, and cloud engineering.
Top comments (2)
Please, can you make your C# code snippets readable like e.g. here dev.to/mirzaleka/class-record-and-...
I noticed that many of your posts include links to your main website home page without linking to the specific source articles or adding contextual value to the discussion.
To maintain the quality of the community and ensure readers can easily find deep-dive resources, it would be much better to link directly to the specific blog posts you are referencing, or use canonical URLs if you are cross-posting.
Generic homepage links can sometimes feel like SEO link building rather than content sharing.Thank you for contributing to the community, and I hope you consider adjusting your linking style for future posts!
Thank you so much for this feedback — genuinely appreciate you taking the time to explain this rather than just downvoting.
You are absolutely right on both points.
On the linking: I will update my posts to link directly to the specific article rather than the homepage. Will fix this going forward.
On the code formatting: thank you for sharing that example. I will look into how Dev.to handles code blocks and update the formatting in my posts.
This is my first month publishing technical content and feedback like this is exactly what helps me improve. Really appreciate it.