We hebben de vijf meest gemaakte EF Core-fouten gemeten: wat elke fout echt kost
N+1, hele entiteiten, tracking, queries in lussen, filteren in het geheugen: elke EF Core 10-fout gemeten tegen de oplossing. Tot 9x trager, 700x meer geheugen.
Vlado Pandžić · Oprichter · Senior .NET-architect
Gepubliceerd · 8 min lezen
Inhoud
- Hoe we hebben gemeten
- De resultaten
- 1. Filteren na ToList(): 9× trager, 700× zoveel geheugen
- 2. Hele entiteiten voor een lijst: 2,4× trager, 12× zoveel geheugen
- 3. N+1: hier 1,9× trager, over een netwerk veel erger
- 4. Een query in een lus: 1,8× trager, en hetzelfde netwerkprobleem
- 5. Tracking op een lijst die niemand wijzigt: 1,5× trager
- Waarom de cijfers een ondergrens zijn
- Hoe u ze vindt in uw applicatie
- Vragen die we vaak horen
- Hoe wij werken
Elke .NET-ontwikkelaar kent het advies: vermijd N+1, gebruik AsNoTracking, laad geen hele entiteiten. Wat meestal ontbreekt, is het getal. Hoeveel trager is de fout nu echt? Genoeg om er iets aan te doen, of iets voor ooit?
Dus hebben we het gemeten. Vijf fouten die we steeds weer tegenkomen in EF Core-applicaties, elk naast de oplossing, op dezelfde data en met de standaardtool voor benchmarks.
Het korte antwoord: de ergste, filteren na ToList(), was 9 keer trager en gebruikte 700 keer zoveel geheugen. De mildste, tracking op een lijst die alleen gelezen wordt, kostte nog steeds 46 % meer tijd. En de twee fouten die het onschuldigst lijken, N+1 en queries in een lus, worden op een echte databaseserver veel erger dan onze cijfers laten zien.
Hoe we hebben gemeten
- EF Core 10 op .NET 10, met SQLite als databasebestand op schijf.
- De data: 1.000 klanten, 10.000 orders met een notitieveld van 2 KB en 50.000 orderregels. Een kwart van de orders staat open.
- De tool: BenchmarkDotNet 0.15.8, met 3 opwarmrondes en 12 gemeten iteraties per geval, inclusief geheugenallocaties.
- Elk geval opent een nieuwe
DbContext, zoals een webrequest dat zou doen. - De machine: een laptop met een Intel Core Ultra 7 255H.
Eén belangrijke beperking: SQLite draait binnen het proces van de applicatie, dus elke query is een functieaanroep en geen reis over het netwerk. Op SQL Server of Azure SQL kost elke extra query ook een round trip over het netwerk. Daar komen we hieronder op terug, want voor twee van de vijf verandert dat het beeld.
De resultaten
| Fout | Tijd | De oplossing | Tijd | Trager | Geheugen |
|---|---|---|---|---|---|
Filteren na ToList(), in het geheugen |
91,4 ms | Filteren in de database | 10,0 ms | 9,1× | 43,6 MB vs 0,06 MB |
| Hele entiteiten voor een lijst | 22,5 ms | Projectie naar de benodigde kolommen | 9,6 ms | 2,4× | 12,7 MB vs 1,0 MB |
| N+1: regels per order geladen | 3,5 ms | Eén query met Include |
1,9 ms | 1,9× | 1,8 MB vs 0,8 MB |
| Eén query per klant in een lus | 19,3 ms | Eén query voor alle klanten | 10,9 ms | 1,8× | 2,6 MB vs 0,3 MB |
| Alleen-lezen lijst met tracking | 27,1 ms | AsNoTracking |
18,6 ms | 1,5× | 13,3 MB vs 10,9 MB |
Nu elk geval apart, met de code.
1. Filteren na ToList(): 9× trager, 700× zoveel geheugen
De klassieker: een ToList() op de verkeerde plek, en het filter draait in C# in plaats van in SQL.
var open = db.Orders.ToList()
.Where(o => o.Status == "Open" && o.Total > 500)
.Count();
De database stuurt alle 10.000 orders, met hun notities van 2 KB, en de applicatie gooit het grootste deel weg. De oplossing laat de database het werk doen:
var open = db.Orders.Count(o => o.Status == "Open" && o.Total > 500);
De fout alloceerde 43,6 MB voor één aanroep; de oplossing 62 KB. Op een webserver betekent dat verschil dat de garbage collector bij elk request hard moet werken, en het groeit mee met de tabel. Meestal sluipt het erin via een methode die List<T> of IEnumerable<T> teruggeeft in plaats van IQueryable<T>, zodat het filter pas wordt toegevoegd als de data al binnen is.
2. Hele entiteiten voor een lijst: 2,4× trager, 12× zoveel geheugen
Een lijstscherm heeft vier kolommen nodig. De code laadt complete entiteiten, inclusief een groot tekstveld dat niemand toont:
var rows = db.Orders.AsNoTracking()
.Include(o => o.Customer)
.Where(o => o.Status == "Open")
.ToList()
.Select(o => new OrderRow(o.Id, o.Customer.Name, o.Date, o.Total));
Een projectie vraagt de database alleen om wat het scherm nodig heeft:
var rows = db.Orders
.Where(o => o.Status == "Open")
.Select(o => new OrderRow(o.Id, o.Customer.Name, o.Date, o.Total))
.ToList();
12,7 MB werd 1 MB. Hoe breder de tabel, en elke tabel wordt in de loop der jaren breder, hoe groter het verschil. Projecties worden bovendien nooit getrackt, dus ze krijgen het voordeel van het laatste punt er gratis bij.
3. N+1: hier 1,9× trager, over een netwerk veel erger
Eén query voor de orders, en dan nog een voor de regels van elke order:
var orders = db.Orders.OrderBy(o => o.Id).Take(100).ToList();
foreach (var order in orders)
total += db.OrderLines.Count(l => l.OrderId == order.Id);
Dat zijn 101 queries. Met Include is het er één:
var orders = db.Orders.OrderBy(o => o.Id).Take(100)
.Include(o => o.Lines)
.ToList();
In onze test was het verschil 1,6 ms. Dat klinkt onschuldig, maar SQLite had geen netwerk. Op een SQL Server in hetzelfde datacenter kost een round trip doorgaans ergens tussen een halve milliseconde en een paar milliseconden. Honderd extra round trips voegen dan zo’n 50 tot 200 ms toe aan elke pagina die dit doet, en de pagina met vandaag 100 rijen heeft er volgend jaar 1.000.
4. Een query in een lus: 1,8× trager, en hetzelfde netwerkprobleem
Hetzelfde patroon in een andere vorm: een lus over klanten, met één query per klant.
foreach (var id in customerIds)
count += db.Orders.Count(o => o.CustomerId == id && o.Status == "Open");
Eén query doet hetzelfde werk:
var count = db.Orders.Count(o => customerIds.Contains(o.CustomerId) && o.Status == "Open");
Lokaal kostten 200 queries tegenover één 1,8× zoveel tijd en 7,7× zoveel geheugen. Over een netwerk voegen die 200 round trips dezelfde 0,1 tot 0,4 seconde toe als bij N+1. Zulke lussen verstoppen zich in imports, achtergrondtaken en rapportages, en daarom groeien nachtelijke runs van minuten naar uren.
5. Tracking op een lijst die niemand wijzigt: 1,5× trager
Standaard houdt EF Core van elke geladen entiteit een kopie bij om wijzigingen te detecteren. Voor een scherm dat alleen data toont, is dat verspild werk:
var orders = db.Orders.Where(o => o.Status == "Open").ToList();
var orders = db.Orders.AsNoTracking().Where(o => o.Status == "Open").ToList();
46 % trager en 2,4 MB meer geheugen, bij elke keer dat een lijst wordt geladen. Het is de mildste van de vijf, en het makkelijkst op te lossen: één aanroep, of QueryTrackingBehavior.NoTracking als standaard voor een context die alleen leest.
Waarom de cijfers een ondergrens zijn
Bij twee van de vijf fouten, N+1 en queries in een lus, draait het vooral om het aantal round trips. Onze opzet haalde het netwerk volledig weg, dus hij laat alleen de kosten binnen de applicatie zien. Een ruwe richtlijn voor een echte server:
| Extra queries per pagina | Bij 0,5 ms per round trip | Bij 2 ms per round trip |
|---|---|---|
| 10 | +5 ms | +20 ms |
| 100 | +50 ms | +200 ms |
| 1.000 | +0,5 s | +2 s |
Bij de andere drie fouten, filteren in het geheugen, hele entiteiten en tracking, gaat het om hoeveel data er wordt verstuurd en hoeveel geheugen die inneemt. Een netwerk maakt ook die erger, want elke overbodige megabyte moet eroverheen.
Hoe u ze vindt in uw applicatie
- Bekijk de SQL. Zet in de ontwikkelomgeving EF Core-logging aan met
LogTo(Console.WriteLine), of roepToQueryString()aan op een query, en tel het aantal queries per pagina. - Zoek naar
ToList()enAsEnumerable()gevolgd doorWhere,CountofFirst, en naar repository-methoden dieList<T>teruggeven. - Zoek naar lussen waarin een query zit, ook de verborgen lussen in lazy-loaded navigatie-eigenschappen.
- Meet de traagste schermen voor en na een oplossing, zoals wij hier hebben gedaan. De oplossing die telt, is de oplossing die het getal laat bewegen.
De volledige lijst met oorzaken, inclusief de oorzaken die we hier niet hebben gebenchmarkt, zoals ontbrekende indexen en paginering die steeds trager wordt, staat in ons artikel over waarom EF Core traag is.
Vragen die we vaak horen
Is EF Core zelf traag?
Niet in onze ervaring. In alle vijf gevallen was de oplossing nog steeds EF Core, alleen anders gebruikt. De meeste trage EF Core-applicaties zijn traag door de manier waarop queries geschreven zijn, niet door de library.
Moeten we dan niet overstappen op Dapper of pure SQL?
Voor een handvol zeer intensief gebruikte queries kan dat zinvol zijn. Voor de rest halen de oplossingen hierboven het grootste deel van de winst, terwijl u één manier van data-toegang houdt. De datalaag herschrijven is zelden de eerste stap.
Gelden deze resultaten ook op SQL Server?
De richting wel, en bij N+1 en lussen worden de verschillen groter door het netwerk. De exacte milliseconden hangen af van uw server, uw data en uw indexen. Daarom loont het om uw eigen traagste schermen te meten.
We zitten nog op EF6. Geldt dit ook voor ons?
Dezelfde fouten bestaan in EF6, en de oplossingen lijken erop. De overstap naar EF Core is een apart project, dat we behandelen in ons artikel over de migratie van EF6 naar EF Core.
Voor applicaties die nog op EF6 draaien, hebben we dezelfde soort queries op EF6 en EF Core 10 naast elkaar gemeten: EF6 vs EF Core 10.
Hoe wij werken
Dit is precies wat wij doen. ProCoding is een .NET-studio in Split, Kroatië, en EF Core-performance is waar we veel van onze tijd aan besteden: de queries vinden die het meest kosten, ze oplossen met metingen voor en na, en ervoor zorgen dat de datalaag bijblijft als de data groeit. Zelfstandig, samen met uw team, of met een freelance .NET-developer in uw team. De eerste stap is een gratis gesprek van 30 minuten.
Bronnen
- BenchmarkDotNet, de benchmarktool die we hebben gebruikt
- Efficient querying, Microsoft Learn
- Tracking vs. no-tracking queries, Microsoft Learn
- Simple logging in EF Core, Microsoft Learn
- Eigen metingen, oktober 2026, zoals hierboven beschreven
Dit artikel is alleen algemene informatie en geen juridisch, fiscaal, financieel of ander professioneel advies. Scenario’s, voorbeelden en berekeningen zijn ter illustratie. Gebruiksvoorwaarden en disclaimer.