Index ยท Additional notes

7 min read
16 min read
Rapid overview

Additional notes

Performance characteristics

Where EF Core spends time

  1. 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.
  2. 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.
  3. Materialization โ€” SQL result โ†’ entity instance via cached delegates. Fast after warmup.
  4. SaveChanges โ€” tracker diffs against snapshots, builds UPDATE / INSERT / DELETE statements, batches them.

Typical numbers (single Postgres, local network)

OperationEF Core 9 defaultEF Core + AsNoTrackingDapperRaw NpgsqlCommand
Single SELECT (warm)~250 ยตs~150 ยตs~120 ยตs~100 ยตs
Single INSERT (SaveChanges)~400 ยตsn/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/amanual, ~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).

SettingDefaultRecommendation
Max Pool Size10025โ€“50 per replica for typical SaaS
Min Pool Size02โ€“5 to avoid cold-acquire
Connection Idle Lifetime300 sleave at default
Connection Lifetime0 (unlimited)3600 โ€” survives Postgres restarts gracefully
Multiplexingfalsetrue 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

  1. N+1 from lazy loading โ€” tenant.Users triggers a query inside a loop. Fix: .Include(t => t.Users) or project to a DTO.
  2. 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.
  3. Forgetting AsNoTracking on read-only queries โ€” silently slower and heavier than necessary. Set query tracking behavior at the DbContext level.
  4. SaveChanges for bulk operations โ€” see Bulk operations.
  5. Include chains creating cartesian explosion โ€” Tenants.Include(t => t.Users).Include(t => t.ApiKeys) returns tenants ร— users ร— apiKeys rows. EF Core 5+ uses split queries by default for collections, but check the generated SQL.
  6. Catching DbUpdateException without inspecting the inner exception โ€” the real Postgres error is in ex.InnerException (typically PostgresException).
  7. Migration race condition with multiple API replicas โ€” all replicas call MigrateAsync on startup; one wins, others see "already applied". Usually fine due to idempotency, but for safety run a separate migrator Job in K8s.
  8. MigrateAsync failing 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

ScenarioUse
Simple CRUD with object graphsEF Core
Read-heavy reporting queries with complex joinsDapper (faster, simpler than EF Core's translation)
Bulk inserts / updatesEFCore.BulkExtensions or raw COPY
Performance-critical hot paths (>1M/day)Dapper or NpgsqlCommand
Stored procedure calls with rich return typesDapper
Anywhere EF Core generates obviously bad SQLDapper

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 truthC# entity modelSQL files
Query authoringLINQ (primary) + raw SQLSQL + generated Go
Type safety for queriesCompile-time for LINQ, runtime for raw SQLCompile-time for sqlc, runtime for pgx-direct
Change trackingBuilt-in, default-onNone
Lazy loadingOptional (default off in EF Core 5+)None
Migrations generationAuto from entity diffManual SQL writing
Migrations apply at startupMigrateAsync()goose.UpContext()
Connection poolNpgsql pool (separate from DbContext pool)pgxpool
Driver protocolBinary (Npgsql)Binary (pgx)
Bulk insertEFCore.BulkExtensions or raw COPYpgx.CopyFrom (native, ~150 ms / 10k rows)
Single-query latency~200โ€“500 ยตs (default), ~150 ยตs (AsNoTracking)~100โ€“200 ยตs
Memory per queryTracker holds references (~500B/entity)Minimal (no tracker)
Compile-time SQL validationLINQ only (not raw SQL)Full (sqlc)
Migration roll backdotnet ef database update PrevNamegoose down
Migration generation from diffYes (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, SaveChanges won'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). On SaveChanges, 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 DbContext scoped, not singleton?" โ€” DbContext holds 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 Job before the rolling update of API pods.
  • "When would you NOT use EF Core?" โ€” Bulk operations (use BulkExtensions or 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 __EFMigrationsHistory for drift; EXPLAIN ANALYZE the generated SQL in psql.
  • "What's DbContext pooling and when do you use it?" โ€” AddDbContextPool reuses 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 issue INSERT INTO users (..., tenant_id) VALUES (..., $1) explicitly. The lack of object-graph navigation is the trade for explicitness.

Further reading