Entity Framework Core Performance: Queries Optimaliseren zonder Compromissen

EF Core kan traag zijn, maar dat hoeft niet. Van N+1 queries tot tracking overhead — concrete optimalisaties die het verschil maken in productie.

Jean-Pierre Broeders

Freelance DevOps Engineer

5 april 20268 min. leestijd
Entity Framework Core Performance: Queries Optimaliseren zonder Compromissen

Entity Framework Core Performance: Queries Optimaliseren zonder Compromissen

EF Core maakt database toegang eenvoudig. Tegelijk is het ook makkelijk om per ongeluk trage queries te schrijven. Wat lokaal met een handvol records prima draait, crasht in productie met een miljoen rijen. Change tracking dat onnodig geheugen opeet. Queries die per item in een lijst opnieuw de database raken.

Dit gaat over de optimalisaties die ertoe doen — niet de micro-optimalisaties die meetbaar zijn maar verwaarloosbaar, maar de aanpassingen die een API van 800ms naar 40ms brengen.

Het N+1 Probleem: De Klassieke Valkuil

Een query haalt een lijst op. Voor elk item in die lijst wordt een aparte query uitgevoerd om gerelateerde data te halen. Bij 100 items: 101 database roundtrips.

// ❌ N+1 query: 1 query voor orders + N queries voor customers
var orders = await _db.Orders.Take(100).ToListAsync();
foreach (var order in orders)
{
    // Dit triggert een aparte query per order
    Console.WriteLine($"{order.Id}: {order.Customer.Name}");
}

EF Core loggt dit gewoon zonder waarschuwing. De database ziet honderden identieke queries voorbij komen, elk met een andere parameter. Connection pooling helpt, maar het blijft overhead.

De fix: eager loading met Include:

// ✅ 1 query via JOIN
var orders = await _db.Orders
    .Include(o => o.Customer)
    .Take(100)
    .ToListAsync();

foreach (var order in orders)
{
    Console.WriteLine($"{order.Id}: {order.Customer.Name}");
}

Een enkele query met een JOIN haalt alles in één keer op. Het verschil is dramatisch bij datasets van enige omvang.

Projection: Alleen Halen Wat Nodig Is

Change tracking is handig voor updates, maar bij read-only scenario's is het pure overhead. EF Core houdt bij welke properties veranderd zijn, zelfs als er geen .SaveChanges() volgt.

Een typisch voorbeeld: een API endpoint die een lijst teruggeeft.

// ❌ Volledige entities met tracking
var products = await _db.Products
    .Include(p => p.Category)
    .ToListAsync();

return products.Select(p => new ProductDto
{
    Id = p.Id,
    Name = p.Name,
    CategoryName = p.Category.Name
});

Dit haalt alle kolommen op, creëert volledige entity objecten, enable change tracking, en mapt daarna alsnog handmatig naar een DTO. Drie onnodige stappen.

Beter: projecteren naar het gewenste type in de query zelf.

// ✅ Alleen de benodigde kolommen, geen tracking
var products = await _db.Products
    .Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name,
        CategoryName = p.Category.Name
    })
    .ToListAsync();

return products;

EF Core vertaalt dit naar een SQL query die precies die drie kolommen ophaalt. Geen overhead, geen onnodige data, geen tracking.

Bij grote datasets scheelt dit honderden megabytes geheugen en seconden querytijd.

AsNoTracking: Expliciet Tracking Uitschakelen

Als projection niet mogelijk is — bijvoorbeeld bij complexere objectgrafen die toch als entities terug moeten — helpt AsNoTracking():

var orders = await _db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
    .AsNoTracking()
    .ToListAsync();

Change tracking blijft dan uit. Entities zijn read-only, maar de objectstructuur blijft intact. Scheelt significant geheugen bij grote resultsets.

Vanaf EF Core 6 kan tracking ook globaal uitgezet worden:

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});

Dan is tracking standaard uit en moet het expliciet aangezet worden waar nodig. Handig voor read-heavy applicaties.

Compiled Queries: Eenmalige Query Parsing

Bij veelgebruikte queries betaalt EF Core elke keer parsing en expression tree compilation. Compiled queries cachen dat:

private static readonly Func<AppDbContext, int, Task<Product?>> _getProductById =
    EF.CompileAsyncQuery((AppDbContext db, int id) =>
        db.Products.FirstOrDefault(p => p.Id == id));

public async Task<Product?> GetProductAsync(int id)
{
    return await _getProductById(_db, id);
}

Het scheelt een paar microseconden per call. Bij duizenden requests per seconde telt dat op. Voor queries die maar zelden draaien is het overkill.

SplitQuery: Grote Joins Opsplitsen

Bij meerdere Include statements genereert EF Core standaard één grote JOIN query. Dat werkt uitstekend voor eenvoudige relaties, maar bij one-to-many collecties ontstaat een cartesian product explosion.

Voorbeeld: een order met 10 items en 5 shipments. De JOIN resulteert in 50 rijen die EF Core moet samenvoegen tot één object.

// ❌ Cartesian explosion bij grote collecties
var order = await _db.Orders
    .Include(o => o.Items)
    .Include(o => o.Shipments)
    .FirstAsync(o => o.Id == orderId);

Met AsSplitQuery() worden aparte queries uitgevoerd:

// ✅ 3 aparte queries: orders, items, shipments
var order = await _db.Orders
    .Include(o => o.Items)
    .Include(o => o.Shipments)
    .AsSplitQuery()
    .FirstAsync(o => o.Id == orderId);

Meer roundtrips, maar elke query is veel kleiner. Bij grote collecties is dit netto sneller.

Batch Updates en Deletes

Tot voor kort vereiste het verwijderen van 1000 rijen eerst een query om ze op te halen, daarna een loop met Remove(), en dan SaveChanges() die 1000 DELETE statements uitvoert.

EF Core 7 introduceert bulk operaties:

// ❌ Oud: haal alles op en delete één voor één
var oldOrders = await _db.Orders
    .Where(o => o.CreatedAt < DateTime.UtcNow.AddYears(-1))
    .ToListAsync();
_db.Orders.RemoveRange(oldOrders);
await _db.SaveChangesAsync();

// ✅ Nieuw: single DELETE statement
await _db.Orders
    .Where(o => o.CreatedAt < DateTime.UtcNow.AddYears(-1))
    .ExecuteDeleteAsync();

Hetzelfde geldt voor updates:

await _db.Products
    .Where(p => p.Stock == 0)
    .ExecuteUpdateAsync(p => p.SetProperty(x => x.IsAvailable, false));

Vertaalt naar één UPDATE statement. Geen entities laden, geen change tracking, direct naar de database.

Index Hints en Raw SQL Wanneer Nodig

EF Core genereert goede SQL, maar soms weet de query planner het beter. Index hints of specifieke query constructies die EF Core niet ondersteunt, kunnen handmatig:

var products = await _db.Products
    .FromSqlRaw(@"
        SELECT * FROM Products WITH (INDEX(IX_Products_Category))
        WHERE CategoryId = {0}
    ", categoryId)
    .ToListAsync();

Of een stored procedure aanroepen:

var stats = await _db.OrderStats
    .FromSqlRaw("EXEC GetOrderStatistics @StartDate, @EndDate",
        new SqlParameter("@StartDate", startDate),
        new SqlParameter("@EndDate", endDate))
    .ToListAsync();

Blijft type-safe en profiteert alsnog van EF Core's mapping, maar met volledige controle over de SQL.

Query Filters voor Soft Deletes

Applicaties met soft deletes — een IsDeleted flag in plaats van echte verwijderingen — moeten overal Where(x => !x.IsDeleted) toevoegen. Vergeet je dat één keer, dan verschijnen verwijderde records plots in productie.

Global query filters voorkomen dat:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
        .HasQueryFilter(p => !p.IsDeleted);
}

Elke query op Products krijgt automatisch die filter. Wil je toch deleted items zien:

var allProducts = await _db.Products
    .IgnoreQueryFilters()
    .ToListAsync();

Werkt ook voor multi-tenancy: filter op TenantId en elke query is automatisch scoped.

OptimalisatieImpactWanneer gebruiken
Include/Eager LoadingHoogBij elke N+1 situatie
Projection (Select)HoogRead-only queries, API endpoints
AsNoTrackingGemiddeldGrote resultsets, geen updates
Compiled QueriesLaagHot paths, zeer frequente queries
SplitQuerySituationeelMeerdere grote collecties
ExecuteUpdate/DeleteHoogBulk operaties

Monitoring en Diagnostics

Zonder meten is optimaliseren gissen. EF Core heeft ingebouwde logging:

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    options.LogTo(Console.WriteLine, LogLevel.Information);
    options.EnableSensitiveDataLogging(); // Alleen in development
    options.EnableDetailedErrors();
});

Dat toont elke uitgevoerde query. In productie is dat te veel, maar voor troubleshooting goud waard.

Voor continue monitoring: MiniProfiler integreert naadloos met EF Core en toont query timings per request.

dotnet add package MiniProfiler.AspNetCore
dotnet add package MiniProfiler.EntityFrameworkCore

Registratie in Program.cs:

builder.Services.AddMiniProfiler(options =>
{
    options.RouteBasePath = "/profiler";
}).AddEntityFramework();

Navigeer naar /profiler/results en zie exact welke queries traag zijn, inclusief call stacks.

Conclusie

EF Core performance draait om bewust zijn van wat er gebeurt. Elke .Include() is een JOIN. Elk entity object dat uit een query komt heeft tracking overhead tenzij anders aangegeven. En lazy loading — standaard uit sinds EF Core 3.0 — kan alsnog aangezet worden, maar dan begint het N+1 feest opnieuw.

De tools bestaan. Projection, compiled queries, bulk updates, query filters. Het is een kwestie van ze toe te passen waar ze nodig zijn. Begin met logging aanzetten, kijk naar de gegenereerde SQL, en optimaliseer de queries die ertoe doen. Niet alles hoeft perfect — wel de hot paths waar duizenden requests doorheen gaan.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op