Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions entity-framework/core/logging-events-diagnostics/interceptors.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Interceptors - EF Core
description: Interception for database operations and other events
author: SamMonoRT
ms.date: 02/26/2026
ms.date: 08/19/2026
uid: core/logging-events-diagnostics/interceptors
ms.custom: sfi-ropc-nochange
---
Expand All @@ -24,7 +24,7 @@ The following table shows the available interceptor interfaces:
| <xref:Microsoft.EntityFrameworkCore.Diagnostics.IDbCommandInterceptor> | Creating commands</br>Executing commands</br>Command failures</br>Disposing the command's DbDataReader | No |
| <xref:Microsoft.EntityFrameworkCore.Diagnostics.IDbConnectionInterceptor> | Opening and closing connections</br>Creating connections</br>Connection failures | No |
| <xref:Microsoft.EntityFrameworkCore.Diagnostics.IDbTransactionInterceptor> | Creating transactions</br>Using existing transactions</br>Committing transactions</br>Rolling back transactions</br>Creating and using savepoints</br>Transaction failures | No |
| <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor> | SavingChanges/SavedChanges</br>SaveChangesFailed</br>Optimistic concurrency handling | No |
| <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor> | SavingChanges/SavedChanges</br>SaveChangesFailed/SaveChangesCanceled</br>Optimistic concurrency handling | No |
| <xref:Microsoft.EntityFrameworkCore.Diagnostics.IMaterializationInterceptor> | Creating, initializing, and finalizing entity instances from query results | Yes |
| <xref:Microsoft.EntityFrameworkCore.Diagnostics.IQueryExpressionInterceptor> | Modifying the LINQ expression tree before a query is compiled | Yes |
| <xref:Microsoft.EntityFrameworkCore.Diagnostics.IIdentityResolutionInterceptor> | Resolving identity conflicts when tracking entities | Yes |
Expand Down Expand Up @@ -705,7 +705,7 @@ The general idea for auditing with the interceptor is:
* An audit message is created at the beginning of SaveChanges and is written to the auditing database
* SaveChanges is allowed to continue
* If SaveChanges succeeds, then the audit message is updated to indicate success
* If SaveChanges fails, then the audit message is updated to indicate the failure
* If SaveChanges fails or is canceled, then the audit message is updated to indicate the outcome

The first stage is handled before any changes are sent to the database using overrides of <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor.SavingChanges*?displayProperty=nameWithType> and <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor.SavingChangesAsync*?displayProperty=nameWithType>.

Expand Down Expand Up @@ -841,9 +841,12 @@ The audit entity is stored on the interceptor so that it can be accessed again o

The audit entity is attached to the audit context, since it already exists in the database and needs to be updated. We then set `Succeeded` and `EndTime`, which marks these properties as modified so SaveChanges will send an update to the audit database.

#### Detecting failure
#### Detecting failure, cancellation, and concurrency conflicts

Failure is handled in much the same way as success, but in the <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor.SaveChangesFailed*?displayProperty=nameWithType> or <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor.SaveChangesFailedAsync*?displayProperty=nameWithType> method. The event data contains the exception that was thrown.
Ordinary failures are handled in much the same way as success, but in the <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor.SaveChangesFailed*?displayProperty=nameWithType> or <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor.SaveChangesFailedAsync*?displayProperty=nameWithType> method. The event data contains the exception that was thrown.

> [!NOTE]
> `SaveChangesFailed` and `SaveChangesFailedAsync` are not called for every exception. A canceled operation calls <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor.SaveChangesCanceled*?displayProperty=nameWithType> or <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor.SaveChangesCanceledAsync*?displayProperty=nameWithType>. A <xref:Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException> calls <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor.ThrowingConcurrencyException*?displayProperty=nameWithType> or <xref:Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor.ThrowingConcurrencyExceptionAsync*?displayProperty=nameWithType> before the exception is thrown. It does not call `SaveChangesFailed` or `SaveChangesFailedAsync`.

<!--
public void SaveChangesFailed(DbContextErrorEventData eventData)
Expand Down Expand Up @@ -876,6 +879,14 @@ Failure is handled in much the same way as success, but in the <xref:Microsoft.E
-->
[!code-csharp[SaveChangesFailed](../../../samples/core/Miscellaneous/SaveChangesInterception/AuditingInterceptor.cs?name=SaveChangesFailed)]

The auditing interceptor also handles cancellation separately. The asynchronous callback does not pass the canceled token to the audit operation, so that the outcome can still be recorded.

[!code-csharp[SaveChangesCanceled](../../../samples/core/Miscellaneous/SaveChangesInterception/AuditingInterceptor.cs?name=SaveChangesCanceled)]

Concurrency interception occurs immediately before EF Core throws the exception and allows the exception to be suppressed. This interceptor records the conflict and returns the existing result without suppressing it.

[!code-csharp[ThrowingConcurrencyException](../../../samples/core/Miscellaneous/SaveChangesInterception/AuditingInterceptor.cs?name=ThrowingConcurrencyException)]

#### Demonstration

The [auditing sample](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Miscellaneous/SaveChangesInterception) contains a simple console application that makes changes to the blogging database and then shows the auditing that was created.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Breaking changes in EF Core 9 (EF9) - EF Core
description: List of breaking changes introduced in Entity Framework Core 9 (EF9)
author: SamMonoRT
ms.date: 01/17/2025
ms.date: 08/19/2026
uid: core/what-is-new/ef-core-9.0/breaking-changes
---

Expand Down Expand Up @@ -37,6 +37,8 @@ EF Core 9 targets .NET 8. This means that existing applications that target .NET
| [`EF.Constant()` and `EF.Parameter()` no longer work inside compiled queries](#ef-constant-compiled) | Low |
| [Some `NoTrackingWithIdentityResolution` queries are now prohibited for JSON collections](#no-tracking-json) | Low |
| [All pending migrations are applied in a single transaction](#migrations-single-transaction) | Low |
| [Primitive collection APIs were replaced](#primitive-collection-apis) | Low |
| [SQL Server `IndexOf` now propagates null values](#indexof-null) | Low |

## High-impact changes

Expand Down Expand Up @@ -233,6 +235,69 @@ var binaryData = await context.Blogs.Select(b => EF.Functions.Unhex(b.HexString)

Otherwise, add runtime checks for null on the return value of Unhex().

<a name="primitive-collection-apis"></a>

### Primitive collection APIs were replaced

[Tracking Issue #33456](https://github.com/dotnet/efcore/issues/33456)

#### Old behavior

In EF Core 8, the following APIs were used for comparing and JSON serialization of primitive collections:

- `ListComparer<TElement>`
- `NullableValueTypeListComparer<TElement>`
- `ObjectListComparer<TElement>`
- `JsonCollectionReaderWriter<TCollection, TConcreteCollection, TElement>`
- `JsonNullableStructCollectionReaderWriter<TCollection, TConcreteCollection, TElement>`

#### New behavior

Starting with EF Core 9.0, these APIs have been replaced by APIs specialized for the collection element type:

| **EF Core 8 API** | **EF Core 9 API** |
|:------------------|:------------------|
| `ListComparer<TElement>` | `ListOfValueTypesComparer<TConcreteList, TElement>` for non-nullable value types, or `ListOfReferenceTypesComparer<TConcreteList, TElement>` for reference types |
| `NullableValueTypeListComparer<TElement>` | `ListOfNullableValueTypesComparer<TConcreteList, TElement>` |
| `ObjectListComparer<TElement>` | `ListOfReferenceTypesComparer<TConcreteList, TElement>` |
| `JsonCollectionReaderWriter<TCollection, TConcreteCollection, TElement>` | `JsonCollectionOfStructsReaderWriter<TConcreteCollection, TElement>` or `JsonCollectionOfReferencesReaderWriter<TConcreteCollection, TElement>` |
| `JsonNullableStructCollectionReaderWriter<TCollection, TConcreteCollection, TElement>` | `JsonCollectionOfNullableStructsReaderWriter<TConcreteCollection, TElement>` |

#### Why

Primitive collection infrastructure was consolidated across relational and Azure Cosmos DB providers. The new APIs use the exact concrete collection type when creating snapshots and distinguish between non-nullable value types, nullable value types, and reference types. This enables support for nested and read-only collections.

#### Mitigations

Code that directly uses one of the removed APIs must use the replacement appropriate for its element type. Supply the concrete collection type, such as `List<int>`, as `TConcreteList` or `TConcreteCollection`, and the element type as `TElement`.

<a name="indexof-null"></a>

### SQL Server `IndexOf` now propagates null values

[Tracking Issue #18773](https://github.com/dotnet/efcore/issues/18773)

#### Old behavior

Previously, when SQL Server translated `IndexOf` with an empty search string, it returned `0` even if the string being searched was `null`.

#### New behavior

Starting with EF Core 9.0, SQL Server returns `null` for `IndexOf` when the string being searched is `null`. This matches the translation used by SQLite.

#### Why

The new translation follows standard null propagation and no longer treats a null string as containing an empty string at index zero.

#### Mitigations

To retain the previous behavior, handle null explicitly in the query:

```csharp
var indexes = context.Entities
.Select(e => e.NullableString == null ? 0 : e.NullableString.IndexOf(string.Empty));
```

<a name="compiled-model-private-methods"></a>

### Compiled models now reference value converter methods directly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,31 +79,64 @@ public async ValueTask<int> SavedChangesAsync(

#region SaveChangesFailed
public void SaveChangesFailed(DbContextErrorEventData eventData)
=> UpdateAudit(eventData.Exception.Message);

public Task SaveChangesFailedAsync(
DbContextErrorEventData eventData,
CancellationToken cancellationToken = default)
=> UpdateAuditAsync(eventData.Exception.Message, cancellationToken);
#endregion

#region SaveChangesCanceled
public void SaveChangesCanceled(DbContextEventData eventData)
=> UpdateAudit("SaveChanges was canceled.");

public Task SaveChangesCanceledAsync(
DbContextEventData eventData,
CancellationToken cancellationToken = default)
=> UpdateAuditAsync("SaveChanges was canceled.", CancellationToken.None);
#endregion

#region ThrowingConcurrencyException
public InterceptionResult ThrowingConcurrencyException(
ConcurrencyExceptionEventData eventData,
InterceptionResult result)
{
using var auditContext = new AuditContext(_connectionString);
UpdateAudit(eventData.Exception.Message);
return result;
}

auditContext.Attach(_audit);
_audit.Succeeded = false;
_audit.EndTime = DateTime.UtcNow;
_audit.ErrorMessage = eventData.Exception.Message;
public async ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
ConcurrencyExceptionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
await UpdateAuditAsync(eventData.Exception.Message, cancellationToken);
return result;
}
#endregion

private void UpdateAudit(string errorMessage)
{
using var auditContext = new AuditContext(_connectionString);
UpdateAudit(errorMessage, auditContext);
auditContext.SaveChanges();
}

public async Task SaveChangesFailedAsync(
DbContextErrorEventData eventData,
CancellationToken cancellationToken = default)
private async Task UpdateAuditAsync(string errorMessage, CancellationToken cancellationToken)
{
using var auditContext = new AuditContext(_connectionString);
UpdateAudit(errorMessage, auditContext);
await auditContext.SaveChangesAsync(cancellationToken);
}

private void UpdateAudit(string errorMessage, AuditContext auditContext)
{
auditContext.Attach(_audit);
_audit.Succeeded = false;
_audit.EndTime = DateTime.UtcNow;
_audit.ErrorMessage = eventData.Exception.InnerException?.Message;

await auditContext.SaveChangesAsync(cancellationToken);
_audit.ErrorMessage = errorMessage;
}
#endregion

#region CreateAudit
private static SaveChangesAudit CreateAudit(DbContext context)
Expand Down
Loading