Index ยท Additional notes
7 min read- Additional notes
- Performance characteristics
- Where EF Core spends time
- Typical numbers (single Postgres, local network)
- Compiled queries
- Connection pool tuning (Npgsql)
- AsNoTracking โ the silent default you should set
- Bulk operations
- EFCore.BulkExtensions
- Raw Npgsql COPY (fastest)
- Observability
- EF Core logging
- Interceptors (per-query custom logic)
- OpenTelemetry instrumentation
- Common pitfalls
- When to drop to Dapper / raw SQL
- Comparison to Go (sqlc + pgx + goose)
- Interview talking points
- Further reading
Additional notes
Performance characteristics
Where EF Core spends time
- Query compilation โ first time a LINQ query runs, EF Core builds an expression tree and translates to SQL. Cached afterwards, but first hit is ~10ร slower.
- Change tracker โ every loaded entity is added to the tracker. Tracker is a dictionary keyed by primary key with snapshot of original values. Memory: ~500โ1000 bytes per tracked entity.
- Materialization โ SQL result โ entity instance via cached delegates. Fast after warmup.
SaveChangesโ tracker diffs against snapshots, builds UPDATE / INSERT / DELETE statements, batches them.
Typical numbers (single Postgres, local network)
| Operation | EF Core 9 default | EF Core + AsNoTracking | Dapper | Raw NpgsqlCommand |
|---|---|---|---|---|
| Single SELECT (warm) | ~250 ยตs | ~150 ยตs | ~120 ยตs | ~100 ยตs |
| Single INSERT (SaveChanges) | ~400 ยตs | n/a | ~150 ยตs | ~120 ยตs |
| 1000-row SELECT scan | ~15 ms | ~8 ms | ~5 ms | ~4 ms |
| Bulk INSERT 10k rows | ~30 s (SaveChanges) / ~3 s (BulkExtensions) | n/a | manual, ~2 s | ~150 ms (COPY) |
Bulk operations are EF Core's weakest area. SaveChanges issues one INSERT per row by default (batched, but still per-row). For high-volume inserts, use EFCore.BulkExtensions (or raw COPY for the absolute floor).
Compiled queries
For hot paths called millions of times:
private static readonly Func<IdentityDbContext, string, Task<Tenant?>> _getBySlug =
EF.CompileAsyncQuery((IdentityDbContext db, string slug) =>
db.Tenants.AsNoTracking().FirstOrDefault(t => t.Slug == slug));
// Usage
var tenant = await _getBySlug(db, slug);
Skips the query-tree-cache lookup. ~10โ20% faster than the equivalent inline LINQ. Worth it on the hottest queries only.
Connection pool tuning (Npgsql)
Npgsql has its own connection pool (separate from EF Core's DbContextPool).
| Setting | Default | Recommendation |
|---|---|---|
Max Pool Size | 100 | 25โ50 per replica for typical SaaS |
Min Pool Size | 0 | 2โ5 to avoid cold-acquire |
Connection Idle Lifetime | 300 s | leave at default |
Connection Lifetime | 0 (unlimited) | 3600 โ survives Postgres restarts gracefully |
Multiplexing | false | true for read-heavy workloads โ multiple queries share one connection (since Npgsql 5) |
Set via connection string:
Host=postgres;Database=identity;Username=app;Password=...;Maximum Pool Size=25;Connection Lifetime=3600;Multiplexing=true
Sizing rule of thumb: Max Pool Size ร replica count โค Postgres max_connections ร 0.8. Same logic as Go's pgxpool.
AsNoTracking โ the silent default you should set
The default db.Tenants.ToListAsync() adds every returned entity to the change tracker. For read-only queries this is pure waste.
// Per-query
var tenants = await db.Tenants.AsNoTracking().ToListAsync();
// Per-DbContext (recommended for read-heavy services)
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
// Per-query opt back IN when you do need tracking
var tenant = await db.Tenants.AsTracking().FirstAsync(t => t.Id == id);
Sets change tracker to NoTracking by default; opt in explicitly when you actually need to modify.
Bulk operations
EF Core's built-in INSERT path is fine for โค100 rows. For more:
EFCore.BulkExtensions
await db.BulkInsertAsync(tenants);
await db.BulkUpdateAsync(tenants);
await db.BulkInsertOrUpdateAsync(tenants);
10โ100ร faster than SaveChanges for โฅ1000 rows. Uses provider-specific bulk paths (Postgres COPY, SQL Server SqlBulkCopy).
Raw Npgsql COPY (fastest)
using var conn = (NpgsqlConnection)db.Database.GetDbConnection();
await conn.OpenAsync();
await using var writer = await conn.BeginBinaryImportAsync(
"COPY tenants (tenant_id, slug, name) FROM STDIN (FORMAT BINARY)");
foreach (var t in tenants)
{
await writer.StartRowAsync();
await writer.WriteAsync(t.TenantId, NpgsqlDbType.Uuid);
await writer.WriteAsync(t.Slug, NpgsqlDbType.Text);
await writer.WriteAsync(t.Name, NpgsqlDbType.Text);
}
await writer.CompleteAsync();
Lowest-latency path available. Directly equivalent to Go's pgx.CopyFrom.
Observability
EF Core logging
builder.Services.AddDbContext<IdentityDbContext>(options =>
options
.UseNpgsql(connStr)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging(builder.Environment.IsDevelopment())); // SQL params, dev only
Interceptors (per-query custom logic)
public class SlowQueryInterceptor : DbCommandInterceptor
{
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command, CommandExecutedEventData eventData, DbDataReader result,
CancellationToken ct = default)
{
if (eventData.Duration.TotalMilliseconds > 100)
{
Log.Warning("Slow query ({Duration}ms): {Sql}",
eventData.Duration.TotalMilliseconds, command.CommandText);
}
return await base.ReaderExecutedAsync(command, eventData, result, ct);
}
}
// Registration
options.AddInterceptors(new SlowQueryInterceptor());
OpenTelemetry instrumentation
builder.Services.AddOpenTelemetry().WithTracing(tracing => tracing
.AddNpgsql() // Npgsql-level spans
.AddEntityFrameworkCoreInstrumentation(opts => opts.SetDbStatementForText = true));
Each query becomes a span with the SQL as db.statement. Direct analog of Go's otelpgx setup.
Common pitfalls
- N+1 from lazy loading โ
tenant.Userstriggers a query inside a loop. Fix:.Include(t => t.Users)or project to a DTO. - Change tracker holding thousands of entities โ long-lived DbContext or load-everything-then-iterate patterns balloon memory. Fix:
AsNoTracking,AsAsyncEnumerable, or scope the DbContext properly. - Forgetting
AsNoTrackingon read-only queries โ silently slower and heavier than necessary. Set query tracking behavior at the DbContext level. SaveChangesfor bulk operations โ see Bulk operations.Includechains creating cartesian explosion โTenants.Include(t => t.Users).Include(t => t.ApiKeys)returnstenants ร users ร apiKeysrows. EF Core 5+ uses split queries by default for collections, but check the generated SQL.- Catching
DbUpdateExceptionwithout inspecting the inner exception โ the real Postgres error is inex.InnerException(typicallyPostgresException). - Migration race condition with multiple API replicas โ all replicas call
MigrateAsyncon startup; one wins, others see "already applied". Usually fine due to idempotency, but for safety run a separate migrator Job in K8s. MigrateAsyncfailing on startup brings the service down โ the current codebase wraps it in try/catch + degraded-mode logging. Worth keeping.
When to drop to Dapper / raw SQL
| Scenario | Use |
|---|---|
| Simple CRUD with object graphs | EF Core |
| Read-heavy reporting queries with complex joins | Dapper (faster, simpler than EF Core's translation) |
| Bulk inserts / updates | EFCore.BulkExtensions or raw COPY |
| Performance-critical hot paths (>1M/day) | Dapper or NpgsqlCommand |
| Stored procedure calls with rich return types | Dapper |
| Anywhere EF Core generates obviously bad SQL | Dapper |
Dapper is a thin layer over IDbConnection:
using var conn = new NpgsqlConnection(connStr);
var tenants = await conn.QueryAsync<Tenant>(
"SELECT tenant_id, slug, name FROM tenants WHERE slug = @Slug",
new { Slug = slug });
No change tracker, no entity model, manual mapping โ closer to Go's pgx-direct in spirit.
Comparison to Go (sqlc + pgx + goose)
| Aspect | .NET (EF Core 9) | Go (pgx + sqlc + goose) |
|---|---|---|
| Schema source of truth | C# entity model | SQL files |
| Query authoring | LINQ (primary) + raw SQL | SQL + generated Go |
| Type safety for queries | Compile-time for LINQ, runtime for raw SQL | Compile-time for sqlc, runtime for pgx-direct |
| Change tracking | Built-in, default-on | None |
| Lazy loading | Optional (default off in EF Core 5+) | None |
| Migrations generation | Auto from entity diff | Manual SQL writing |
| Migrations apply at startup | MigrateAsync() | goose.UpContext() |
| Connection pool | Npgsql pool (separate from DbContext pool) | pgxpool |
| Driver protocol | Binary (Npgsql) | Binary (pgx) |
| Bulk insert | EFCore.BulkExtensions or raw COPY | pgx.CopyFrom (native, ~150 ms / 10k rows) |
| Single-query latency | ~200โ500 ยตs (default), ~150 ยตs (AsNoTracking) | ~100โ200 ยตs |
| Memory per query | Tracker holds references (~500B/entity) | Minimal (no tracker) |
| Compile-time SQL validation | LINQ only (not raw SQL) | Full (sqlc) |
| Migration roll back | dotnet ef database update PrevName | goose down |
| Migration generation from diff | Yes (entity model diff) | No (write SQL, or use atlas for declarative) |
The deeper difference: EF Core is opinionated optimization โ it's making decisions for you (change tracking, lazy loading, query translation). When the decisions are right, it's faster to write. When they're wrong, you spend hours figuring out why a simple query is slow. sqlc+pgx is zero opinion โ every byte of SQL is yours. Less convenience, fewer surprises.
Interview talking points
- "Explain
AsNoTracking." โ Disables the change tracker for the query. The returned entities aren't added to the tracker's internal dictionary, no snapshot is taken,SaveChangeswon't touch them. Use it for read-only queries; it's a measurable perf win (~30โ40% on large result sets) and a memory win. - "What does the change tracker actually do?" โ Maintains a dictionary of
entity โ (state, original-values-snapshot). OnSaveChanges, it diffs current values against the snapshot to generate UPDATE statements only for changed properties. Costs memory; saves you from writing UPDATE statements. - "Why is
DbContextscoped, not singleton?" โDbContextholds the change tracker, the open connection, and query caches. It's not thread-safe. One per request keeps tracker scope reasonable and avoids cross-request data leakage. - "What's
ExecuteUpdateAsync?" โ EF Core 7+ bulk-update path that bypasses the change tracker.db.Tenants.Where(...).ExecuteUpdateAsync(s => s.SetProperty(...))issues a single UPDATE without loading entities. Massive perf win for bulk modifications. - "How do EF Core migrations differ from goose / golang-migrate?" โ EF Core generates the migration SQL from your entity-model diff. goose makes you write the SQL by hand. EF Core is faster for rapid iteration; goose makes SQL the source of truth (DBAs can read it directly).
- "How would you safely apply migrations across multiple API replicas?" โ Either (a) idempotency-of-MigrateAsync covers it (all replicas race, one wins, rest see "already applied") โ generally fine, or (b) run migrations as a separate K8s
Jobbefore the rolling update of API pods. - "When would you NOT use EF Core?" โ Bulk operations (use
BulkExtensionsor COPY), reporting queries with complex joins (Dapper is faster and clearer), hot paths with >1M req/day (every microsecond counts). - "How do you profile EF Core queries in production?" โ
LogTo+ interceptors for slow queries; OpenTelemetry for distributed tracing; MiniProfiler for dev; check__EFMigrationsHistoryfor drift;EXPLAIN ANALYZEthe generated SQL in psql. - "What's
DbContextpooling and when do you use it?" โAddDbContextPoolreuses DbContext instances across requests instead of allocating fresh ones. Cuts allocations on high-throughput services. Doesn't affect connection pooling (Npgsql does that separately). - "What's the equivalent of
tenant.Users.Add(...)in Go?" โ There isn't one. In Go you'd issueINSERT INTO users (..., tenant_id) VALUES (..., $1)explicitly. The lack of object-graph navigation is the trade for explicitness.
Further reading
- EF Core documentation
- Npgsql documentation
- EFCore.BulkExtensions
- Dapper
- Use the index, Luke โ DB indexing reference (language-agnostic)