Breaking changes in EF Core 7.0 (EF7)

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

Target Framework

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

Summary

Breaking change Impact
Encrypt defaults to true for SQL Server connections High
Some warnings will again throw exceptions by default High
SQL Server tables with triggers or certain computed columns now require special EF Core configuration High
SQLite tables with AFTER triggers and virtual tables now require special EF Core configuration High
Orphaned dependents of optional relationships are not automatically deleted Medium
Cascade delete is configured between tables when using TPT mapping with SQL Server Medium
Higher chance of busy/locked errors on SQLite when not using write-ahead logging Medium
Key properties may need to be configured with a provider value comparer Low
Check constraints and other table facets are now configured on the table Low
Navigations from new entities to deleted entities are not fixed up Low
Using FromSqlRaw and related methods from the wrong provider throws Low
Scaffolded OnConfiguring no longer calls IsConfigured Low

High-impact changes

Encrypt defaults to true for SQL Server connections

Tracking Issue: SqlClient #1210

Important

This is a severe breaking change in the Microsoft.Data.SqlClient package. There is nothing that can be done in EF Core to revert or mitigate this change. Please direct feedback to the Microsoft.Data.SqlClient GitHub Repo or contact a Microsoft Support Professional for additional questions or help.

Old behavior

SqlClient connection strings use Encrypt=False by default. This allows connections on development machines where the local server does not have a valid certificate.

New behavior

SqlClient connection strings use Encrypt=True by default. This means that:

  • The server must be configured with a valid certificate
  • The client must trust this certificate

If these conditions are not met, then a SqlException will be thrown. For example:

A connection was successfully established with the server, but then an error occurred during the login process. (provider: SSL Provider, error: 0 - The certificate chain was issued by an authority that is not trusted.)

Why

This change was made to ensure that, by default, either the connection is secure or the application will fail to connect.

Mitigations

There are three ways to proceed:

  1. Install a valid certificate on the server. Note that this is an involved process and requires obtaining a certificate and ensuring it is signed by an authority trusted by the client.
  2. If the server has a certificate, but it is not trusted by the client, then TrustServerCertificate=True to allow bypassing the normal trust mechanims.
  3. Explicitly add Encrypt=False to the connection string.

Warning

Options 2 and 3 both leave the server in a potentially insecure state.

Some warnings throw exceptions by default again

Tracking Issue #29069

Old behavior

In EF Core 6.0, a bug in the SQL Server provider meant that some warnings that are configured to throw exceptions by default were instead being logged but not throwing exceptions. These warnings are:

EventId Description
RelationalEventId.AmbientTransactionWarning An application may have expected an ambient transaction to be used when it was actually ignored.
RelationalEventId.IndexPropertiesBothMappedAndNotMappedToTable An index specifies properties some of which are mapped and some of which are not mapped to a column in a table.
RelationalEventId.IndexPropertiesMappedToNonOverlappingTables An index specifies properties which map to columns on non-overlapping tables.
RelationalEventId.ForeignKeyPropertiesMappedToUnrelatedTables A foreign key specifies properties which don't map to the related tables.

New behavior

Starting with EF Core 7.0, these warnings again, by default, result in an exception being thrown.

Why

These are issues that very likely indicate an error in the application code that should be fixed.

Mitigations

Fix the underlying issue that is the reason for the warning.

Alternately, the warning level can be changed so that it is logged only or suppressed entirely. For example:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .ConfigureWarnings(b => b.Ignore(RelationalEventId.AmbientTransactionWarning));

SQL Server tables with triggers or certain computed columns now require special EF Core configuration

Tracking Issue #27372

Old behavior

Previous versions of the SQL Server provider saved changes via a less efficient technique which always worked.

New behavior

By default, EF Core now saves changes via a significantly more efficient technique; unfortunately, this technique is not supported on SQL Server if the target table has database triggers, or certain types of computed columns. See the SQL Server documentation for more details.

Why

The performance improvements linked to the new method are significant enough that it's important to bring them to users by default. At the same time, we estimate usage of database triggers or the affected computed columns in EF Core applications to be low enough that the negative breaking change consequences are outweighed by the performance gain.

Mitigations

Starting with EF Core 8.0, the use or not of the "OUTPUT" clause can be configured explicitly. For example:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .ToTable(tb => tb.UseSqlOutputClause(false));
}

In EF7 or later, If the target table has a trigger, then you can let EF Core know this, and EF will revert to the previous, less efficient technique. This can be done by configuring the corresponding entity type as follows:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .ToTable(tb => tb.HasTrigger("SomeTrigger"));
}

Note that doing this doesn't actually make EF Core create or manage the trigger in any way - it currently only informs EF Core that triggers are present on the table. As a result, any trigger name can be used. Specifying a trigger can be used to revert the old behavior even if there isn't actually a trigger in the table.

If most or all of your tables have triggers, you can opt out of using the newer, efficient technique for all your model's tables by using the following model building convention:

public class BlankTriggerAddingConvention : IModelFinalizingConvention
{
    public virtual void ProcessModelFinalizing(
        IConventionModelBuilder modelBuilder,
        IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes())
        {
            var table = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table);
            if (table != null
                && entityType.GetDeclaredTriggers().All(t => t.GetDatabaseName(table.Value) == null)
                && (entityType.BaseType == null
                    || entityType.GetMappingStrategy() != RelationalAnnotationNames.TphMappingStrategy))
            {
                entityType.Builder.HasTrigger(table.Value.Name + "_Trigger");
            }

            foreach (var fragment in entityType.GetMappingFragments(StoreObjectType.Table))
            {
                if (entityType.GetDeclaredTriggers().All(t => t.GetDatabaseName(fragment.StoreObject) == null))
                {
                    entityType.Builder.HasTrigger(fragment.StoreObject.Name + "_Trigger");
                }
            }
        }
    }
}

Use the convention on your DbContext by overriding ConfigureConventions:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ => new BlankTriggerAddingConvention());
}

This effectively calls HasTrigger on all your model's tables, instead of you having to do it manually for each and every table.

SQLite tables with AFTER triggers and virtual tables now require special EF Core configuration

Tracking Issue #29916

Old behavior

Previous versions of the SQLite provider saved changes via a less efficient technique which always worked.

New behavior

By default, EF Core now saves changes via a more efficient technique, using the RETURNING clause. Unfortunately, this technique is not supported on SQLite if target table is has database AFTER triggers, is virtual, or if older versions of SQLite are being used. See the SQLite documentation for more details.

Why

The simplifications and performance improvements linked to the new method are significant enough that it's important to bring them to users by default. At the same time, we estimate usage of database triggers and virtual tables in EF Core applications to be low enough that the negative breaking change consequences are outweighed by the performance gain.

Mitigations

In EF Core 8.0, the UseSqlReturningClause method has been introduced to explicitly revert back to the older, less efficient SQL. For example:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .ToTable(tb => tb.UseSqlReturningClause(false));
}

If you are still using EF Core 7.0, then it's possible to revert to the old mechanism for the entire application by inserting the following code in your context configuration:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseSqlite(...)
        .ReplaceService<IUpdateSqlGenerator, SqliteLegacyUpdateSqlGenerator>();

Medium-impact changes

Orphaned dependents of optional relationships are not automatically deleted

Tracking Issue #27217

Old behavior

A relationship is optional if its foreign key is nullable. Setting the foreign key to null allows the dependent entity exist without any related principal entity. Optional relationships can be configured to use cascade deletes, although this is not the default.

An optional dependent can be severed from its principal by either setting its foreign key to null, or clearing the navigation to or from it. In EF Core 6.0, this would cause the dependent to be deleted when the relationship was configured for cascade delete.

New behavior

Starting with EF Core 7.0, the dependent is no longer deleted. Note that if the principal is deleted, then the dependent will still be deleted since cascade deletes are configured for the relationship.

Why

The dependent can exist without any relationship to a principal, so severing the relationship should not cause the entity to be deleted.

Mitigations

The dependent can be explicitly deleted:

context.Remove(blog);

Or SaveChanges can be overridden or intercepted to delete dependents with no principal reference. For example:

context.SavingChanges += (c, _) =>
    {
        foreach (var entry in ((DbContext)c!).ChangeTracker
            .Entries<Blog>()
            .Where(e => e.State == EntityState.Modified))
        {
            if (entry.Reference(e => e.Author).CurrentValue == null)
            {
                entry.State = EntityState.Deleted;
            }
        }
    };

Cascade delete is configured between tables when using TPT mapping with SQL Server

Tracking Issue #28532

Old behavior

When mapping an inheritance hierarchy using the TPT strategy, the base table must contain a row for every entity saved, regardless of the actual type of that entity. Deleting the row in the base table should delete rows in all the other tables. EF Core configures a cascade deletes for this.

In EF Core 6.0, a bug in the SQL Server database provider meant that these cascade deletes were not being created.

New behavior

Starting with EF Core 7.0, the cascade deletes are now being created for SQL Server just as they always were for other databases.

Why

Cascade deletes from the base table to the sub-tables in TPT allow an entity to be deleted by deleting its row in the base table.

Mitigations

In most cases, this change should not cause any issues. However, SQL Server is very restrictive when there are multiple cascade behaviors configured between tables. This means that if there is an existing cascading relationship between tables in the TPT mapping, then SQL Server may generate the following error:

Microsoft.Data.SqlClient.SqlException: The DELETE statement conflicted with the REFERENCE constraint "FK_Blogs_People_OwnerId". The conflict occurred in database "Scratch", table "dbo.Blogs", column 'OwnerId'. The statement has been terminated.

For example, this model creates a cycle of cascading relationships:

[Table("FeaturedPosts")]
public class FeaturedPost : Post
{
    public int ReferencePostId { get; set; }
    public Post ReferencePost { get; set; } = null!;
}

[Table("Posts")]
public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public string? Content { get; set; }
}

One of these will need to be configured to not use cascade deletes on the server. For example, to change the explicit relationship:

modelBuilder
    .Entity<FeaturedPost>()
    .HasOne(e => e.ReferencePost)
    .WithMany()
    .OnDelete(DeleteBehavior.ClientCascade);

Or to change the implicit relationship created for the TPT mapping:

modelBuilder
    .Entity<FeaturedPost>()
    .HasOne<Post>()
    .WithOne()
    .HasForeignKey<FeaturedPost>(e => e.Id)
    .OnDelete(DeleteBehavior.ClientCascade);

Higher chance of busy/locked errors on SQLite when not using write-ahead logging

Old behavior

Previous versions of the SQLite provider saved changes via a less efficient technique which was able to automatically retry when the table was locked/busy and write-ahead logging (WAL) was not enabled.

New behavior

By default, EF Core now saves changes via a more efficient technique, using the RETURNING clause. Unfortunately, this technique is not able to automatically retry when busy/locked. In a multi-threaded application (like a web app) not using write-ahead logging, it is common to encounter these errors.

Why

The simplifications and performance improvements linked to the new method are significant enough that it's important to bring them to users by default. Databases created by EF Core also enable write-ahead logging by default. The SQLite team also recommends enabling write-ahead logging by default.

Mitigations

If possible, you should enable write-ahead logging on your database. If your database was created by EF, this should already be the case. If not, you can enable write-ahead logging by executing the following command.

PRAGMA journal_mode = 'wal';

If, for some reason, you can't enable write-ahead logging, it's possible to revert to the old mechanism for the entire application by inserting the following code in your context configuration:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseSqlite(...)
        .ReplaceService<IUpdateSqlGenerator, SqliteLegacyUpdateSqlGenerator>();

Low-impact changes

Key properties may need to be configured with a provider value comparer

Tracking Issue #27738

Old behavior

In EF Core 6.0, key values taken directly from the properties of entity types were used for comparison of key values when saving changes. This would make use of any custom value comparer configured on these properties.

New behavior

Starting with EF Core 7.0, database values are used for these comparisons. This "just works" for the vast majority of cases. However, if the properties were using a custom comparer, and that comparer cannot be applied to the database values, then a "provider value comparer" may be needed, as shown below.

Why

Various entity-splitting and table-splitting can result in multiple properties mapped to the same database column, and vice-versa. This requires values to be compared after conversion to value that will be used in the database.

Mitigations

Configure a provider value comparer. For example, consider the case where a value object is being used as a key, and the comparer for that key uses case-insensitive string comparisons:

var blogKeyComparer = new ValueComparer<BlogKey>(
    (l, r) => string.Equals(l.Id, r.Id, StringComparison.OrdinalIgnoreCase),
    v => v.Id.ToUpper().GetHashCode(),
    v => v);

var blogKeyConverter = new ValueConverter<BlogKey, string>(
    v => v.Id,
    v => new BlogKey(v));

modelBuilder.Entity<Blog>()
    .Property(e => e.Id).HasConversion(
        blogKeyConverter, blogKeyComparer);

The database values (strings) cannot directly use the comparer defined for BlogKey types. Therefore, a provider comparer for case-insensitive string comparisons must be configured:

var caseInsensitiveComparer = new ValueComparer<string>(
    (l, r) => string.Equals(l, r, StringComparison.OrdinalIgnoreCase),
    v => v.ToUpper().GetHashCode(),
    v => v);

var blogKeyComparer = new ValueComparer<BlogKey>(
    (l, r) => string.Equals(l.Id, r.Id, StringComparison.OrdinalIgnoreCase),
    v => v.Id.ToUpper().GetHashCode(),
    v => v);

var blogKeyConverter = new ValueConverter<BlogKey, string>(
    v => v.Id,
    v => new BlogKey(v));

modelBuilder.Entity<Blog>()
    .Property(e => e.Id).HasConversion(
        blogKeyConverter, blogKeyComparer, caseInsensitiveComparer);

Check constraints and other table facets are now configured on the table

Tracking Issue #28205

Old behavior

In EF Core 6.0, HasCheckConstraint, HasComment, and IsMemoryOptimized were called directly on the entity type builder. For example:

modelBuilder
    .Entity<Blog>()
    .HasCheckConstraint("CK_Blog_TooFewBits", "Id > 1023");

modelBuilder
    .Entity<Blog>()
    .HasComment("It's my table, and I'll delete it if I want to.");

modelBuilder
    .Entity<Blog>()
    .IsMemoryOptimized();

New behavior

Starting with EF Core 7.0, these methods are instead called on the table builder:

modelBuilder
    .Entity<Blog>()
    .ToTable(b => b.HasCheckConstraint("CK_Blog_TooFewBits", "Id > 1023"));

modelBuilder
    .Entity<Blog>()
    .ToTable(b => b.HasComment("It's my table, and I'll delete it if I want to."));

modelBuilder
    .Entity<Blog>()
    .ToTable(b => b.IsMemoryOptimized());

The existing methods have been marked as Obsolete. They currently have the same behavior as the new methods, but will be removed in a future release.

Why

These facets apply to tables only. They will not be applied to any mapped views, functions, or stored procedures.

Mitigations

Use the table builder methods, as shown above.

Tracking Issue #28249

Old behavior

In EF Core 6.0, when a new entity is tracked either from a tracking query or by attaching it to the DbContext, then navigations to and from related entities in the Deleted state are fixed up.

New behavior

Starting with EF Core 7.0, navigations to and from Deleted entities are not fixed up.

Why

Once an entity is marked as Deleted it rarely makes sense to associate it with non-deleted entities.

Mitigations

Query or attach entities before marking entities as Deleted, or manually set navigation properties to and from the deleted entity.

Tracking Issue #26502

Old behavior

In EF Core 6.0, using the Azure Cosmos DB FromSqlRaw extension method when using a relational provider, or the relational FromSqlRaw extension method when using the Azure Cosmos DB provider could silently fail. Likewise, using relational methods on the in-memory provider is a silent no-op.

New behavior

Starting with EF Core 7.0, using an extension method designed for one provider on a different provider will throw an exception.

Why

The correct extension method must be used for it to function correctly in all situations.

Mitigations

Use the correct extension method for the provider being used. If multiple providers are referenced, then call the extension method as a static method. For example:

var result = CosmosQueryableExtensions.FromSqlRaw(context.Blogs, "SELECT ...").ToList();

Or:

var result = RelationalQueryableExtensions.FromSqlRaw(context.Blogs, "SELECT ...").ToList();

Scaffolded OnConfiguring no longer calls IsConfigured

Tracking Issue #4274

Old behavior

In EF Core 6.0, the DbContext type scaffolded from an existing database contained a call to IsConfigured. For example:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    if (!optionsBuilder.IsConfigured)
    {
#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.
        optionsBuilder.UseNpgsql("MySecretConnectionString");
    }
}

New behavior

Starting with EF Core 7.0, the call to IsConfigured is no longer included.

Why

There are very limited scenarios where the database provider is configured inside your DbContext in some cases, but only if the context is not configured already. Instead, leaving OnConfiguring here makes it more likely that a connection string containing sensitive information is left in the code, despite the compile-time warning. Thus the extra safely and cleaner code from removing this was deemed worthwhile, especially given that the --no-onconfiguring (.NET CLI) or -NoOnConfiguring (Visual Studio Package Manager Console) flag can be used to prevent scaffolding of the OnConfiguring method, and that customizable templates exist to add back IsConfigured if it is really needed.

Mitigations

Either:

  • Use the --no-onconfiguring (.NET CLI) or -NoOnConfiguring (Visual Studio Package Manager Console) argument when scaffolding from an existing database.
  • Customize the T4 templates to add back the call to IsConfigured.