Interceptor writer
HistoryWriter.Interceptor writes history from a SaveChangesInterceptor in your application, in the same transaction as the change. It is the default writer. Use it where the role that applies migrations cannot create functions and triggers; otherwise prefer the trigger writer.
It only sees SaveChanges
ExecuteUpdate, ExecuteDelete, writes through FromSql or SqlQuery, and changes made by other processes never reach a SaveChangesInterceptor. They write no history under this writer.
HDST001: analyzer diagnostic
An analyzer bundled with the package flags an ExecuteUpdate / ExecuteUpdateAsync / ExecuteDelete / ExecuteDeleteAsync call against an entity your project configures with IsTemporal():
// Policy is IsTemporal() (see AppDbContext above), so this line raises HDST001: under
// HistoryWriter.Interceptor (the default) it bypasses SaveChanges and writes no history.
await db.Policies.ExecuteUpdateAsync(s => s.SetProperty(p => p.Status, PolicyStatus.Cancelled));It reports at Info severity, not Warning: from source alone the analyzer can prove the entity is temporal, but it cannot see which HistoryWriter your application actually uses — that's a runtime UseHindsight(...) call, possibly in a different project entirely (an ASP.NET host's Program.cs configuring a DbContext whose entities and repositories live in a separate class library, for example). So it fires on every such call regardless of writer mode, and the diagnostic message says so. Read it as "go check", not as "history is definitely being lost".
If you're using HistoryWriter.Trigger, the call is fine as-is — the database trigger records it whichever way the row changed — and HDST001 is a known false positive. Suppress it at the call site once you've verified that:
// This DbContext is configured with HistoryWriter.Trigger (see Choosing a history writer), so
// the database trigger records this write regardless - HDST001 doesn't know that and still
// fires. Verified at this call site, so it's suppressed rather than worked around.
#pragma warning disable HDST001 // verified: this DbContext uses HistoryWriter.Trigger
await db.Policies.ExecuteUpdateAsync(s => s.SetProperty(p => p.Status, PolicyStatus.Cancelled));
#pragma warning restore HDST001See DESIGN.md D4 for the full reasoning behind reporting this way instead of trying (and failing) to detect the writer mode itself.
How a SaveChanges is recorded
Before the save, the interceptor snapshots every temporal entity that is
Added,ModifiedorDeleted, and drops anyModifiedentity whose only changes are to excluded properties. It takes one timestamp fromTimeProviderfor the whole save, and opens a transaction if you have none (see Transactions).For each
Deletedentity it re-reads the row from the database by key (SELECT … FOR UPDATE). An entity deleted without being loaded first, such asRemove(new Policy { Id = id, … }), only holds default values, and those must not end up in the tombstone. If the row no longer exists, the save throwsInvalidOperationException.The save runs as usual.
After the save, when store-generated keys are known, it writes history for every entity in a single batched round trip (split every 512 rows):
Added: inserts an open version;Modified: closes the open version and inserts the new one;Deleted: closes the open version and inserts a tombstone.
It commits the transaction if it opened it.
The open version is closed at GREATEST(@timestamp, valid_from + interval '1 microsecond'), the same clamp as the trigger writer. A save that captured its timestamp and then waited for a row lock, or a clock that stepped backwards, still produces a contiguous chain of positive periods. In exactly those cases, the new version starts slightly later than the time TimeProvider reported.
Values are sent as parameters with each column's own type mapping, so an enum stored as text, a jsonb column or a numeric(18,4) column lands in history exactly as in the main table.
If the history write fails
The history is written after EF Core has sent the change and accepted it in the change tracker. If the history write then fails, for example because of invalid JSON in ChangeContext.Extra or a dropped connection:
- The database is always correct. The interceptor rolls back its transaction, so neither the change nor its history is stored.
AddedandModifiedentities are restored to their state before the save, so catching the exception and callingSaveChangesagain retries the whole change.Deletedentities stay detached. EF Core has already removed them from the change tracker, so a retry does nothing for them. Query the entity again and remove it again.- An
Addedentity with a store-generated key keeps the key it received in the rolled-back transaction. See Limitations for what a retry does with it.
The trigger writer does not have this gap: its history is written in the same statement as the change.
Your own interceptors run after Hindsight's
A SaveChangesInterceptor you add with AddInterceptors(...) always runs after Hindsight's, no matter where it appears relative to UseHindsight(...). That is EF Core's ordering, not a setting.
So if your interceptor changes an entity's state inside SavingChanges, for example an audit stamp that flips an Unchanged entity to Modified, Hindsight has already taken its snapshot. The update reaches the main table, but no version is written for it, and no exception is thrown.
If you rely on such an interceptor, use the trigger writer: the trigger sees the UPDATE in the database, whoever caused it.
Cost
About 1.7× to 3× the time of a plain SaveChanges, depending on the operation, and one extra round trip for a save that deletes a temporal entity. See the benchmarks.