EF Core performance tuning is mostly about controlling what reaches the database: how many round trips a request makes, how many rows and columns come back, and whether the database can answer from an index. This guide is for developers who already ship EF Core applications and need them faster under real load. It covers no-tracking queries, projections, the N+1 problem, split queries, compiled queries and models, context pooling, batching and bulk updates, raw SQL, indexes, SQL logging and benchmarking, with the version-specific gains in EF Core 9, 10 and 11.
What Actually Makes EF Core Slow?#
A typical EF Core request pays five kinds of cost, roughly in decreasing order of impact:
- Round trips. Twenty small queries cost far more than one well-shaped query, especially against a cloud database.
- Database work. A missing index turns a seek into a scan, and no C# tuning fixes that.
- Data volume. Every unneeded column and row is read, sent, parsed and allocated.
- EF overhead. Query compilation (mostly cached), materialization, change-tracking snapshots and identity resolution.
- Your own allocations, such as re-filtering lists in memory.
EF Core's own overhead is real but usually the smallest item, so measure first and fix the biggest cost.
How EF Core Executes a Query#
Knowing the pipeline tells you which knob affects which cost. When you await a LINQ query, EF Core:
- Looks up the expression tree in its compiled-query cache, keyed by the tree's shape. A hit skips translation entirely; this is why variables are parameterized rather than inlined.
- On a miss, translates the tree to SQL and caches the result in an internal
IMemoryCache, whose default size limit is 10,240 units (a compiled query costs 10, a model 100). - Opens a connection from the ADO.NET pool, executes the command and streams rows through a data reader.
- Materializes objects. For tracking queries it also snapshots each entity and checks the identity map so the same row always yields the same instance.
Compiled queries remove step 1's tree comparison, projections shrink steps 3 and 4, no-tracking removes the snapshot work in step 4, and context pooling removes the per-request setup cost of the DbContext itself. For the fundamentals of contexts and tracking, see Entity Framework Core: The Complete Guide.
Getting Started: See the SQL Before Tuning It#
You cannot tune queries you have not seen. Turn on command logging in development, tag important queries, and use ToQueryString() to inspect SQL without running it:
builder.Services.AddDbContext<ShopDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("Shop"));
if (builder.Environment.IsDevelopment())
{
options
.LogTo(Console.WriteLine,
new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information)
.EnableSensitiveDataLogging() // parameter values in logs: development only
.EnableDetailedErrors();
}
});
// Tags appear as SQL comments, so the query is easy to find in logs, traces and Query Store
var recent = db.Orders
.TagWith("Dashboard: recent orders")
.TagWithCallSite() // adds file name and line number
.Where(o => o.PlacedAt >= since)
.OrderByDescending(o => o.PlacedAt)
.Take(50);
Console.WriteLine(recent.ToQueryString());Each executed command is logged with its duration, so an unexpected burst of similar commands reveals N+1 patterns immediately. Keep command logging off in production; it slows the app and floods storage. In production, rely on metrics and tracing instead, as described below.
No-Tracking Queries and Identity Resolution#
Tracking queries snapshot every entity and maintain an identity map so that SaveChanges can detect changes. On read-only paths that work is wasted. In the EF documentation's own BenchmarkDotNet run, loading 10 blogs with 20 posts each took about 1,415 Β΅s and allocated 380 KB with tracking, against about 993 Β΅s and 233 KB with AsNoTracking().
// Read-only list: no snapshots, no identity map
var products = await db.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync(ct);
// Read-only graph where one Customer appears on many orders: de-duplicate instances
var orders = await db.Orders
.AsNoTrackingWithIdentityResolution()
.Include(o => o.Customer)
.Where(o => o.PlacedAt >= since)
.ToListAsync(ct);
// Read-mostly context, such as a reporting API: make no-tracking the default
builder.Services.AddDbContext<ReportingDbContext>(o => o
.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));Plain no-tracking queries skip identity resolution, so a customer referenced by 100 orders is materialized 100 times. When that duplication hurts, AsNoTrackingWithIdentityResolution() de-duplicates through a throwaway tracker that is discarded after the query. Queries that project into DTOs are never tracked, so they need neither operator.
Project Only What You Need with Select#
Projection is the highest-leverage technique in EF Core. Selecting a DTO shrinks the column list, avoids change tracking, lets the database compute aggregates, and often removes the need for Include because navigation access inside Select becomes a join.
public sealed record ProductCard(int Id, string Name, decimal Price, double? Rating);
var cards = await db.Products
.Where(p => p.CategoryId == categoryId && p.IsActive)
.OrderBy(p => p.Name)
.ThenBy(p => p.Id)
.Select(p => new ProductCard(
p.Id,
p.Name,
p.Price,
p.Reviews.Average(r => (double?)r.Rating))) // AVG runs in SQL; null if no reviews
.Take(50)
.ToListAsync(ct);The EF documentation includes a benchmark that averages a rating column four different ways. Its published results show how each step toward the database pays off:
| Approach (EF docs benchmark) | Mean time | Allocated |
|---|---|---|
| Load tracked entities, average in memory | 2,860 Β΅s | 1,310 KB |
Load with AsNoTracking, average in memory | 1,353 Β΅s | 540 KB |
| Project only the rating column | 911 Β΅s | 252 KB |
| Compute the average in the database | 627 Β΅s | 33 KB |
The absolute numbers depend on hardware and data, but the ordering holds almost everywhere: do the work in SQL, return only what the caller needs. For large result sets, prefer streaming with AsAsyncEnumerable() over buffering with ToListAsync(), and page everything. Offset paging with Skip gets slower as the offset grows; keyset paging, filtering on the last seen key such as Where(o => o.Id > lastId), stays fast on deep pages if an index matches the ordering.
Avoiding the N+1 Query Problem#
N+1 means one query to load a list followed by one query per item. It is the most common EF Core performance bug because each individual query looks fast in isolation. Lazy loading causes it implicitly; loops that await queries cause it explicitly.
// N+1: one query for the orders, then one query per order
var orders = await db.Orders.Where(o => o.PlacedAt >= since).ToListAsync(ct);
foreach (var order in orders)
{
var name = await db.Customers
.Where(c => c.Id == order.CustomerId)
.Select(c => c.Name)
.SingleAsync(ct); // a round trip per iteration
Console.WriteLine($"{order.Number}: {name}");
}
// Fix 1: one round trip that returns exactly the needed columns
var rows = await db.Orders
.Where(o => o.PlacedAt >= since)
.Select(o => new { o.Number, CustomerName = o.Customer.Name })
.ToListAsync(ct);
// Fix 2: when you already hold the keys, load related rows in one batch
var customerIds = orders.Select(o => o.CustomerId).Distinct().ToArray();
var names = await db.Customers
.Where(c => customerIds.Contains(c.Id))
.Select(c => new { c.Id, c.Name })
.ToDictionaryAsync(c => c.Id, c => c.Name, ct);The second fix relies on how EF Core translates Contains over a local collection. EF Core 10 sends one parameter per value and pads the list to a few fixed sizes, which keeps the number of distinct SQL statements low while still telling the query planner how many values to expect. EF Core 8 and 9 sent the values as a single JSON array instead, which reuses one plan but hides the cardinality from the optimizer.
Split Queries vs Cartesian Explosion#
When a query includes two collections at the same level, a single SQL statement returns their cross product: a blog with 10 posts and 10 contributors produces 100 rows. This is cartesian explosion, and it grows multiplicatively with each additional collection. Split queries load each collection with its own SELECT instead of joining everything.
// Lines x Payments would multiply rows in a single JOIN; split it into three SELECTs
var order = await db.Orders
.Include(o => o.Lines)
.Include(o => o.Payments)
.AsSplitQuery()
.SingleAsync(o => o.Id == orderId, ct);
// Or make splitting the default and opt specific queries back into a single statement
builder.Services.AddDbContext<ShopDbContext>(o => o.UseSqlServer(
connectionString,
sql => sql.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)));
var summary = await db.Orders
.Include(o => o.Lines)
.AsSingleQuery()
.SingleAsync(o => o.Id == orderId, ct);Split queries are not free. Each is an extra round trip, earlier result sets are buffered in memory unless SQL Server's MARS is enabled, and concurrent writes can produce inconsistent results unless you use a snapshot or serializable transaction. EF Core logs a warning when a query loads multiple collections without an explicit splitting mode. EF Core 10 fixed an ordering inconsistency between split statements when paging, and EF Core 11 prunes unneeded reference joins from them. Often the best answer is neither mode: a projection that selects only the needed columns.
Compiled Queries and Compiled Models#
EF Core caches translations, but each execution still compares the expression tree against the cache. Compiled queries skip that lookup by turning a LINQ query into a reusable delegate. In the EF documentation's benchmark, a single-row compiled query ran in about 564 Β΅s against 672 Β΅s uncompiled and allocated 9 KB instead of 13 KB; the gain grows with query complexity.
public sealed record OrderDto(int Id, string Number, OrderStatus Status, int LineCount);
public static class OrderQueries
{
// Compiled once per process; the delegates are thread-safe and work with any context
public static readonly Func<ShopDbContext, int, Task<OrderDto?>> ById =
EF.CompileAsyncQuery((ShopDbContext db, int id) =>
db.Orders
.Where(o => o.Id == id)
.Select(o => new OrderDto(o.Id, o.Number, o.Status, o.Lines.Count))
.SingleOrDefault());
public static readonly Func<ShopDbContext, int, IAsyncEnumerable<OrderDto>> ForCustomer =
EF.CompileAsyncQuery((ShopDbContext db, int customerId) =>
db.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.PlacedAt)
.Select(o => new OrderDto(o.Id, o.Number, o.Status, o.Lines.Count)));
}
// Usage
var order = await OrderQueries.ById(db, orderId);
await foreach (var dto in OrderQueries.ForCustomer(db, customerId))
{
// stream results
}Keep compiled-query parameters to simple scalars; since EF Core 9, EF.Constant() and EF.Parameter() throw inside compiled queries. Dynamic queries cannot be compiled, and they are also where cache misses come from: building expression trees with Expression.Constant for user input forces a recompilation per value. The EF docs measured about 1,666 Β΅s per execution for that mistake against 757 Β΅s with a parameter, before counting plan-cache pollution on the server.
Compiled models attack a different cost: building the model at startup. For models with hundreds or thousands of entity types, dotnet ef dbcontext optimize generates the model as C# code. Since EF Core 9 the context finds that model automatically, and the Microsoft.EntityFrameworkCore.Tasks package can regenerate it on every build:
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tasks" Version="10.0.12"
PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<EFOptimizeContext>true</EFOptimizeContext>
<EFScaffoldModelStage>build</EFScaffoldModelStage>
</PropertyGroup>The EF docs still list limitations for compiled models, including no support for global query filters, lazy-loading or change-tracking proxies, or custom IModelCacheKeyFactory implementations, so use them only when startup time is a measured problem. Query precompilation and Native AOT support, introduced in EF Core 9, are still documented as experimental and not recommended for production.
DbContext Pooling#
Creating a DbContext is cheap but not free: each instance sets up internal services. Context pooling resets and reuses instances instead. The EF documentation's single-threaded benchmark fetching one row showed about 702 Β΅s and 50 KB allocated without pooling against 350 Β΅s and 4.6 KB with it. Pooling is orthogonal to ADO.NET connection pooling, which the driver handles regardless.
// Simple case: swap AddDbContext for AddDbContextPool (default pool size is 1024)
builder.Services.AddDbContextPool<ShopDbContext>(
o => o.UseSqlServer(builder.Configuration.GetConnectionString("Shop")),
poolSize: 512);
// Per-request state (for example a tenant ID) with pooling: wrap the pooled factory
builder.Services.AddPooledDbContextFactory<TenantDbContext>(
o => o.UseSqlServer(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddScoped<TenantDbContextFactory>();
builder.Services.AddScoped(sp => sp.GetRequiredService<TenantDbContextFactory>().CreateDbContext());
public sealed class TenantDbContextFactory(
IDbContextFactory<TenantDbContext> pooledFactory, ITenantContext tenant)
{
public TenantDbContext CreateDbContext()
{
var db = pooledFactory.CreateDbContext();
db.TenantId = tenant.TenantId; // set on every rent; instances are reused
return db;
}
}Pooled contexts behave like singletons that are reset between uses. OnConfiguring runs only once per instance, constructors should not depend on scoped services, and any state you add yourself must be reset on each rent, as the factory above does. EF Core also does not reset state you create in the underlying driver, such as a connection you opened manually.
Batching and Bulk Writes with ExecuteUpdate and ExecuteDelete#
SaveChanges already batches: all pending inserts, updates and deletes go out in as few round trips as possible, inside one transaction. For SQL Server the EF team found batching inefficient below about 4 statements and of diminishing value beyond about 40, so the provider sends at most 42 statements per batch by default. You can tune MinBatchSize and MaxBatchSize, but benchmark before and after.
When you would otherwise load thousands of rows just to change one column, use set-based operations instead. ExecuteUpdate and ExecuteDelete (EF Core 7 and later) send a single statement and never touch the change tracker:
builder.Services.AddDbContext<ShopDbContext>(o => o.UseSqlServer(
connectionString,
sql => sql.MaxBatchSize(100))); // the SQL Server default is 42; measure first
// Many inserts in one SaveChanges are batched into a few round trips
db.Products.AddRange(newProducts);
await db.SaveChangesAsync(ct);
// One UPDATE statement; nothing is loaded or tracked
await db.Products
.Where(p => p.CategoryId == categoryId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 1.05m), ct);
// One DELETE statement
var cutoff = DateTimeOffset.UtcNow.AddYears(-2);
await db.AuditEntries
.Where(a => a.CreatedAt < cutoff)
.ExecuteDeleteAsync(ct);Remember that these methods bypass the change tracker, SaveChanges interceptors and concurrency-token checks, and each runs in its own implicit transaction unless you start one. For loading millions of rows, even batched inserts are too slow; use the provider's bulk-copy API, such as SqlBulkCopy, described in Dapper and ADO.NET: High-Performance Data Access.
Raw SQL When LINQ Is Not Enough#
Sometimes the best SQL is something EF Core does not generate: a window function, a query hint, a table-valued function or a hand-tuned join. EF Core lets you write that SQL and still compose LINQ on top when the SQL is composable.
// FromSql turns interpolated values into DbParameters, so this is injection-safe
var topSellers = await db.Products
.FromSql($"SELECT * FROM dbo.GetTopSellers({since})")
.Where(p => p.IsActive)
.OrderBy(p => p.Name)
.AsNoTracking()
.ToListAsync(ct);
// SqlQuery maps rows to types outside the model (EF Core 8 and later)
var daily = await db.Database
.SqlQuery<DailySales>($"""
SELECT CAST(PlacedAt AS date) AS Day, COUNT(*) AS Orders, SUM(Total) AS Revenue
FROM sales.Orders
WHERE PlacedAt >= {since}
GROUP BY CAST(PlacedAt AS date)
""")
.ToListAsync(ct);
// Non-query SQL returns the number of affected rows
var archived = await db.Database.ExecuteSqlAsync(
$"EXEC sales.ArchiveOrders @Before = {cutoff}", ct);
public sealed record DailySales(DateOnly Day, int Orders, decimal Revenue);Use FromSqlRaw and SqlQueryRaw only when you must splice identifiers such as column names into the SQL, and validate them against an allow-list; EF Core 10 ships an analyzer that warns about string concatenation inside these raw APIs. Treat raw SQL as a last resort for proven hot spots, because it bypasses the model's type safety and refactoring support.
Indexes: The Database Side of EF Core Performance#
Most slow EF Core queries are really missing-index problems. EF Core creates an index for every foreign key by convention, and everything else is up to you. Declare indexes in the model so migrations keep every environment consistent:
modelBuilder.Entity<Order>(b =>
{
// Matches WHERE CustomerId = @id ORDER BY PlacedAt DESC, and covers the projected columns
b.HasIndex(o => new { o.CustomerId, o.PlacedAt })
.IsDescending(false, true)
.IncludeProperties(o => new { o.Number, o.Status }); // SQL Server INCLUDE columns
// Filtered index: only the rows the hot query touches
b.HasIndex(o => o.PlacedAt)
.HasFilter("[Status] = 'Pending'")
.HasDatabaseName("IX_Orders_Pending_PlacedAt");
b.HasIndex(o => o.Number).IsUnique();
});Composite indexes help queries that filter on their leading columns, not on a trailing column alone. A filter on an expression such as Price / 2 cannot use a plain index, but a persisted computed column with its own index can. Verify every change against the actual execution plan, captured on a database with production-like volumes, because optimizers choose scans on tiny test tables. EF Core 11 also strips no-op CAST expressions that previously stopped some value-converted columns from using their indexes. The deeper SQL Server side, including plans and statistics, is covered in SQL Server for .NET Developers.
Measuring: Metrics, Tracing and Benchmarks#
In production, EF Core 9 and later publish metrics through System.Diagnostics.Metrics under the meter Microsoft.EntityFrameworkCore: active contexts, queries, compiled-query cache hits and misses, execution-strategy failures and optimistic-concurrency failures. Add it to OpenTelemetry with AddMeter("Microsoft.EntityFrameworkCore"). A cache hit rate stuck below 100% after warm-up usually means a dynamic query with inlined constants. For per-command timing, the OpenTelemetry.Instrumentation.SqlClient package traces SQL Server commands; see OpenTelemetry in .NET.
To decide between two ways of writing a query, benchmark them with BenchmarkDotNet against realistic data rather than guessing:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.EntityFrameworkCore;
BenchmarkRunner.Run<OrderQueryBenchmarks>();
[MemoryDiagnoser]
public class OrderQueryBenchmarks
{
private DbContextOptions<ShopDbContext> _options = null!;
[GlobalSetup]
public void Setup() => _options = new DbContextOptionsBuilder<ShopDbContext>()
.UseSqlServer(Environment.GetEnvironmentVariable("SHOP_BENCH_DB"))
.Options;
[Benchmark(Baseline = true)]
public async Task<List<Order>> Tracking()
{
await using var db = new ShopDbContext(_options);
return await db.Orders.Where(o => o.CustomerId == 42).ToListAsync();
}
[Benchmark]
public async Task<List<Order>> NoTracking()
{
await using var db = new ShopDbContext(_options);
return await db.Orders.AsNoTracking().Where(o => o.CustomerId == 42).ToListAsync();
}
[Benchmark]
public async Task<List<OrderDto>> Projection()
{
await using var db = new ShopDbContext(_options);
return await db.Orders
.Where(o => o.CustomerId == 42)
.Select(o => new OrderDto(o.Id, o.Number, o.Status, o.Lines.Count))
.ToListAsync();
}
}Run benchmarks in Release mode against a local database seeded with production-like data. BenchmarkDotNet measures single-threaded throughput, so validate concurrency-sensitive changes, such as pooling, with a load test as well. More on the tool in Benchmarking .NET Code with BenchmarkDotNet.
Version-Specific Performance Changes in EF Core 9, 10 and 11#
| Version | Performance-relevant changes |
|---|---|
| EF Core 9 (.NET 8 and 9) | Metrics via System.Diagnostics.Metrics; auto-discovered compiled models and MSBuild regeneration; EF.Parameter and options to control collection parameterization; pruning of unneeded joins and projected columns; experimental precompiled queries |
| EF Core 10 (.NET 10, LTS) | Local collections sent as padded parameter lists; consistent ordering across split queries; ExecuteUpdate on JSON columns; native json type on Azure SQL and SQL Server 2025; COALESCE translated as ISNULL on SQL Server in most cases |
| EF Core 11 (.NET 11, RC) | Unneeded reference joins pruned from split queries and redundant ORDER BY keys removed (the EF team measured 29% and 22% gains in sample scenarios); vector columns no longer loaded by default; no-op casts stripped; JSON_CONTAINS for primitive collections at compatibility level 170 |
Teams still on .NET 8 can run EF Core 9 at most, and both EF Core 8 and 9 leave support on November 10, 2026. Moving to EF Core 10 is therefore both a performance and a support decision.
When to Use Each Technique#
| Technique | Fixes | Trade-off |
|---|---|---|
AsNoTracking | Snapshot and identity-map overhead on reads | Entities cannot be saved without attaching |
Projection with Select | Excess columns, tracking, client-side math | Returns DTOs, not updatable entities |
Include or batch loading | N+1 round trips | Over-fetching if you include too much |
AsSplitQuery | Cartesian explosion from multiple collections | Extra round trips, possible inconsistency |
| Compiled queries | Cache lookup cost on hot, complex queries | Static shape, scalar parameters only |
| Compiled models | Slow startup with very large models | Feature limitations, regeneration step |
| Context pooling | Per-request context setup cost | Care with per-request state |
ExecuteUpdate / ExecuteDelete | Loading rows only to modify them | Bypasses tracking and concurrency tokens |
| Raw SQL | SQL that LINQ cannot express well | Less type safety, more maintenance |
| Indexes | Scans and sorts in the database | Slower writes, more storage |
Best Practices: An EF Core Performance Checklist#
- Log the SQL and count round trips per request in development; tag important queries.
- Make reads no-tracking or projected, and select only the columns the caller needs.
- Eliminate N+1 with projection,
Includeor batchedContainslookups, and avoid lazy loading on server paths. - Choose a splitting mode explicitly for queries that include several collections.
- Page every list with a unique, stable ordering, and use keyset paging for deep pages.
- Check execution plans on production-like data and add indexes in the model.
- Write set-based changes with
ExecuteUpdateandExecuteDelete, and use bulk copy for very large loads. - Enable context pooling for high-throughput services once you have reviewed context state.
- Compile the hottest queries, and consider a compiled model only for very large models.
- Watch EF metrics for cache misses and concurrency failures, and benchmark changes before shipping.
- Stay current: use the latest patch, such as EF Core 10.0.12 at the time of writing.
Common Pitfalls#
- Tuning EF before the database. Missing indexes dominate; check plans first.
- Calling
ToList()too early, then filtering or counting in memory. - Loading a list to count it instead of using
CountAsync()orAnyAsync(). - Including entire graphs for a screen that shows three fields.
- Building expression trees with constants for user input, defeating the query cache.
- Mixing sync and async database calls, which risks thread-pool starvation.
- Benchmarking against empty tables, where every plan looks fine.
- Sensitive-data logging in production, which costs throughput and leaks data.
Frequently Asked Questions#
Is AsNoTracking always faster than a tracking query?#
For queries that return entities you will not modify, yes: it skips snapshots and the identity map. The exception is a graph where many rows reference the same entity, where duplicated instances can cost more memory than tracking; use AsNoTrackingWithIdentityResolution() there. Projections into DTOs are untracked anyway.
When should I use split queries instead of a single query?#
Use split queries when a query includes two or more collection navigations at the same level, or a collection whose parent rows carry large columns. Stay with single queries for reference navigations and small collections, especially on high-latency connections. When in doubt, measure both, or project only the needed columns instead.
Are compiled queries worth the extra code?#
Only for hot, frequently executed queries with non-trivial LINQ, where the saved cache lookup is measurable. For most queries, EF Core's automatic query cache already delivers most of the benefit. Benchmark a candidate query before and after compiling it.
Does DbContext pooling replace ADO.NET connection pooling?#
No. Connection pooling in the database driver reuses physical connections, while context pooling reuses DbContext objects and their internal services. They are independent, and high-throughput services typically benefit from both.
Should I switch from EF Core to Dapper for performance?#
Rarely for a whole application. Apply the techniques above first, since most gains come from query shape and indexes. For the few proven hot paths that still need hand-written SQL, use raw SQL through EF Core or Dapper on the same connection.
Summary#
- Most EF Core performance problems are round trips, missing indexes and over-fetching, not EF overhead.
- Log and tag SQL, then fix N+1 queries, project to DTOs and use no-tracking reads.
- Use split queries against cartesian explosion, pooling for throughput, and compiled queries for hot paths.
- Prefer
ExecuteUpdateandExecuteDeletefor set-based writes, and raw SQL only for proven hot spots. - EF Core 9 added metrics, EF Core 10 improved parameterization and JSON updates, and EF Core 11 trims unnecessary joins.