EF Core SQL Generation — What EF Core Sends to SQL Server

Understanding what SQL EF Core generates is essential for writing performant data access code. EF Core translates LINQ to SQL — but the translation is not always optimal, and sometimes it falls back to client evaluation (executing the query in memory) without a clear warning. Enabling SQL logging during development and validating generated queries with SET STATISTICS IO catches performance problems before they reach production.

SQL Logging and LINQ Translation

// ── Enable SQL logging in development ─────────────────────────────────────
builder.Services.AddDbContext<AppDbContext>(opts => {
    opts.UseSqlServer(connStr);
    if (builder.Environment.IsDevelopment()) {
        opts.LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging()   // include parameter values
            .EnableDetailedErrors();
    }
});

// ── AsNoTracking() — read-only queries (no change tracker overhead) ────────
// For list/detail pages where you don't need to SaveChanges:
var posts = await _db.Posts
    .AsNoTracking()                  // skip change tracking — faster reads
    .Where(p => p.IsPublished)
    .OrderByDescending(p => p.PublishedAt)
    .Take(10)
    .ToListAsync(ct);
// Generated SQL:
// SELECT TOP(10) p.Id, p.Title, ... FROM Posts p
// WHERE p.IsPublished = 1
// ORDER BY p.PublishedAt DESC

// ── Include() — single collection (generates JOIN) ────────────────────────
var post = await _db.Posts
    .AsNoTracking()
    .Include(p => p.Author)
    .Include(p => p.Category)
    .FirstOrDefaultAsync(p => p.Slug == slug, ct);
// Generated SQL: single query with JOINs for Author and Category

// ── AsSplitQuery() — multiple collection includes ─────────────────────────
// Without split query: cartesian explosion (Posts × Tags × Comments rows)
// With split query: separate SQL statements for each collection
var postWithTags = await _db.Posts
    .AsNoTracking()
    .AsSplitQuery()                  // separate queries per collection
    .Include(p => p.Tags)
    .Include(p => p.Comments.Where(c => c.IsApproved))
    .FirstOrDefaultAsync(p => p.Id == id, ct);
// Generated SQL:
// SELECT ... FROM Posts WHERE Id = @id          (1 query)
// SELECT ... FROM Tags JOIN PostTags ...         (2nd query)
// SELECT ... FROM Comments WHERE PostId = @id    (3rd query)

// ── Projection — Select only needed columns ────────────────────────────────
var summaries = await _db.Posts
    .AsNoTracking()
    .Where(p => p.IsPublished)
    .Select(p => new PostSummaryDto {   // only fetch these columns
        Id         = p.Id,
        Title      = p.Title,
        Slug       = p.Slug,
        AuthorName = p.Author!.DisplayName,   // navigation inside Select — joins
        ViewCount  = p.ViewCount,
    })
    .OrderByDescending(p => p.ViewCount)
    .Take(10)
    .ToListAsync(ct);
// Generated SQL: SELECT p.Id, p.Title, p.Slug, u.DisplayName, p.ViewCount
// FROM Posts p JOIN Users u ON u.Id = p.AuthorId
// WHERE p.IsPublished = 1 ORDER BY p.ViewCount DESC OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY
Note: EF Core’s client evaluation silently fetches entire tables into memory and filters in C# when it cannot translate a LINQ expression to SQL. In EF Core 3+, client evaluation for the top-level query is disabled by default — it throws an InvalidOperationException with “could not be translated” when it encounters untranslatable LINQ. However, client evaluation can still occur in specific positions (like inside Select() projections). Always examine the generated SQL in development to verify the whole query runs in SQL Server, not partially in memory.
Tip: Use AsSplitQuery() globally in DbContext.OnConfiguring() with UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) when your application frequently loads entities with multiple collection navigations. Without split queries, including 3 collections on a post that has 10 tags and 20 comments produces 10 × 20 = 200 rows for what is effectively 31 pieces of data — a cartesian explosion. Split queries execute separate SQL statements per collection but eliminate row multiplication.
Warning: AsNoTracking() is not just a micro-optimisation — it eliminates the change tracker’s overhead entirely (no object identity map, no snapshot comparison on SaveChanges). For a list endpoint returning 50 posts, the change tracker would maintain 50 entity snapshots that are never used. Use AsNoTracking() as the default for all read-only queries (GET endpoints) and explicitly opt into tracking only when you need to call SaveChanges on the returned entities.

Common Mistakes

Mistake 1 — Include() with multiple collections without AsSplitQuery (cartesian explosion)

❌ Wrong — Include(Tags).Include(Comments); 10 tags × 50 comments = 500 rows returned for 60 pieces of data.

✅ Correct — AsSplitQuery() generates separate queries per collection; no row multiplication.

Mistake 2 — Not using AsNoTracking() on read-only queries (change tracker overhead)

❌ Wrong — list endpoint loads 100 posts with change tracking enabled; 100 snapshots maintained in memory unnecessarily.

✅ Correct — AsNoTracking() on all read-only queries; only use tracking when SaveChanges will be called.

🧠 Test Yourself

A post has 5 tags and 20 approved comments. A query uses Include(p => p.Tags).Include(p => p.Comments) without AsSplitQuery(). How many rows does SQL Server return?