Cascade Delete in EF Core: Behaviors and Pitfalls

Cascade Delete in EF Core: Behaviors and Pitfalls

7 min read··Updated ·

databasedotnetef-core

Cascade delete removes dependent entities, like the OrderLine rows under an Order, when the principal entity is deleted. You configure it per relationship with OnDelete(DeleteBehavior.Cascade), which makes EF delete tracked dependents and makes the migration emit ON DELETE CASCADE on the foreign key. Required relationships get Cascade by convention, and optional ones get ClientSetNull, which only nulls the foreign key on dependents EF has already loaded.

Delete an order and its order lines should go with it. Every ORM promises this, EF Core delivers it, and most developers stop reading there.

Then one day a delete that always worked starts throwing foreign key violations, but only in production, and only for some rows. The root cause is almost always the same: DeleteBehavior configures two different mechanisms, one in EF's memory and one in the database, and they do not have to agree.

The Two Mechanisms

When you delete a principal (the Order), something has to happen to its dependents (the OrderLine rows holding the foreign key). Two independent actors can handle it:

Deleting an Order triggers two independent actors: EF Core cascades only tracked dependents in memory, while the database FK constraint handles every dependent row including unloaded ones

EF Core, in memory. The change tracker knows about the entities it is tracking. If you delete an order while its lines are loaded and tracked, EF marks the lines Deleted too and issues DELETE statements for them, children first, in the right order.

The database, via the foreign key constraint. The migration generates ON DELETE CASCADE (or SET NULL, or NO ACTION) on the FK. The database then handles dependents for every delete, including rows EF has never seen.

Here is the point the documentation makes quietly and bugs make loudly: EF's in-memory cascade only applies to tracked entities. If the lines are not loaded, EF sends one DELETE for the order and hopes the database handles the rest. Whether it does depends entirely on what constraint the migration created.

What Does DeleteBehavior Actually Map To?

Each DeleteBehavior value answers both questions at once: what EF does to tracked dependents, and what DDL the migration generates.

  • Cascade: EF deletes tracked dependents, and the database gets ON DELETE CASCADE. Both actors cascade. Unloaded dependents are handled by the database.
  • ClientCascade: EF deletes tracked dependents, but the database gets NO ACTION/RESTRICT. If any dependent is not loaded when you delete the principal, the database throws an FK violation.
  • SetNull: the database gets ON DELETE SET NULL, and EF nulls the FK on tracked dependents. Only valid for optional (nullable FK) relationships.
  • ClientSetNull (the default for optional relationships): EF nulls the FK on tracked dependents, the database gets NO ACTION. Same trap as ClientCascade, with nulling instead of deleting.
  • Restrict / NoAction: nobody cascades. EF will even throw at SaveChanges if you leave tracked dependents orphaned. Deleting a principal with existing dependents is an error unless you handle the dependents yourself first.

And the conventions that decide which one you get when you configure nothing:

  • Required relationship (non-nullable FK): Cascade.
  • Optional relationship (nullable FK): ClientSetNull.

Configuration lives in OnModelCreating, per relationship:

modelBuilder.Entity<Order>()
    .HasMany(o => o.Lines)
    .WithOne(l => l.Order)
    .HasForeignKey(l => l.OrderId)
    .OnDelete(DeleteBehavior.Cascade);

If you take one sentence from this article: the Client* behaviors mean the database will not help you, and any delete that touches unloaded dependents will fail with an FK violation.

The Classic Failure, Step by Step

This is the "works in tests, fails in production" bug.

Say the relationship between Blog and Post is configured as ClientCascade, or it is optional and got the default ClientSetNull. Now delete a blog by id without loading its posts:

var blog = await dbContext.Blogs.FirstAsync(b => b.Id == blogId);

dbContext.Blogs.Remove(blog);

await dbContext.SaveChangesAsync();
// PostgresException: 23503: update or delete on table "Blogs"
// violates foreign key constraint "FK_Posts_Blogs_BlogId" on table "Posts"

In your test, the blog had no posts (or you had them loaded from an earlier assertion), so it passed. If the blog has 400 posts that EF never loaded, the database sees a bare DELETE FROM Blogs, and the NO ACTION constraint rejects it. (On SQL Server the same failure surfaces as a SqlException about a conflicted REFERENCE constraint.)

Three legitimate fixes, in order of my preference:

// 1. Let the database own it: switch to DeleteBehavior.Cascade
//    (migration adds ON DELETE CASCADE).

// 2. Delete dependents explicitly with a set-based query first.
await dbContext.Posts
    .Where(p => p.BlogId == blogId)
    .ExecuteDeleteAsync();

await dbContext.Blogs
    .Where(b => b.Id == blogId)
    .ExecuteDeleteAsync();

// 3. Load the graph so EF's client cascade has something to work on.
var blog = await dbContext.Blogs
    .Include(b => b.Posts)
    .FirstAsync(b => b.Id == blogId);

dbContext.Blogs.Remove(blog); // posts marked Deleted too
await dbContext.SaveChangesAsync();

Option 3 is the one to be suspicious of: loading 400 rows to delete them is pure waste. Note that ExecuteDeleteAsync bypasses the change tracker entirely, so with it, client-side behaviors never run and only the database constraint matters. If you use bulk deletes, your delete behavior IS your DDL, full stop. And when a delete spans multiple statements like option 2, wrap it in a transaction.

Why ClientCascade Exists at All: SQL Server's Cascade Paths

If Cascade is the honest behavior, why would anyone choose ClientCascade?

Because SQL Server refuses some cascades. Create a schema where the same table is reachable through two ON DELETE CASCADE chains (a diamond), and the migration fails with:

Msg 1785: Introducing FOREIGN KEY constraint 'FK_...' on table '...'
may cause cycles or multiple cascade paths.
Specify ON DELETE NO ACTION or ON UPDATE NO ACTION,
or modify other FOREIGN KEY constraints.

The standard example: Customer has Orders, Customer has Addresses, and Order references Address. Deleting a customer can reach the order both directly and through the address, and SQL Server will not create the second cascade. PostgreSQL, for the record, allows this happily; it is a SQL Server limitation, and it is one of the behavioral differences worth knowing if you work across PostgreSQL and SQL Server.

Your options when you hit error 1785:

  • Break one edge of the diamond with DeleteBehavior.Restrict or NoAction and delete that path explicitly in code.
  • Use ClientCascade on that edge: EF still cascades tracked graphs, and you accept the load-before-delete obligation documented above.
  • Reconsider whether both relationships should be required. Often one of them is really optional, and SetNull dissolves the diamond.

ClientCascade is a workaround with a maintenance contract attached, not a default to reach for.

Cascades and Aggregates: Being Deliberate

My actual rule for choosing behaviors has less to do with EF and more with domain design:

Cascade inside an aggregate, restrict between aggregates.

OrderLine has no life without its Order: cascade, and let the database enforce it. But Order referencing Customer is a relationship between aggregates, and silently vaporizing a customer's orders because someone deleted the customer is a catastrophe, not a convenience. That edge gets Restrict, and "delete a customer" becomes an explicit use case that decides what happens to orders. This mapping between aggregate boundaries and FK behaviors is a recurring theme in modeling aggregates with EF Core, and it is the lens I teach in Pragmatic Domain-Driven Design.

Two pitfalls to close the loop on:

Soft delete changes everything. If Order is soft-deleted (an IsDeleted flag plus a query filter), a database ON DELETE CASCADE is now a landmine: the parent row never gets a SQL DELETE in normal operation, but any hard delete (cleanup jobs, GDPR erasure) will physically cascade to children you meant to keep as soft-deleted history. Soft-deleted aggregates should pair with Restrict semantics and propagate the flag explicitly to dependents.

Verify the DDL, not the C#. The delete behavior you configured is a claim; the migration is the truth. Read the generated migration (or the SQL from dotnet ef migrations script) and confirm the onDelete: values match your intent, the same way you would review any migration before it ships.

Summary

  • DeleteBehavior sets two things at once: EF's handling of tracked dependents and the ON DELETE clause in your schema. Bugs live in the gap between them.
  • Cascade means the database covers unloaded rows. ClientCascade and ClientSetNull mean it does not, and deletes fail the moment dependents exist that EF has not loaded.
  • ExecuteDeleteAsync skips the change tracker, so only the database behavior applies to it.
  • SQL Server's multiple-cascade-paths error is the main legitimate reason ClientCascade exists. Prefer breaking the diamond with Restrict plus explicit deletes.
  • Design rule: cascade within an aggregate, restrict between aggregates, and never mix database cascades with soft delete without deciding exactly what a hard delete should destroy.

Cascade delete is not one feature. It is a contract between your code and your schema, and it only works when both sides say the same thing.

Frequently Asked Questions

What is cascade delete in EF Core?

When a principal entity like an Order is deleted, cascade delete removes its dependent entities like OrderLines automatically. EF Core can perform the cascade on tracked entities in memory, and the database can enforce it with an ON DELETE CASCADE constraint. By default both are configured for required relationships.

What is the difference between DeleteBehavior.Cascade and ClientCascade?

Cascade configures both EF and the database to cascade, so the delete works even for rows EF never loaded. ClientCascade only cascades entities tracked in the DbContext, and the database gets ON DELETE NO ACTION or RESTRICT, so deleting with unloaded dependents throws a foreign key violation.

Why does SQL Server complain about multiple cascade paths?

SQL Server refuses to create a table that can be reached by more than one ON DELETE CASCADE chain, failing the migration with error 1785. The fix is switching some relationships to Restrict or NoAction and handling those deletes in code.

Does cascade delete work with soft delete?

Not out of the box. A database cascade physically removes dependent rows even if the parent was only meant to be soft-deleted, and soft-deleting a parent does not touch dependents at all. Soft delete designs should use Restrict semantics and handle propagation explicitly.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.