Querying Well
Most EF performance problems are not EF problems β they are queries that fetch too much, too often, or too late. Four habits fix nearly all of them.
IQueryable is a recipe, not a result
A LINQ query against a DbSet builds an expression tree; nothing hits the database until
you materialize it (ToList, First, Count, or iterating). That's deferred execution,
and it's a superpower: you can compose filters conditionally and EF translates the final shape
into one SQL statement:
var q = _db.Permits.AsQueryable();
if (status != null) q = q.Where(p => p.Status == status);
var page = q.OrderBy(p => p.Number).Skip(50).Take(25).ToList();
The moment you call .ToList() (or slip into IEnumerable), everything after it runs in
memory on rows already fetched. The classic mistake β _db.Permits.ToList().Where(...) β
downloads the whole table to filter it in C#. Compose first, materialize last.
Project early with Select
If a screen needs five columns, don't load fifty. A projection β Select into a view model
before materializing β becomes a SQL SELECT of exactly those columns, including values
reached through navigations (p.Applicant.Name becomes a join, not a second query). Projection
is also the cleanest fix for lazy-loading and serialization issues: view models have no
proxies, no cycles, no surprises.
Loading related data: Include vs lazy vs N+1
Access a navigation property that wasn't loaded and lazy loading (when enabled) silently issues another query. Do that inside a loop over 200 permits and you've run 201 queries β the N+1 problem, the most common EF performance bug in existence. Your options:
- Eager load with
.Include(p => p.Applicant)β one joined query, explicit intent. - Project β often better than Include, because you also stop over-fetching columns.
- Lazy load β acceptable for genuinely occasional access on single entities; dangerous in loops and inside views (a Razor view lazily hitting the database is a design smell).
Note that lazy loading is off by default in EF Core β it only happens if someone opted in
with UseLazyLoadingProxies(), so check your context configuration before assuming. Either
way, when a page is slow, count the queries
first β a profiler or EF's logging (next lessons) makes N+1 jump out instantly.
AsNoTracking for read-only work
By default the context tracks every entity it loads so it can detect changes. For
read-only screens β grids, reports, lookups β that bookkeeping is pure overhead. Add
.AsNoTracking() to skip it: less memory, faster materialization, and no accidental saves
of things you never meant to modify. (Projections to view models are effectively untracked
already.) A simple team rule works well: queries that feed a screen are no-tracking or
projections; only code that intends to call SaveChanges loads tracked entities.