DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted on • Edited on

C# Lambda Expressions and LINQ: The Two Features That Changed How I Write Code

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Statement lambda for multiple lines:

x =>
{
    var doubled = x * 2;
    return doubled;
}
Enter fullscreen mode Exit fullscreen mode

No parameters use empty parentheses:

() => DateTime.UtcNow
Enter fullscreen mode Exit fullscreen mode

Multiple parameters use parentheses:

(x, y) => x + y
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Action has no return value (void):

Action<string> printer = message => Console.WriteLine(message);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Select transforms each element — projects to a new shape:

posts.Select(p => p.Title)
posts.Select(p => new { p.Title, p.Excerpt })
Enter fullscreen mode Exit fullscreen mode

OrderBy and OrderByDescending sort the results:

posts.OrderBy(p => p.Title)
posts.OrderByDescending(p => p.CreatedAt)
Enter fullscreen mode Exit fullscreen mode

FirstOrDefault finds one element safely — returns null if nothing matches rather than throwing an exception:

posts.FirstOrDefault(p => p.Slug == "my-post")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Count with a predicate counts matching elements:

posts.Count(p => p.Tech == "Azure")
Enter fullscreen mode Exit fullscreen mode

Take and Skip enable pagination:

posts.Skip((page - 1) * pageSize).Take(pageSize)
Enter fullscreen mode Exit fullscreen mode

GroupBy groups elements by a key:

posts.GroupBy(p => p.Tech)
     .Select(g => new { Tech = g.Key, Count = g.Count() })
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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()

Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

EF Core translates this to:

SELECT Id, Title, Slug, Tech
FROM Posts
WHERE IsPublished = 1
ORDER BY CreatedAt DESC
Enter fullscreen mode Exit fullscreen mode

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]++;
}
Enter fullscreen mode Exit fullscreen mode

After GroupBy:

var techCounts = posts
    .GroupBy(p => p.Tech)
    .ToDictionary(g => g.Key, g => g.Count());
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
vaso profile image
Vaclav Elias

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!

Collapse
 
manoharij profile image
Manohari Jayachandran

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.