Learnixo
Back to blog
AI Systemsintermediate

LINQ Performance — Writing Queries That Scale

LINQ performance patterns: avoiding N+1, efficient pagination, AsNoTracking, compiled queries, chunking, parallel LINQ, and the profiling approach that finds query bottlenecks before production.

Asma Hafeez KhanMay 16, 20265 min read
LINQPerformanceC#.NETEF CoreOptimization
Share:𝕏

The Performance Mental Model

LINQ over IQueryable (EF Core) runs in the database. Every operator you add becomes SQL. The goal is to do as much as possible in SQL and return as little data as possible to .NET.

Performance hierarchy (best to worst):
  1. SQL aggregate  → COUNT(*) returns 1 row
  2. SQL projection → SELECT Id, Name returns 2 columns
  3. SQL filter     → WHERE IsActive = 1 returns a subset
  4. Full entity    → SELECT * returns all columns
  5. In-memory filter after ToList() → loads everything, filters in .NET
  6. N+1 queries    → 1 + N round trips to the database

AsNoTracking for Read-Only Queries

C#
// With tracking (default): EF Core tracks every entity in memory
// Use for: create, update, delete operations
var patient = await db.Patients.FindAsync(id);
patient.Name = "Updated";
await db.SaveChangesAsync();

// Without tracking: EF Core skips change tracking overhead
// Use for: read-only queries, lists, projections
var patients = await db.Patients
    .AsNoTracking()
    .Where(p => p.IsActive)
    .ToListAsync();

// QueryTrackingBehavior: set globally for read-heavy contexts
builder.Services.AddDbContext<ReadOnlyContext>(options =>
    options.UseSqlServer(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

Production issue I've seen: A clinical dashboard loaded 5,000 patient summaries with tracking enabled. EF Core maintained change-tracking entries for each entity — holding 5,000 entity snapshots in memory per request. Under concurrent load, this caused 2GB memory spikes. Adding AsNoTracking() to all read-only queries halved memory usage immediately.


Efficient Pagination with Skip/Take

C#
// Always combine Skip/Take with a deterministic OrderBy
// Without OrderBy, SQL Server can return rows in any order between pages
var page = await db.Patients
    .AsNoTracking()
    .OrderBy(p => p.Id)  // deterministic order
    .Skip(pageNumber * pageSize)
    .Take(pageSize)
    .Select(p => new PatientSummaryDto(p.Id, p.FullName, p.MRN))
    .ToListAsync();

// Keyset pagination (better performance for deep pages)
// "Give me 20 patients after ID = lastSeenId"
var keysetPage = await db.Patients
    .AsNoTracking()
    .Where(p => p.Id > lastSeenId)
    .OrderBy(p => p.Id)
    .Take(pageSize)
    .ToListAsync();

Avoiding N+1 Queries

C#
// N+1: 1 query for patients, then 1 per patient for prescriptions
var patients = await db.Patients.ToListAsync();
foreach (var p in patients)
{
    var rxCount = p.Prescriptions.Count;  // lazy load — 1 query per patient
    Console.WriteLine($"{p.Name}: {rxCount} prescriptions");
}
// 1 + N queries total

// Fix 1: eager loading with Include
var patients = await db.Patients
    .Include(p => p.Prescriptions)  // JOIN — 1 query total
    .ToListAsync();

// Fix 2: projection (load only what you need — no Include overhead)
var summary = await db.Patients
    .Select(p => new
    {
        p.FullName,
        RxCount = p.Prescriptions.Count()  // SQL COUNT() subquery
    })
    .ToListAsync();
// 1 query, returns only Name + Count — no Prescription data loaded

Compiled Queries

C#
// EF Core re-parses the expression tree on every call
// Compiled queries cache the parsed plan
private static readonly Func<AppDbContext, string, Task<Patient?>> FindByMRN =
    EF.CompileAsyncQuery(
        (AppDbContext db, string mrn) =>
            db.Patients
                .AsNoTracking()
                .FirstOrDefault(p => p.MRN == mrn && p.IsActive));

// Usage — zero expression compilation overhead
var patient = await FindByMRN(db, "MRN-001");

Projecting vs Loading Full Entities

C#
// Expensive: loads all 40+ columns including large text fields
var patients = await db.Patients.ToListAsync();

// Efficient: loads only 4 columns
var summaries = await db.Patients
    .Select(p => new PatientSummaryDto(p.Id, p.FullName, p.MRN, p.Department))
    .ToListAsync();

// Rule of thumb:
// If you only read properties (no write-back), project to DTO
// If you need to track and save, load the entity

Parallel LINQ (PLINQ) for CPU-Bound In-Memory

C#
// PLINQ: parallel processing for CPU-intensive in-memory operations
// DO NOT use PLINQ with EF Core — DbContext is not thread-safe

// Appropriate use: CPU-bound processing on already-loaded data
var drugInteractions = prescriptions
    .AsParallel()
    .WithDegreeOfParallelism(4)
    .Where(rx => CheckInteraction(rx, allDrugs))  // CPU-intensive check
    .ToList();

// DO NOT:
// db.Patients.AsParallel().Where(...)  // wrong — DbContext not thread-safe

Batching Large Operations

C#
// Processing 100,000 records in one pass risks timeouts and memory pressure
// Process in chunks
var offset = 0;
const int batchSize = 500;

while (true)
{
    var batch = await db.DrugOrders
        .AsNoTracking()
        .Where(o => !o.IsProcessed)
        .OrderBy(o => o.Id)
        .Skip(offset)
        .Take(batchSize)
        .ToListAsync();

    if (batch.Count == 0) break;

    foreach (var order in batch)
        await ProcessOrderAsync(order);

    offset += batch.Count;
}

// Or use ExecuteDeleteAsync / ExecuteUpdateAsync for bulk operations
await db.DrugOrders
    .Where(o => o.IsCompleted && o.CompletedAt < DateTime.UtcNow.AddYears(-2))
    .ExecuteDeleteAsync();  // single SQL DELETE — no entity loading

Measuring Query Performance

C#
// Development: log slow queries
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .LogTo(
               output: Console.WriteLine,
               minimumLevel: LogLevel.Information,
               options: DbContextLoggerOptions.SingleLine)
           .EnableDetailedErrors()
           .EnableSensitiveDataLogging());  // dev only

// Custom slow query logging
options.ConfigureWarnings(w =>
    w.Log((RelationalEventId.CommandExecuted, LogLevel.Warning)));

Red Flag / Green Answer

Red Flag: "Our patient list endpoint takes 4 seconds. We added Skip/Take but it is still slow."

Skip(500).Take(20) still scans 520 rows. Without an index on the sort column, it is a full table scan. Add an index on the column used in OrderBy. For very deep pages (offset > 10,000), switch to keyset pagination.

Green Answer:

Index on OrderBy column. For deep pages, keyset pagination: WHERE Id > lastSeenId ORDER BY Id TAKE 20. Profile with ToQueryString() and verify the execution plan uses the index.


Key Takeaway

LINQ performance is about minimizing data movement: filter in SQL (not in memory), project to DTOs (not full entities), use AsNoTracking() for reads, paginate with a deterministic OrderBy. Measure first — use EF Core's SQL logging or MiniProfiler to see what queries run. The most common culprits are N+1 (use Include or projection), full-entity loads for read-only operations (use projection), and missing indexes on OrderBy/Where columns.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.