Breaking changes in EF Core 9 (EF9)

This page documents API and behavior changes that have the potential to break existing applications updating from EF Core 8 to EF Core 9. Make sure to review earlier breaking changes if updating from an earlier version of EF Core:

Target Framework

EF Core 9 targets .NET 8. This means that existing applications that target .NET 8 can continue to do so. Applications targeting older .NET, .NET Core, and .NET Framework versions will need to target .NET 8 or .NET 9 to use EF Core 9.

Summary

Note

If you are using Azure Cosmos DB, please see the separate section below on Azure Cosmos DB breaking changes.

Breaking change Impact
Exception is thrown when applying migrations if there are pending model changes High
EF.Functions.Unhex() now returns byte[]? Low
SqlFunctionExpression's nullability arguments' arity validated Low
ToString() method now returns empty string for null instances Low
Shared framework dependencies were updated to 9.0.x Low

High-impact changes

Exception is thrown when applying migrations if there are pending model changes

Tracking Issue #33732

Old behavior

If the model has pending changes compared to the last migration they are not applied with the rest of the migrations when Migrate is called.

New behavior

Starting with EF Core 9.0, if the model has pending changes compared to the last migration an exception is thrown when dotnet ef database update, Migrate or MigrateAsync is called:

The model for context 'DbContext' has pending changes. Add a new migration before updating the database. This exception can be suppressed or logged by passing event ID 'RelationalEventId.PendingModelChangesWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.

Why

Forgetting to add a new migration after making model changes is a common mistake that can be hard to diagnose in some cases. The new exception ensures that the app's model matches the database after the migrations are applied.

Mitigations

There are several common situations when this exception can be thrown:

  • There are no migrations at all. This is common when the database is updated through other means.
    • Mitigation: If you don't plan to use migrations for managing the database schema then remove the Migrate or MigrateAsync call, otherwise add a migration.
  • There is at least one migration, but the model snapshot is missing. This is common for migrations created manually.
    • Mitigation: Add a new migration using EF tooling, this will update the model snapshot.
  • The model wasn't modified by the developer, but it's built in a non-deterministic way causing EF to detect it as modified. This is common when new DateTime(), DateTime.Now, DateTime.UtcNow, or Guid.NewGuid() are used in objects supplied to HasData().
    • Mitigation: Add a new migration, examine its contents to locate the cause, and replace the dynamic data with a static, hardcoded value in the model. The migration should be recreated after the model is fixed. If dynamic data has to be used for seeding consider using the new seeding pattern instead of HasData().
  • The last migration was created for a different provider than the one used to apply the migrations.
  • The migrations are generated or choosen dynamically by replacing some of the EF services.
    • Mitigation: The warning is a false positive in this case and should be suppressed:

      options.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))

If your scenario doesn't fall under any of the above cases and adding a new migration creates the same migration each time or an empty migration and the exception is still thrown then create a small repro project and share it with the EF team in a new issue.

Low-impact changes

EF.Functions.Unhex() now returns byte[]?

Tracking Issue #33864

Old behavior

The EF.Functions.Unhex() function was previously annotated to return byte[].

New behavior

Starting with EF Core 9.0, Unhex() is now annotated to return byte[]?.

Why

Unhex() is translated to the SQLite unhex function, which returns NULL for invalid inputs. As a result, Unhex() returned null for those cases, in violation of the annotation.

Mitigations

If you are sure that the text content passed to Unhex() represents a valid, hexadecimal string, you can simply add the null-forgiving operator as an assertion that the invocation will never return null:

var binaryData = await context.Blogs.Select(b => EF.Functions.Unhex(b.HexString)!).ToListAsync();

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

SqlFunctionExpression's nullability arguments' arity validated

Tracking Issue #33852

Old behavior

Previously it was possible to create a SqlFunctionExpression with a different number of arguments and nullability propagation arguments.

New behavior

Starting with EF Core 9.0, EF now throws if the number of arguments and nullability propagation arguments do not match.

Why

Not having matching number of arguments and nullability propagation arguments can lead to unexpected behavior.

Mitigations

Make sure the argumentsPropagateNullability has same number of elements as the arguments. When in doubt use false for nullability argument.

ToString() method now returns empty string for null instances

Tracking Issue #33941

Old behavior

Previously EF returned inconsistent results for the ToString() method when the argument value was null. E.g. ToString() on bool? property with null value returned null, but for non-property bool? expressions whose value was null it returned True. The behavior was also inconsistent for other data types, e.g. ToString() on null value enum returned empty string.

New behavior

Starting with EF Core 9.0, the ToString() method now consistently returns empty string in all cases when the argument value is null.

Why

The old behavior was inconsistent across different data types and situations, as well as not aligned with the C# behavior.

Mitigations

To revert to the old behavior, rewrite the query accordingly:

var newBehavior = context.Entity.Select(x => x.NullableBool.ToString());
var oldBehavior = context.Entity.Select(x => x.NullableBool == null ? null : x.NullableBool.ToString());

Shared framework dependencies were updated to 9.0.x

Old behavior

Apps using the Microsoft.NET.Sdk.Web SDK and targetting net8.0 would resolve packages like System.Text.Json, Microsoft.Extensions.Caching.Memory, Microsoft.Extensions.Configuration.Abstractions, Microsoft.Extensions.Logging and Microsoft.Extensions.DependencyModel from the shared framework, so these assemblies wouldn't normally be deployed with the app.

New behavior

While EF Core 9.0 still supports net8.0 it now references the 9.0.x versions of System.Text.Json, Microsoft.Extensions.Caching.Memory, Microsoft.Extensions.Configuration.Abstractions, Microsoft.Extensions.Logging and Microsoft.Extensions.DependencyModel. Apps targetting net8.0 will not be able to leverage the shared framework to avoid deploying these assemblies.

Why

The matching dependency versions contain the latest security fixes and using them simplifies the servicing model for EF Core.

Mitigations

Change your app to target net9.0 to get the previous behavior.

Azure Cosmos DB breaking changes

Extensive work has gone into making the Azure Cosmos DB provider better in 9.0. The changes include a number of high-impact breaking changes; if you are upgrading an existing application, please read the following carefully.

Breaking change Impact
The discriminator property is now named $type instead of Discriminator High
The id property no longer contains the discriminator by default High
Sync I/O via the Azure Cosmos DB provider is no longer supported Medium
SQL queries must now project JSON values directly Medium
Undefined results are now automatically filtered from query results Medium
Incorrectly translated queries are no longer translated Medium
HasIndex now throws instead of being ignored Low
IncludeRootDiscriminatorInJsonId was renamed to HasRootDiscriminatorInJsonId after 9.0.0-rc.2 Low

High-impact changes

The discriminator property is now named $type instead of Discriminator

Tracking Issue #34269

Old behavior

EF automatically adds a discriminator property to JSON documents to identify the entity type that the document represents. In previous versions of EF, this JSON property used to be named Discriminator by default.

New behavior

Starting with EF Core 9.0, the discriminator property is now called $type by default. If you have existing documents in Azure Cosmos DB from previous versions of EF, these use the old Discriminator naming, and after upgrading to EF 9.0, queries against those documents will fail.

Why

An emerging JSON practice uses a $type property in scenarios where a document's type needs to be identified. For example, .NET's System.Text.Json also supports polymorphism, using $type as its default discriminator property name (docs). To align with the rest of the ecosystem and make it easier to interoperate with external tools, the default was changed.

Mitigations

The easiest mitigation is to simply configure the name of the discriminator property to be Discriminator, just as before:

modelBuilder.Entity<Session>().HasDiscriminator<string>("Discriminator");

Doing this for all your top-level entity types will make EF behave just like before.

At this point, if you wish, you can also update all your documents to use the new $type naming.

The id property now contains only the EF key property by default

Tracking Issue #34179

Old behavior

Previously, EF inserted the discriminator value of your entity type into the id property of the document. For example, if you saved a Blog entity type with an Id property containing 8, the JSON id property would contain Blog|8.

New behavior

Starting with EF Core 9.0, the JSON id property no longer contains the discriminator value, and only contains the value of your key property. For the above example, the JSON id property would simply be 8. If you have existing documents in Azure Cosmos DB from previous versions of EF, these have the discriminator value in the JSON id property, and after upgrading to EF 9.0, queries against those documents will fail.

Why

Since the JSON id property must be unique, the discriminator was previously added to it to allow different entities with the same key value to exist. For example, this allowed having both a Blog and a Post with an Id property containing the value 8 within the same container and partition. This aligned better with relational database data modeling patterns, where each entity type is mapped to its own table, and therefore has its own key-space.

EF 9.0 generally changed the mapping to be more aligned with common Azure Cosmos DB NoSQL practices and expectations, rather than to correspond to the expectations of users coming from relational databases. In addition, having the discriminator value in the id property made it more difficult for external tools and systems to interact with EF-generated JSON documents; such external systems aren't generally aware of the EF discriminator values, which are by default derived from .NET types.

Mitigations

The easiest mitigation is to simply configure EF to include the discriminator in the JSON id property, as before. A new configuration option has been introduced for this purpose:

modelBuilder.Entity<Session>().HasDiscriminatorInJsonId();

Doing this for all your top-level entity types will make EF behave just like before.

At this point, if you wish, you can also update all your documents to rewrite their JSON id property. Note that this is only possible if entities of different types don't share the same id value within the same container.

Medium-impact changes

Sync I/O via the Azure Cosmos DB provider is no longer supported

Tracking Issue #32563

Old behavior

Previously, calling synchronous methods like ToList or SaveChanges would cause EF Core to block synchronously using .GetAwaiter().GetResult() when executing async calls against the Azure Cosmos DB SDK. This can result in deadlock.

New behavior

Starting with EF Core 9.0, EF now throws by default when attempting to use synchronous I/O. The exception message is "Azure Cosmos DB does not support synchronous I/O. Make sure to use and correctly await only async methods when using Entity Framework Core to access Azure Cosmos DB. See https://aka.ms/ef-cosmos-nosync for more information."

Why

Synchronous blocking on asynchronous methods can result in deadlock, and the Azure Cosmos DB SDK only supports async methods.

Mitigations

In EF Core 9.0, the error can be suppressed with:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.ConfigureWarnings(w => w.Ignore(CosmosEventId.SyncNotSupported));
}

That being said, applications should stop using sync APIs with Azure Cosmos DB since this is not supported by the Azure Cosmos DB SDK. The ability to suppress the exception will be removed in a future release of EF Core, after which the only option will be to use async APIs.

SQL queries must now project JSON values directly

Tracking Issue #25527

Old behavior

Previously, EF generated queries such as the following:

SELECT c["City"] FROM root c

Such queries cause Azure Cosmos DB to wrap each result in a JSON object, as follows:

[
    {
        "City": "Berlin"
    },
    {
        "City": "México D.F."
    }
]
New behavior

Starting with EF Core 9.0, EF now adds the VALUE modifier to queries as follows:

SELECT VALUE c["City"] FROM root c

Such queries cause Azure Cosmos DB to return the values directly, without being wrapped:

[
    "Berlin",
    "México D.F."
]

If your application makes use of SQL queries, such queries are likely broken after upgrading to EF 9.0, as they don't include the VALUE modifier.

Why

Wrapping each result in an additional JSON object can cause performance degradation in some scenarios, bloats the JSON result payload, and isn't the natural way to work with Azure Cosmos DB.

Mitigations

To mitigate, simply add the VALUE modifier to the projections of your SQL queries, as shown above.

Undefined results are now automatically filtered from query results

Tracking Issue #25527

Old behavior

Previously, EF generated queries such as the following:

SELECT c["City"] FROM root c

Such queries cause Azure Cosmos DB to wrap each result in a JSON object, as follows:

[
    {
        "City": "Berlin"
    },
    {
        "City": "México D.F."
    }
]

If any of the results were undefined (e.g. the City property was absent from the document), an empty document was returned, and EF would return null for that result.

New behavior

Starting with EF Core 9.0, EF now adds the VALUE modifier to queries as follows:

SELECT VALUE c["City"] FROM root c

Such queries cause Azure Cosmos DB to return the values directly, without being wrapped:

[
    "Berlin",
    "México D.F."
]

The Azure Cosmos DB behavior is to automatically filter undefined values out of results; this means that if one of the City properties is absent from the document, the query would return just a single result, rather than two results, with one being null.

Why

Wrapping each result in an additional JSON object can cause performance degradation in some scenarios, bloats the JSON result payload, and isn't the natural way to work with Azure Cosmos DB.

Mitigations

If getting null values for undefined results is important for your application, coalesce the undefined values to null using the new EF.Functions.Coalesce operator:

var users = await context.Customer
    .Select(c => EF.Functions.CoalesceUndefined(c.City, null))
    .ToListAsync();

Incorrectly translated queries are no longer translated

Tracking Issue #34123

Old behavior

Previously, EF translated queries such as the following:

var sessions = await context.Sessions
    .Take(5)
    .Where(s => s.Name.StartsWith("f"))
    .ToListAsync();

However, the SQL translation for this query was incorrect:

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Session") AND STARTSWITH(c["Name"], "f"))
OFFSET 0 LIMIT @__p_0

In SQL, the WHERE clause is evaluated before the OFFSET and LIMIT clauses; but in the LINQ query above, the Take operator appears before the Where operator. As a result, such queries could return incorrect results.

New behavior

Starting with EF Core 9.0, such queries are no longer translated, and an exception is thrown.

Why

Incorrect translations can cause silent data corruption, which can introduce hard-to-discover bugs in your application. EF always prefer to fail-fast by throwing up-front rather than to possibly cause data corruption.

Mitigations

If you were happy with the previous behavior and would like to execute the same SQL, simply swap around the order of LINQ operators:

var sessions = await context.Sessions
    .Where(s => s.Name.StartsWith("f"))
    .Take(5)
    .ToListAsync();

Unfortunately, Azure Cosmos DB does not currently support the OFFSET and LIMIT clauses in SQL subqueries, which is what the proper translation of the original LINQ query requires.

Low-impact changes

HasIndex now throws instead of being ignored

Tracking Issue #34023

Old behavior

Previously, calls to HasIndex were ignored by the EF Cosmos DB provider.

New behavior

The provider now throws if HasIndex is specified.

Why

In Azure Cosmos DB, all properties are indexed by default, and no indexing needs to be specified. While it's possible to define a custom indexing policy, this isn't currently supported by EF, and can be done via the Azure Portal without EF support. Since HasIndex calls weren't doing anything, they are no longer allowed.

Mitigations

Remove any calls to HasIndex.

IncludeRootDiscriminatorInJsonId was renamed to HasRootDiscriminatorInJsonId after 9.0.0-rc.2

Tracking Issue #34717

Old behavior

The IncludeRootDiscriminatorInJsonId API was introduced in 9.0.0 rc.1.

New behavior

For the final release of EF Core 9.0, the API was renamed to HasRootDiscriminatorInJsonId

Why

Another related API was renamed to start with Has instead of Include, and so this one was renamed for consistency as well.

Mitigations

If your code is using the IncludeRootDiscriminatorInJsonId API, simply change it to reference HasRootDiscriminatorInJsonId instead.