DEV_NET_CORE
GET_STARTED
.NETModern C# patterns

LINQ Querying

Overview

LINQ, or Language Integrated Query, is a set of C# language features and .NET APIs that let developers query data using strongly typed C# code. Instead of writing separate query logic for arrays, lists, XML, databases, and other data sources, LINQ provides a common query model based on sequence operations such as filtering, projection, sorting, grouping, joining, and aggregation.

In C#, LINQ is used through two main styles:

  • Query syntax, which looks similar to SQL.
  • Method syntax, which uses extension methods such as Where, Select, OrderBy, GroupBy, and Join.

LINQ matters because modern .NET applications constantly work with collections and data sources. A backend service might filter domain entities, transform DTOs, query Entity Framework Core data, group report results, validate input collections, or compose business rules over in-memory objects. LINQ gives developers a concise and readable way to express those operations.

For interviews, LINQ is important because it tests more than syntax. Interviewers often use LINQ questions to evaluate whether a candidate understands:

  • Functional-style transformations in C#.
  • Deferred execution and when a query actually runs.
  • The difference between IEnumerable<T> and IQueryable<T>.
  • How LINQ behaves with in-memory collections versus database-backed queries.
  • Performance risks such as repeated enumeration, unnecessary materialization, client-side evaluation, and inefficient grouping or joining.
  • Practical query writing skills using filtering, projection, grouping, joining, ordering, and aggregation.

A strong answer should show that LINQ is not just a shortcut for loops. It is a query abstraction that can produce clean code when used correctly, but it can also create hidden performance or correctness problems when developers do not understand execution timing, provider translation, and materialization.

Core Concepts

What LINQ Is

LINQ is a language and library feature that allows query operations to be written directly in C#.

A LINQ query usually has three parts:

  1. A data source.
  2. A query definition.
  3. Query execution.

Example:

Code
var numbers = new[] { 1, 2, 3, 4, 5, 6 };

var evenNumbers = numbers
    .Where(number => number % 2 == 0)
    .Select(number => number * 10);

foreach (var number in evenNumbers)
{
    Console.WriteLine(number);
}

In this example:

  • numbers is the data source.
  • Where filters the sequence.
  • Select projects each matching value.
  • The query executes when evenNumbers is enumerated by foreach.

LINQ commonly works with:

  • Arrays
  • List<T>
  • Dictionary<TKey, TValue>
  • IEnumerable<T>
  • IQueryable<T>
  • XML data
  • Entity Framework Core queries
  • Custom query providers

Query Syntax and Method Syntax

C# supports both query syntax and method syntax.

Query syntax:

Code
var adults =
    from person in people
    where person.Age >= 18
    orderby person.LastName, person.FirstName
    select person;

Method syntax:

Code
var adults = people
    .Where(person => person.Age >= 18)
    .OrderBy(person => person.LastName)
    .ThenBy(person => person.FirstName);

Both forms are compiled into method calls. Query syntax can be easier to read for complex queries involving join, group, or multiple from clauses. Method syntax is often more common in modern C# code because it is concise, composable, and supports all LINQ operators.

Some operations have no direct query syntax equivalent and must use method syntax, such as:

Code
int count = people.Count(person => person.Age >= 18);
Person? first = people.FirstOrDefault(person => person.IsActive);
bool anyInactive = people.Any(person => !person.IsActive);

Best practice is to use the syntax that makes the query easiest to understand. Many production codebases prefer method syntax for simple pipelines and query syntax for complex joins or groupings.

Standard Query Operators

Standard query operators are methods that perform common query operations over sequences.

Common categories include:

CategoryCommon OperatorsPurpose
FilteringWhere, OfTypeKeep only matching elements
ProjectionSelect, SelectManyTransform elements into another shape
SortingOrderBy, OrderByDescending, ThenBy, ThenByDescendingSort results
GroupingGroupByGroup elements by key
JoiningJoin, GroupJoin, LeftJoin where availableCombine related sequences
AggregationCount, Sum, Average, Min, Max, AggregateProduce a single calculated value
Element accessFirst, FirstOrDefault, Single, SingleOrDefault, Last, ElementAtRetrieve specific elements
Set operationsDistinct, Union, Intersect, ExceptCompare or combine sequences
QuantifiersAny, All, ContainsTest conditions
PartitioningSkip, Take, SkipWhile, TakeWhileReturn part of a sequence
MaterializationToList, ToArray, ToDictionary, ToHashSetExecute and store results

Example using several operators:

Code
var topCustomers = customers
    .Where(customer => customer.IsActive)
    .OrderByDescending(customer => customer.TotalSpend)
    .Take(10)
    .Select(customer => new CustomerSummary(
        customer.Id,
        customer.Name,
        customer.TotalSpend))
    .ToList();

This query filters active customers, sorts them by spend, takes the top ten, projects them into a DTO, and materializes the result as a list.

IEnumerable<T> and LINQ to Objects

IEnumerable<T> represents a sequence that can be enumerated. LINQ to Objects works with in-memory sequences such as arrays and lists.

Example:

Code
IEnumerable<string> names = new List<string>
{
    "Alice",
    "Bob",
    "Charlie"
};

var shortNames = names.Where(name => name.Length <= 3);

For IEnumerable<T>, LINQ operators usually execute in memory using delegates such as Func<T, bool>.

Example:

Code
Func<string, bool> predicate = name => name.StartsWith("A");

var result = names.Where(predicate);

This is useful for in-memory operations, but it means every element may need to be inspected by the application process. For very large collections, developers should consider memory usage and algorithmic complexity.

IQueryable<T> and Provider-Based Queries

IQueryable<T> represents a query that can be translated by a provider. The most common example is Entity Framework Core, where a LINQ query is translated into SQL.

Example:

Code
IQueryable<Customer> query = dbContext.Customers
    .Where(customer => customer.IsActive)
    .OrderBy(customer => customer.Name);

With IQueryable<T>, the LINQ expression is represented as an expression tree. A provider, such as EF Core, analyzes that expression tree and translates it into another query language, commonly SQL.

This is a critical interview topic because the same-looking LINQ query can behave differently depending on whether it is running over IEnumerable<T> or IQueryable<T>.

Example:

Code
// Database-side filtering when Customers is IQueryable<Customer>
var customers = await dbContext.Customers
    .Where(customer => customer.Country == "US")
    .ToListAsync();

The filter can be translated into SQL and executed by the database.

But if the query is materialized too early:

Code
var allCustomers = await dbContext.Customers.ToListAsync();

var usCustomers = allCustomers
    .Where(customer => customer.Country == "US")
    .ToList();

The application loads all customers first, then filters in memory. This can be a serious performance problem.

Deferred Execution

Deferred execution means a LINQ query is not executed when it is defined. It executes when it is enumerated.

Example:

Code
var numbers = new List<int> { 1, 2, 3 };

var query = numbers.Where(number => number > 1);

numbers.Add(4);

foreach (var number in query)
{
    Console.WriteLine(number);
}

Output:

Code
2
3
4

The query includes 4 because it was not executed when it was assigned to query. It was executed later during enumeration.

Common operations that trigger execution include:

  • foreach
  • ToList
  • ToArray
  • ToDictionary
  • Count
  • Any
  • First
  • Single
  • Sum
  • Average
  • Max
  • Min

Deferred execution is powerful because it allows query composition:

Code
IQueryable<Order> query = dbContext.Orders;

if (status is not null)
{
    query = query.Where(order => order.Status == status);
}

if (fromDate is not null)
{
    query = query.Where(order => order.CreatedAt >= fromDate);
}

var results = await query
    .OrderByDescending(order => order.CreatedAt)
    .Take(50)
    .ToListAsync();

The query is built step by step but only sent to the database once.

Immediate Execution and Materialization

Immediate execution happens when a LINQ operation must produce a final value or materialized collection.

Examples:

Code
int count = customers.Count();

bool hasActiveCustomers = customers.Any(customer => customer.IsActive);

List<Customer> activeCustomers = customers
    .Where(customer => customer.IsActive)
    .ToList();

ToList, ToArray, ToDictionary, and ToHashSet are materialization methods. They execute the query and store the results in memory.

Materialization is useful when:

  • You need a stable snapshot of data.
  • You will enumerate results multiple times.
  • You want to close a database query before further in-memory processing.
  • You need collection-specific operations.

But materializing too early can hurt performance:

Code
// Bad for large database tables
var customers = await dbContext.Customers.ToListAsync();

var activeCustomers = customers
    .Where(customer => customer.IsActive)
    .Take(100)
    .ToList();

Better:

Code
var activeCustomers = await dbContext.Customers
    .Where(customer => customer.IsActive)
    .Take(100)
    .ToListAsync();

The better version allows the database to filter and limit the result.

Streaming and Non-Streaming Operators

Some deferred operators can stream results one at a time. Others must process more data before returning results.

Streaming operators include:

Code
var result = numbers
    .Where(number => number > 10)
    .Select(number => number * 2);

Where and Select can process each item as it is requested.

Non-streaming operators may need to inspect the full source before returning the first result. Examples include:

Code
var ordered = numbers.OrderBy(number => number);
var grouped = people.GroupBy(person => person.DepartmentId);

OrderBy needs to sort the input. GroupBy needs to build groups. These operations can require more memory and time.

This matters for large data sets because a query chain can look simple but still perform expensive work.

Filtering with Where

Where filters a sequence based on a predicate.

Code
var activeProducts = products
    .Where(product => product.IsActive);

Multiple Where calls can be composed:

Code
var query = products.AsEnumerable();

query = query.Where(product => product.IsActive);
query = query.Where(product => product.Price > 100);

For IQueryable<T>, multiple filters are typically combined into one database query by the provider.

Common mistake:

Code
var expensiveProducts = products
    .Where(product => product.Price > 100)
    .Where(product => product.Category.Name == "Electronics");

This is fine if products is in memory and all navigation properties are loaded. But with EF Core, navigation access and translation behavior must be considered. Developers should understand what gets translated and whether related data is needed.

Projection with Select

Select transforms each element into another shape.

Example:

Code
var customerNames = customers
    .Select(customer => customer.Name);

Projection into DTOs:

Code
var summaries = await dbContext.Customers
    .Where(customer => customer.IsActive)
    .Select(customer => new CustomerSummaryDto
    {
        Id = customer.Id,
        Name = customer.Name,
        Email = customer.Email
    })
    .ToListAsync();

Projection is especially important with EF Core because it can reduce data transfer. Instead of loading full entities, you can select only the fields needed by the API response.

Common mistake:

Code
var customers = await dbContext.Customers.ToListAsync();

var summaries = customers.Select(customer => new CustomerSummaryDto
{
    Id = customer.Id,
    Name = customer.Name
});

This loads full entities first, then maps in memory.

Better:

Code
var summaries = await dbContext.Customers
    .Select(customer => new CustomerSummaryDto
    {
        Id = customer.Id,
        Name = customer.Name
    })
    .ToListAsync();

Flattening with SelectMany

SelectMany flattens nested collections.

Example:

Code
var allOrderLines = orders
    .SelectMany(order => order.Lines);

Without SelectMany, Select returns a sequence of sequences:

Code
IEnumerable<IEnumerable<OrderLine>> linesByOrder = orders
    .Select(order => order.Lines);

With SelectMany, the result is a single sequence:

Code
IEnumerable<OrderLine> allLines = orders
    .SelectMany(order => order.Lines);

SelectMany is useful for:

  • Orders and order lines.
  • Customers and addresses.
  • Roles and permissions.
  • Parent-child collections.
  • Flattening nested DTOs.

Example with parent context:

Code
var lineSummaries = orders
    .SelectMany(
        order => order.Lines,
        (order, line) => new
        {
            OrderId = order.Id,
            ProductId = line.ProductId,
            Quantity = line.Quantity
        });

Sorting with OrderBy and ThenBy

OrderBy starts a sort operation. ThenBy adds secondary sorting.

Code
var sortedPeople = people
    .OrderBy(person => person.LastName)
    .ThenBy(person => person.FirstName);

Common mistake:

Code
var sortedPeople = people
    .OrderBy(person => person.LastName)
    .OrderBy(person => person.FirstName);

The second OrderBy starts a new primary sort and can override the previous ordering. Use ThenBy for secondary sorting.

Descending sort:

Code
var recentOrders = orders
    .OrderByDescending(order => order.CreatedAt)
    .ThenBy(order => order.Id);

For pagination, always use deterministic ordering:

Code
var page = await dbContext.Orders
    .OrderByDescending(order => order.CreatedAt)
    .ThenByDescending(order => order.Id)
    .Skip(pageIndex * pageSize)
    .Take(pageSize)
    .ToListAsync();

Adding a tie-breaker like Id helps avoid unstable pages when multiple records have the same date.

Grouping with GroupBy

GroupBy groups elements by a key.

Code
var ordersByStatus = orders
    .GroupBy(order => order.Status);

foreach (var group in ordersByStatus)
{
    Console.WriteLine(group.Key);

    foreach (var order in group)
    {
        Console.WriteLine(order.Id);
    }
}

Projection after grouping:

Code
var totalsByStatus = orders
    .GroupBy(order => order.Status)
    .Select(group => new
    {
        Status = group.Key,
        Count = group.Count(),
        Total = group.Sum(order => order.Total)
    });

With EF Core, grouping can be translated when the shape maps cleanly to SQL aggregation. Complex group projections may not translate or may require different query design.

Common usage in APIs:

Code
var report = await dbContext.Orders
    .Where(order => order.CreatedAt >= start && order.CreatedAt < end)
    .GroupBy(order => order.Status)
    .Select(group => new OrderStatusReportDto
    {
        Status = group.Key,
        Count = group.Count(),
        TotalAmount = group.Sum(order => order.TotalAmount)
    })
    .ToListAsync();

Joining Sequences

Join combines two sequences based on matching keys.

Code
var query = customers.Join(
    orders,
    customer => customer.Id,
    order => order.CustomerId,
    (customer, order) => new
    {
        CustomerName = customer.Name,
        OrderId = order.Id,
        order.Total
    });

Query syntax is often more readable for joins:

Code
var query =
    from customer in customers
    join order in orders
        on customer.Id equals order.CustomerId
    select new
    {
        CustomerName = customer.Name,
        OrderId = order.Id,
        order.Total
    };

Group join:

Code
var customersWithOrders =
    from customer in customers
    join order in orders
        on customer.Id equals order.CustomerId
        into customerOrders
    select new
    {
        Customer = customer,
        Orders = customerOrders
    };

Left join pattern:

Code
var query =
    from customer in customers
    join order in orders
        on customer.Id equals order.CustomerId
        into customerOrders
    from order in customerOrders.DefaultIfEmpty()
    select new
    {
        CustomerName = customer.Name,
        OrderId = order?.Id
    };

In EF Core, joins are often unnecessary if navigation properties are properly modeled. However, explicit joins are still useful for projections, reporting, and queries across non-navigation relationships.

Aggregation

Aggregation produces a single value from a sequence.

Examples:

Code
int count = orders.Count();

decimal total = orders.Sum(order => order.TotalAmount);

decimal average = orders.Average(order => order.TotalAmount);

decimal max = orders.Max(order => order.TotalAmount);

Use Any instead of Count() > 0 when you only need to know whether at least one element exists:

Code
bool hasOrders = orders.Any();

For EF Core, Any can translate to an efficient existence check, while Count counts matching rows.

Common mistake:

Code
if (orders.Count() > 0)
{
    // ...
}

Better:

Code
if (orders.Any())
{
    // ...
}

Aggregate is a general-purpose accumulator:

Code
var csv = names.Aggregate((current, next) => $"{current}, {next}");

For strings, prefer string.Join because it is clearer and more efficient:

Code
var csv = string.Join(", ", names);

Element Operators

Element operators return specific elements.

Common examples:

Code
var first = people.First();
var firstOrDefault = people.FirstOrDefault();

var single = people.Single();
var singleOrDefault = people.SingleOrDefault();

Important differences:

OperatorBehavior
FirstReturns first element; throws if sequence is empty
FirstOrDefaultReturns first element or default value if empty
SingleReturns only element; throws if zero or more than one
SingleOrDefaultReturns only element or default if empty; throws if more than one

Use Single when the business rule requires exactly one match. Use First when multiple matches are acceptable and you only need the first.

Example:

Code
var user = await dbContext.Users
    .SingleOrDefaultAsync(user => user.Email == email);

This communicates that email should be unique. If more than one record matches, an exception exposes a data integrity problem.

For high-traffic paths where exceptions are not desired, you may query with Take(2) and handle duplicates explicitly.

Set Operations

Set operations compare or combine sequences.

Code
var uniqueTags = tags.Distinct();

var allIds = internalIds.Union(externalIds);

var sharedIds = internalIds.Intersect(externalIds);

var missingIds = expectedIds.Except(actualIds);

For custom types, set operations need correct equality behavior. You can use records, override equality, or pass an equality comparer.

Example with custom comparer:

Code
var distinctCustomers = customers.DistinctBy(customer => customer.Email);

Common set-related operators include:

  • Distinct
  • DistinctBy
  • Union
  • UnionBy
  • Intersect
  • IntersectBy
  • Except
  • ExceptBy

These are useful for deduplication, comparing IDs, and merging data from different sources.

Partitioning and Pagination

Partitioning operators return part of a sequence.

Code
var firstTen = products.Take(10);

var nextTen = products.Skip(10).Take(10);

Pagination example:

Code
var page = await dbContext.Products
    .Where(product => product.IsActive)
    .OrderBy(product => product.Name)
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .Select(product => new ProductDto
    {
        Id = product.Id,
        Name = product.Name,
        Price = product.Price
    })
    .ToListAsync();

Best practices:

  • Always order before using Skip and Take.
  • Validate page number and page size.
  • Avoid very large offsets for high-scale systems because offset pagination can become expensive.
  • Consider keyset pagination for large data sets.

Keyset pagination example:

Code
var nextPage = await dbContext.Products
    .Where(product => product.Name.CompareTo(lastName) > 0)
    .OrderBy(product => product.Name)
    .Take(pageSize)
    .ToListAsync();

Keyset pagination uses a known last value instead of skipping many rows.

Any, All, and Contains

Quantifier operators answer yes/no questions.

Code
bool hasActiveUsers = users.Any(user => user.IsActive);

bool allUsersVerified = users.All(user => user.IsVerified);

bool containsId = selectedIds.Contains(user.Id);

Important detail:

Code
bool allVerified = users.All(user => user.IsVerified);

If users is empty, All returns true. This is mathematically correct but can surprise developers. If you require at least one item and all must match:

Code
bool valid = users.Any() && users.All(user => user.IsVerified);

With EF Core, Contains over a local list of primitive values is commonly translated into an SQL IN expression:

Code
var selectedUsers = await dbContext.Users
    .Where(user => selectedIds.Contains(user.Id))
    .ToListAsync();

For very large ID lists, consider database-specific limits and alternative designs such as temporary tables, table-valued parameters, or batch processing.

Null Handling in LINQ

LINQ queries often interact with nullable data.

Example:

Code
var names = people
    .Where(person => person.Name is not null)
    .Select(person => person.Name!);

Null-safe projection:

Code
var cities = customers
    .Select(customer => customer.Address?.City)
    .Where(city => city is not null);

Default values:

Code
var displayNames = users
    .Select(user => user.DisplayName ?? user.Email);

Be careful with FirstOrDefault because default can be null for reference types and 0 for integers.

Code
var firstNumber = numbers.FirstOrDefault();

If numbers is empty, firstNumber is 0, which may also be a valid number. In such cases, use nullable projection or check with Any.

Custom Types, Equality, and Comparers

LINQ operators such as Distinct, GroupBy, ToDictionary, Join, and Contains depend on equality.

For primitive types, equality usually works as expected.

For custom classes, default equality compares references unless equality is overridden.

Example:

Code
public sealed class Customer
{
    public string Email { get; init; } = "";
}

Two Customer objects with the same email are not equal by default if they are different instances.

Options:

Use a record:

Code
public sealed record CustomerKey(string Email);

Use key selector operators:

Code
var uniqueCustomers = customers.DistinctBy(customer => customer.Email);

Use a custom comparer:

Code
public sealed class CustomerEmailComparer : IEqualityComparer<Customer>
{
    public bool Equals(Customer? x, Customer? y)
    {
        return string.Equals(x?.Email, y?.Email, StringComparison.OrdinalIgnoreCase);
    }

    public int GetHashCode(Customer obj)
    {
        return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Email);
    }
}

Then:

Code
var uniqueCustomers = customers.Distinct(new CustomerEmailComparer());

ToDictionary, ToLookup, and GroupBy

ToDictionary creates a dictionary and requires unique keys.

Code
var customersById = customers.ToDictionary(customer => customer.Id);

If duplicate keys exist, ToDictionary throws an exception.

ToLookup creates a lookup where each key can have multiple values.

Code
var ordersByCustomerId = orders.ToLookup(order => order.CustomerId);

var ordersForCustomer = ordersByCustomerId[customerId];

GroupBy creates grouped sequences and is often used for reporting or aggregation.

Code
var groups = orders.GroupBy(order => order.CustomerId);

Comparison:

APIAllows Duplicate KeysTypical Use
ToDictionaryNoFast lookup by unique key
ToLookupYesFast lookup where one key has many values
GroupByYesGrouping and aggregation pipeline

LINQ and Entity Framework Core

When LINQ is used with EF Core, the query is translated to database-specific SQL where possible.

Example:

Code
var products = await dbContext.Products
    .Where(product => product.IsActive && product.Price > 100)
    .OrderBy(product => product.Name)
    .Select(product => new ProductDto
    {
        Id = product.Id,
        Name = product.Name,
        Price = product.Price
    })
    .ToListAsync();

This should execute filtering, ordering, and projection in the database.

Important EF Core LINQ principles:

  • Keep queryable operations as IQueryable<T> until the final materialization step.
  • Use ToListAsync, FirstOrDefaultAsync, SingleOrDefaultAsync, and other async operators for database queries.
  • Project only the columns needed for API responses.
  • Avoid calling custom C# methods inside filters because they may not translate to SQL.
  • Be careful with AsEnumerable; it switches from provider-based querying to in-memory LINQ.
  • Use AsNoTracking for read-only entity queries when change tracking is not needed.
  • Avoid N+1 query patterns.
  • Understand what runs on the server and what runs in memory.

Example of a bad query for EF Core:

Code
var products = await dbContext.Products
    .Where(product => IsExpensive(product.Price))
    .ToListAsync();

static bool IsExpensive(decimal price) => price > 100;

The custom method may not translate to SQL.

Better:

Code
var products = await dbContext.Products
    .Where(product => product.Price > 100)
    .ToListAsync();

Client Evaluation and Server Evaluation

Server evaluation means the database performs the filtering, sorting, grouping, or aggregation. Client evaluation means the application performs it after data is loaded.

Server-side filtering:

Code
var activeUsers = await dbContext.Users
    .Where(user => user.IsActive)
    .ToListAsync();

Client-side filtering after materialization:

Code
var users = await dbContext.Users.ToListAsync();

var activeUsers = users
    .Where(user => user.IsActive)
    .ToList();

The second version loads all users into memory first.

AsEnumerable can intentionally switch to client-side LINQ:

Code
var results = dbContext.Users
    .Where(user => user.IsActive)
    .AsEnumerable()
    .Where(user => CustomInMemoryRule(user))
    .ToList();

This can be valid when:

  • The database-side query already reduces the data to a small set.
  • The remaining rule cannot be translated to SQL.
  • The performance trade-off is acceptable.

But it should be used deliberately and not accidentally.

Multiple Enumeration

Multiple enumeration happens when the same deferred query is executed more than once.

Example:

Code
var activeUsers = users.Where(user => user.IsActive);

int count = activeUsers.Count();

foreach (var user in activeUsers)
{
    Console.WriteLine(user.Name);
}

If users is a database query, this can trigger multiple database queries. If users is an expensive iterator, it can repeat expensive work.

If the results are needed multiple times, materialize once:

Code
var activeUsers = users
    .Where(user => user.IsActive)
    .ToList();

int count = activeUsers.Count;

foreach (var user in activeUsers)
{
    Console.WriteLine(user.Name);
}

Be careful not to materialize too early. Materialization is useful when you need a snapshot or repeated access, but harmful when it prevents efficient query translation.

LINQ Performance Considerations

LINQ improves readability, but developers should understand performance.

Common performance concerns:

  • Repeated enumeration of deferred queries.
  • Materializing large data sets with ToList.
  • Filtering after materialization instead of before.
  • Using Count() > 0 instead of Any().
  • Calling OrderBy more than needed.
  • Using nested LINQ queries that cause O(n²) behavior.
  • Using GroupBy or ToDictionary without considering memory usage.
  • Accidentally switching from IQueryable<T> to IEnumerable<T>.
  • Using custom methods in EF Core query filters.
  • Selecting full entities when a DTO projection would be enough.

Example of inefficient nested lookup:

Code
var results = customers.Select(customer => new
{
    Customer = customer,
    Orders = orders.Where(order => order.CustomerId == customer.Id).ToList()
});

If both collections are in memory, this may scan orders for every customer.

Better:

Code
var ordersByCustomerId = orders.ToLookup(order => order.CustomerId);

var results = customers.Select(customer => new
{
    Customer = customer,
    Orders = ordersByCustomerId[customer.Id].ToList()
});

This builds a lookup once and avoids repeated scanning.

LINQ Readability and Maintainability

LINQ is best when it makes intent clear.

Readable:

Code
var overdueInvoices = invoices
    .Where(invoice => invoice.Status == InvoiceStatus.Open)
    .Where(invoice => invoice.DueDate < today)
    .OrderBy(invoice => invoice.DueDate)
    .Select(invoice => new OverdueInvoiceDto
    {
        Id = invoice.Id,
        CustomerName = invoice.Customer.Name,
        DueDate = invoice.DueDate,
        Amount = invoice.Amount
    })
    .ToList();

Hard to read:

Code
var result = invoices.Where(x => x.Status == InvoiceStatus.Open && x.DueDate < today)
    .OrderBy(x => x.DueDate)
    .Select(x => new OverdueInvoiceDto { Id = x.Id, CustomerName = x.Customer.Name, DueDate = x.DueDate, Amount = x.Amount })
    .ToList();

Best practices:

  • Use meaningful lambda parameter names in complex queries.
  • Break long queries into steps when it improves clarity.
  • Avoid mixing heavy business logic inside LINQ expressions.
  • Prefer explicit DTO projection for API responses.
  • Keep database queries translatable.
  • Use method syntax and query syntax intentionally.
  • Add tests for complex query behavior.

Common Mistakes

Common LINQ mistakes include:

Assuming the Query Runs Immediately

Code
var query = users.Where(user => user.IsActive);

This defines a query. It does not necessarily execute it.

Materializing Too Early

Code
var users = await dbContext.Users.ToListAsync();

var active = users.Where(user => user.IsActive);

This loads all rows before filtering.

Using OrderBy Twice Instead of ThenBy

Code
var sorted = users
    .OrderBy(user => user.LastName)
    .OrderBy(user => user.FirstName);

Use:

Code
var sorted = users
    .OrderBy(user => user.LastName)
    .ThenBy(user => user.FirstName);

Using Count() > 0

Code
if (orders.Count() > 0)
{
}

Use:

Code
if (orders.Any())
{
}

Ignoring First, Single, and Default Behavior

Code
var user = users.First(user => user.Email == email);

This throws if there is no match. Use FirstOrDefault or SingleOrDefault depending on the business rule.

Accidentally Running Client-Side Logic for Database Queries

Code
var users = dbContext.Users
    .AsEnumerable()
    .Where(user => ExpensiveCustomCheck(user))
    .ToList();

This might load far more data than expected.

Reusing Deferred Queries Without Understanding Re-Execution

Code
var query = dbContext.Users.Where(user => user.IsActive);

var count = await query.CountAsync();
var list = await query.ToListAsync();

This sends two database queries. That may be acceptable, but it should be intentional.

Interview Practice

PreviousIEnumerable vs IQueryable in C#Next UpObserver-Style Communication in C#