Data access¶
Targets: net10.0, csharp-14 · Last reviewed: 2026-09-14 · Sources: code-with-mukesh, milan-jovanovic, shay-rojansky
EF Core is the default ORM. Write set-based work as set-based SQL; keep the change tracker for the writes that need it.
Opinions¶
- Use
ExecuteUpdateAsync/ExecuteDeleteAsyncfor updates and deletes that don't need the entities loaded. Load-modify-SaveChangespulls every row into the change tracker to emit statements the database could have generated itself; the set-based APIs issue oneUPDATE/DELETE. Independent benchmarks put the gap in the hundreds of times on 10,000-row operations. (Mukesh: Bulk operations in EF Core 10, Jovanović: What you need to know about EF Core bulk updates)
// Set-based: one statement, no entities materialized
await db.Orders
.Where(o => o.Status == OrderStatus.Pending && o.Placed < cutoff)
.ExecuteUpdateAsync(
s => s.SetProperty(o => o.Status, OrderStatus.Expired),
cancellationToken);
- Know what the set-based APIs skip, and keep audited writes on
SaveChanges. They bypass the change tracker entirely: interceptors don't fire,SaveChanges-based audit and outbox logic doesn't run, and global query filters are not applied to the predicate. A soft-delete filter you rely on everywhere else is silently missing. Spell the filter out in theWhereclause, or keep that write onSaveChanges. (Mukesh: Bulk operations in EF Core 10, Jovanović: EF Core bulk updates) - Model dates and times with the types in datetime.md, and let EF Core map them natively:
DateOnly/TimeOnlyto the SQL date and time column types on EF Core 8+, not the legacyDateTime-for-a-date shape. The provider differences and the UTC-vs-local storage decision live in datetime.md. - On PostgreSQL, store instants as
DateTimewithKind.Utcand let Npgsql map them totimestamptz. Despite its nametimestamptzstores a UTC instant and no zone at all, so the offset on aDateTimeOffsethas nowhere to round-trip to: Npgsql rejects anyDateTimeOffsetwhose offset is non-zero, mapsKind.Utctotimestamptz, and mapsLocal/Unspecifiedto plaintimestamp. Read the resulting "UTC everywhere" rule as provider-shaped rather than domain-shaped: it describes what the column can faithfully round-trip, not what the domain is allowed to forget. It therefore sits underneath the split in datetime.md rather than against it: the instant column is UTC either way, and a future or recurring human-scheduled event still needs its local time and IANA zone id in their own columns beside it. (Rojansky: PostgreSQL/.NET timestamp mapping) - Choose indexes from the queries the application runs, then prove each one with
EXPLAIN ANALYZE. The schema does not tell you what to index; the access paths do. For a B-tree, lead with the columns compared by equality and put the column you range over or sort by last. Measure before and after, because the plan is the only evidence that the index is used at all. Jovanović's issue-tracker demo over a million comments takes a count from a 17ms sequential scan to a 0.6ms index-only scan, and the issue-and-user query from 436ms to roughly half a millisecond. Those are his numbers on his data, so reproduce them on yours. (Jovanović: SQL indexing explained: composite indexes and column order) - A global query filter is a convenience, not tenant isolation: put row-level security under it. EF Core adds the predicate to the SQL it generates and to nothing else, so
ExecuteSql, an attached entity you save, and anything behindIgnoreQueryFilters()walk straight past it. A PostgreSQL policy attaches to every statement for every non-exempt role, so it holds when someone forgets the filter. Three details decide whether it actually holds. Connect as a role that owns nothing, because owners, superusers andBYPASSRLSroles skip policies, and run migrations as a separate owner. AddFORCE ROW LEVEL SECURITYso the owner is covered too. Write the predicate so an unset tenant matches no rows instead of every row. (Jovanović: PostgreSQL row-level security with EF Core and Npgsql) - Set the tenant when the connection opens, not when the request begins. EF Core opens a connection per command and closes it afterwards, and Npgsql resets pooled session state, so a
SETissued at the start of a request lands on a connection the next query never sees. Disabling that reset is worse, because the pooled connection then arrives carrying the previous request's tenant. ADbConnectionInterceptoroverridingConnectionOpenedruns on whichever physical connection the pool hands out, which is the only place the setting is reliably in scope. (Jovanović: PostgreSQL row-level security with EF Core and Npgsql) - For inserts, batched
SaveChangesis the default; switch to a bulk-copy path only above roughly ten thousand rows.AddRange+ oneSaveChangeskeeps interceptors and audit trails working and is fast enough for ordinary write paths. Adding entities one at a time in a loop is the anti-pattern, an order of magnitude slower than the batched call for no benefit. (Mukesh: Fastest way to bulk insert thousands of rows in EF Core)
Source redundancy¶
code-with-mukesh (marked Corroborate.) and milan-jovanovic were both admitted on 2026-08-14 under the lowered longevity bars, and the benchmark numbers behind the first opinion are theirs, not reproduced here. The shape of the guidance (set-based writes for set-based work; change-tracker semantics are the trade) is corroborated across both; the specific ratios are not this repository's claim. Re-verify against Microsoft Learn's EF Core documentation before quoting figures.
shay-rojansky carries a recorded independence limit: he maintains Npgsql and is on Microsoft's EF Core team, so the mapping rules above are cited as mechanism, which is what his notes permit. The judgment of when to use those types is jon-skeet's in datetime.md, not his.