Why filtering matters
Filtering turns big data sets into the exact rows you need. In C#, the easiest way to filter is LINQ. For apps backed by a database (EF Core), the difference between filtering in-memory vs. in the database can make or break performance.
This post shows practical patterns you can paste into your code today: simple filters, optional filters, dynamic filters with expressions, case-insensitive search, date/number ranges, nested collections, and EF Core-friendly approaches.
IEnumerable vs IQueryable (know your target)
- IEnumerable<T>: in-memory LINQ to Objects. All operations happen in your process.
- IQueryable<T>: query provider (e.g., EF Core) translates your LINQ into SQL. Keep filters as IQueryable as long as possible to let the database do the heavy lifting.
Rule of thumb: Apply Where/Select/OrderBy before calling ToList/AsEnumerable.
Sample model
public sealed class Product
{
public int Id { get; set; }
public string? Name { get; set; }
public decimal Price { get; set; }
public bool InStock { get; set; }
public string Category { get; set; } = "";
public DateTime CreatedUtc { get; set; }
}
Basics: simple filters with LINQ
var products = new List<Product>
{
new() { Id = 1, Name = "Keyboard", Price = 49.99m, InStock = true, Category = "Peripherals" },
new() { Id = 2, Name = "Monitor", Price = 199.99m, InStock = false, Category = "Displays" },
new() { Id = 3, Name = "Mouse", Price = 24.99m, InStock = true, Category = "Peripherals" },
};
// Price and stock
var midPricedInStock = products
.Where(p => p.Price >= 25m && p.Price <= 150m && p.InStock)
.ToList();
// Membership
var allowedCategories = new[] { "Peripherals", "Laptops" };
var inAllowedCategories = products
.Where(p => allowedCategories.Contains(p.Category))
.ToList();
// Any/All on nested collections
public sealed class Order { public int Id { get; set; } public List<Product> Items { get; set; } = new(); }
var orders = new List<Order> { new() { Id = 10, Items = products } };
var ordersWithExpensiveItems = orders.Where(o => o.Items.Any(i => i.Price > 100m));
Null-safe and case-insensitive text filtering
- In-memory (LINQ to Objects):
string? keyword = "key";
var match = products.Where(p =>
p.Name is not null &&
p.Name.Contains(keyword!, StringComparison.OrdinalIgnoreCase));
// Alternative (works on older targets):
var match2 = products.Where(p =>
p.Name is not null &&
p.Name.IndexOf(keyword!, StringComparison.OrdinalIgnoreCase) >= 0);
- EF Core (server-side): avoid ToLower/ToUpper when you care about indexes.
- SQL Server: default collation is often case-insensitive already.
- PostgreSQL (Npgsql): use ILike for case-insensitive.
// SQL Server or provider-agnostic pattern-dependent on collation
var q1 = context.Products.Where(p => EF.Functions.Like(p.Name!, $"%{keyword}%"));
// SQL Server force CI collation if needed
var q2 = context.Products.Where(p => EF.Functions.Like(
EF.Functions.Collate(p.Name!, "SQL_Latin1_General_CP1_CI_AS"), $"%{keyword}%"));
// PostgreSQL (Npgsql)
var q3 = context.Products.Where(p => EF.Functions.ILike(p.Name!, $"%{keyword}%"));
Date and numeric ranges
DateTime from = DateTime.UtcNow.AddDays(-30);
DateTime to = DateTime.UtcNow;
var recent = context.Products.Where(p => p.CreatedUtc >= from && p.CreatedUtc < to);
// Inclusive bounds helper
bool InRange(decimal value, decimal? min, decimal? max) =>
(!min.HasValue || value >= min) && (!max.HasValue || value <= max);
var inMemoryRange = products.Where(p => InRange(p.Price, 10m, 100m));
Optional filters (apply only when present)
public sealed class ProductFilter
{
public string? Query { get; set; }
public decimal? MinPrice { get; set; }
public decimal? MaxPrice { get; set; }
public bool? InStock { get; set; }
public string? Category { get; set; }
}
IQueryable<Product> Apply(IQueryable<Product> q, ProductFilter f)
{
if (!string.IsNullOrWhiteSpace(f.Query))
q = q.Where(p => EF.Functions.Like(p.Name!, $"%{f.Query}%"));
if (f.MinPrice.HasValue)
q = q.Where(p => p.Price >= f.MinPrice.Value);
if (f.MaxPrice.HasValue)
q = q.Where(p => p.Price <= f.MaxPrice.Value);
if (f.InStock.HasValue)
q = q.Where(p => p.InStock == f.InStock.Value);
if (!string.IsNullOrWhiteSpace(f.Category))
q = q.Where(p => p.Category == f.Category);
return q;
}
Composing dynamic predicates (EF Core-friendly)
When you need to build a single Expression<Func<T,bool>> predicate from parts (to pass once into Where, Count, etc.), avoid Expression.Invoke which EF often cannot translate. Use parameter replacement instead.
using System.Linq.Expressions;
public static class PredicateBuilder
{
private sealed class ReplaceVisitor : ExpressionVisitor
{
private readonly ParameterExpression _from;
private readonly ParameterExpression _to;
public ReplaceVisitor(ParameterExpression from, ParameterExpression to)
{ _from = from; _to = to; }
protected override Expression VisitParameter(ParameterExpression node)
=> node == _from ? _to : base.VisitParameter(node);
}
public static Expression<Func<T, bool>> True<T>() => x => true;
public static Expression<Func<T, bool>> False<T>() => x => false;
public static Expression<Func<T, bool>> AndAlso<T>(
this Expression<Func<T, bool>> left,
Expression<Func<T, bool>> right)
{
var param = Expression.Parameter(typeof(T), "x");
var leftBody = new ReplaceVisitor(left.Parameters[0], param).Visit(left.Body)!;
var rightBody = new ReplaceVisitor(right.Parameters[0], param).Visit(right.Body)!;
return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(leftBody, rightBody), param);
}
public static Expression<Func<T, bool>> OrElse<T>(
this Expression<Func<T, bool>> left,
Expression<Func<T, bool>> right)
{
var param = Expression.Parameter(typeof(T), "x");
var leftBody = new ReplaceVisitor(left.Parameters[0], param).Visit(left.Body)!;
var rightBody = new ReplaceVisitor(right.Parameters[0], param).Visit(right.Body)!;
return Expression.Lambda<Func<T, bool>>(Expression.OrElse(leftBody, rightBody), param);
}
}
Use it to assemble optional filters into one expression:
Expression<Func<Product, bool>> predicate = PredicateBuilder.True<Product>();
if (!string.IsNullOrWhiteSpace(filter.Query))
predicate = predicate.AndAlso(p => EF.Functions.Like(p.Name!, $"%{filter.Query}%"));
if (filter.MinPrice.HasValue)
predicate = predicate.AndAlso(p => p.Price >= filter.MinPrice.Value);
if (filter.MaxPrice.HasValue)
predicate = predicate.AndAlso(p => p.Price <= filter.MaxPrice.Value);
if (filter.InStock.HasValue)
predicate = predicate.AndAlso(p => p.InStock == filter.InStock.Value);
var page = 1; var pageSize = 20;
var query = context.Products.AsNoTracking().Where(predicate);
var total = await query.CountAsync();
var items = await query
.OrderBy(p => p.Price)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new { p.Id, p.Name, p.Price })
.ToListAsync();
Filtering nested collections
// Orders that ONLY contain in-stock items
var strict = context.Set<Order>().Where(o => o.Items.All(i => i.InStock));
// Orders that contain at least one product in a category
var anyCat = context.Set<Order>().Where(o => o.Items.Any(i => i.Category == "Peripherals"));
// Flatten to filter over all items
var items = context.Set<Order>().SelectMany(o => o.Items).Where(i => i.Price > 100m);
End-to-end example: API endpoint with dynamic filters + pagination
// Minimal API style (Program.cs)
app.MapGet("/products", async (
[AsParameters] ProductFilter filter,
int page = 1,
int pageSize = 20,
MyDbContext context) =>
{
// Compose once
Expression<Func<Product, bool>> predicate = PredicateBuilder.True<Product>();
if (!string.IsNullOrWhiteSpace(filter.Query))
predicate = predicate.AndAlso(p => EF.Functions.Like(p.Name!, $"%{filter.Query}%"));
if (filter.MinPrice.HasValue)
predicate = predicate.AndAlso(p => p.Price >= filter.MinPrice.Value);
if (filter.MaxPrice.HasValue)
predicate = predicate.AndAlso(p => p.Price <= filter.MaxPrice.Value);
if (filter.InStock.HasValue)
predicate = predicate.AndAlso(p => p.InStock == filter.InStock.Value);
if (!string.IsNullOrWhiteSpace(filter.Category))
predicate = predicate.AndAlso(p => p.Category == filter.Category);
var baseQuery = context.Products.AsNoTracking().Where(predicate);
var total = await baseQuery.CountAsync();
var data = await baseQuery
.OrderByDescending(p => p.CreatedUtc)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProductDto(p.Id, p.Name!, p.Price, p.InStock))
.ToListAsync();
return Results.Ok(new { total, page, pageSize, data });
});
public sealed record ProductDto(int Id, string Name, decimal Price, bool InStock);
Performance checklist for filtering
- Keep queries as IQueryable to push work to the database.
- Apply Where before Select when both are present; project only needed columns.
- Use AsNoTracking for read-only lists.
- Avoid ToLower/ToUpper on columns in the predicate; prefer collations or provider functions (Like/ILike).
- When filtering by a list of IDs, using a local List/array in Contains translates to SQL IN. Example:
var ids = request.Ids.ToList();
var q = context.Products.Where(p => ids.Contains(p.Id)); // becomes IN (...)
- Index columns you filter on frequently (Price, Category, CreatedUtc, etc.).
- Paginate with Skip/Take; fetch total count with the same predicate.
- Beware client-eval: don't call methods in predicates that EF can't translate.
- Consider compiled queries for hot paths in EF Core (EF.CompileQuery / EF.CompileAsyncQuery).
Common pitfalls
- Calling ToList() too early moves filtering to memory and loads unnecessary rows.
- Using Regex or custom C# methods inside an EF Core Where — not translatable; either move to in-memory after narrowing or use SQL functions.
- Time zones: store in UTC; filter in UTC, convert at the edge.
- Nulls in string filters: guard with p.Name != null or coalesce: (p.Name ?? "")
Takeaways
- Prefer building composable predicates (optional filters) and keep them IQueryable.
- Use provider functions for text search and let indexes work.
- Test filters with both small in-memory sets and integration tests against your DB provider.
Copy the PredicateBuilder and optional filter patterns into your project and you'll have flexible, high-performance filtering with clean code.