DEV Community

VLAD
VLAD

Posted on

EF Core Isn't Slow. These 5 Query Patterns Are.

Four lines of C# can send 101 queries to SQL Server. One line can pull a whole table into memory just to show ten rows. In both cases EF Core is doing exactly what the code asked for. The problem is the SQL that the code turns into.

Here are five patterns behind a lot of slow EF Core code, plus the one habit that catches all of them.

First: LINQ isn't run as C

When you write a LINQ query against a DbSet, EF Core doesn't run your lambdas. It reads the query as an expression tree and translates it into SQL. Nothing hits the database until you ask for results, with ToList, First, foreach and the like.

So asking "is my C# fast?" doesn't help much. The useful question is: what SQL did this become?

See the SQL before you guess

One line in your context options logs every command EF sends (Simple Logging):

optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
Enter fullscreen mode Exit fullscreen mode

For a single query, ToQueryString() returns the SQL as a string without running it (available since EF Core 5.0):

var sql = query.ToQueryString();
Enter fullscreen mode Exit fullscreen mode

Count the queries and look at the columns. Each fix below starts there.

Trap 1: N+1

var orders = await db.Orders.ToListAsync();     // 1 query
foreach (var o in orders)
    Console.WriteLine(o.Customer.Name);         // +1 query per order
Enter fullscreen mode Exit fullscreen mode

With lazy loading turned on, every o.Customer access is a separate round trip. 100 orders means 101 queries. Lazy loading is opt-in: you get it from the Microsoft.EntityFrameworkCore.Proxies package and UseLazyLoadingProxies(). Once it's on, it hides this cost from you. The docs warn about it directly: lazy loading makes the N+1 problem very easy to trigger, and they recommend avoiding it.

Fix: ask for what you need up front. A projection becomes a single query with a JOIN:

var rows = await db.Orders
    .Select(o => new { o.Id, CustomerName = o.Customer.Name })
    .ToListAsync();                              // 1 query, with a JOIN
Enter fullscreen mode Exit fullscreen mode

Use Include(o => o.Customer) if you really need the whole entity. Also watch for the same trap without lazy loading: a query inside a foreach.

Trap 2: the whole table

var big  = db.Orders.ToList().Where(o => o.Total > 1000);   // whole table
var good = db.Orders.Where(o => o.Total > 1000).ToList();   // WHERE in SQL
Enter fullscreen mode Exit fullscreen mode

ToList() runs first, so the first line selects every row and filters in memory. In the second line, the filter becomes a WHERE clause. The lambda is identical, but the query is not.

There's a sneakier version:

IEnumerable<Order> orders = db.Orders;
var big = orders.Where(o => o.Total > 1000).ToList();       // whole table, again
Enter fullscreen mode Exit fullscreen mode

Once the DbSet is typed as IEnumerable<T> (a variable, or a method parameter), Where resolves to the in-memory LINQ operator, and EF never sees the filter. Keep it IQueryable until you're done filtering.

EF Core does protect you from the accidental version. Since 3.0, an expression it can't translate, anywhere except the final projection, throws instead of silently running on the client. It can't protect you here, because here you explicitly asked for the whole table.

Trap 3: loading more than you show

A list page needs two columns, and db.Orders.ToList() brings back all of them. EF also tracks every entity it loads. As the docs explain, it keeps an identity dictionary and a snapshot per entity, so it can detect changes when you call SaveChanges. That costs time and memory.

// read-only list: just the columns
var rows = await db.Orders
    .Select(o => new OrderRow(o.Id, o.Total)).ToListAsync();

// read-only, full entities: skip the tracking
var orders = await db.Orders.AsNoTracking().ToListAsync();
Enter fullscreen mode Exit fullscreen mode

A projection with no entity types in the result isn't tracked at all. If you need full entities but won't save them, add AsNoTracking().

How much does it matter? In Microsoft's own benchmark (10 blogs with 20 posts each), the no-tracking query took about 30% less time (993 µs vs. 1,415 µs) and allocated almost 40% less memory (233 KB vs. 380 KB). That's a small query, and the difference is still real.

Trap 4: cartesian explosion

var blogs = await db.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Two collection Includes at the same level become one SQL query with two JOINs, and the database returns a cross product. The docs' example: a blog with 10 posts and 10 contributors comes back as 100 rows. EF Core logs a warning when a query loads multiple collections and you haven't configured a splitting mode, so read the log.

Fix: .AsSplitQuery() sends one query per collection instead of one big JOIN. It comes with trade-offs: there's an extra round trip per query, and if someone writes in between, the queries don't see one consistent snapshot. Split when the JOIN explodes, not everywhere.

Trap 5: the index killer

db.Users.Where(u => u.Email.ToLower() == email)
Enter fullscreen mode Exit fullscreen mode
WHERE LOWER([u].[Email]) = @email
Enter fullscreen mode Exit fullscreen mode

EF translates ToLower faithfully, as a function wrapped around the column. When a query filters on an expression over a column, a simple index can't be used. On SQL Server you usually didn't need it anyway: the default server collation for an English (US) install, SQL_Latin1_General_CP1_CI_AS, is case-insensitive.

The same idea applies to StartsWith and EndsWith: on SQL Server, the first can use an index on the column and the second can't.

A slow page? Go in this order

  1. Log the SQL.
  2. Count the queries. More than a few for one page? Look for N+1.
  3. Check rows and columns. Are you loading the whole table, every column, or a cartesian explosion?
  4. Open the query plan and look for a scan on a column you indexed.

EF Core doesn't make your query slow. It sends exactly the SQL you described, so read that SQL before you blame the ORM.

What's the most queries you've ever found behind one page?


I make Vlad's Stack: short explainers on how the tools you use every day actually work, for people who write code. VIDEO_CHANNEL_URL

Top comments (0)