DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted 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;
}

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 doubler = x => x * 2;

Action has no return value (void):
Action 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 for 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();
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://www.techstackblog.com

Follow for weekly posts on C#, Azure integration,
and cloud engineering.

Top comments (0)