Skip to main content

How to Choose What Happens on Delete in EF Core: Cascade or Restrict

· 9 min read
Jagdish Kumawat
Founder @ Dewiride

What should happen to a category's products when the category is deleted? EF Core answers for you, unless you answer first: for a required relationship, its default is to delete the products too, silently. This guide shows that default happening in MySQL, switches it to Restrict so the database refuses instead, and shows the two different errors you get afterwards, one from MySQL and one from EF Core itself.

The Choices​

When you delete a row that other rows point to, three things can happen to those other rows: delete them too (cascade), clear their pointer (set null), or refuse the delete (restrict). EF Core sets this with OnDelete and a DeleteBehavior value, and it writes the choice into the database's foreign key as ON DELETE ….

EF Core's cascade delete page gives the defaults: "Required relationships are configured to use cascade deletes by default", and "The default for optional relationships like this is ClientSetNull." A relationship is required when the foreign key can't be empty, such as an int CategoryId.

Prerequisites​

Step 1: See the Default, Cascade​

A product belongs to a category, and the foreign key is a non-nullable int:

src/Dewiride.Inventory.Data/Entities/Product.cs (excerpt)
public int CategoryId { get; set; }

public Category Category { get; set; } = null!;

With no configuration, EF Core's first migration created the foreign key with cascade:

Migrations/…_CreateCatalogTables.cs (excerpt)
table.ForeignKey(
name: "FK_Products_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);

What that means in practice, from the mysql client. A category with two products, then the category is deleted (the tables still had EF Core's default names, Categories and Products, at this point):

mysql>
INSERT INTO Categories (Name, CreatedAtUtc) VALUES ('Cables', UTC_TIMESTAMP(6));
INSERT INTO Products (Sku, Name, CategoryId, UnitPrice, QuantityOnHand, ReorderLevel, IsArchived, CreatedAtUtc, UpdatedAtUtc)
VALUES ('CBL-USBC-1M', 'USB-C cable, 1 m', 1, 9.99, 25, 5, 0, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)),
('CBL-USBC-2M', 'USB-C cable, 2 m', 1, 12.49, 10, 5, 0, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6));
DELETE FROM Categories WHERE Id = 1;
SELECT COUNT(*) FROM Products;
Output (the delete and the count)
Query OK, 1 row affected (0.003 sec)

+----------+
| COUNT(*) |
+----------+
| 0 |
+----------+
1 row in set (0.000 sec)

The mysql client where a Cables category with two products is deleted, MySQL reports only 1 row affected, and SELECT COUNT(*) FROM Products then returns 0.

MySQL reported 1 row affected, the category, and the two products were gone too. Nothing warned anyone. For data like products, which have stock, prices and history, that's rarely what you want.

Step 2: Switch to Restrict​

Configure the relationship explicitly, with OnDelete(DeleteBehavior.Restrict):

src/Dewiride.Inventory.Data/Configurations/ProductConfiguration.cs (excerpt)
builder.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);

This reads like a sentence: a product has one category, a category has many products, CategoryId holds the link, and deleting a category that still has products is refused. EF Core's one-to-many page shows this exact use: "It's possible to configure EF to throw an exception instead of automatically deleting dependent rows that can no longer exist".

Create and apply the migration:

Terminal
dotnet ef migrations add RestrictCategoryDelete --project src/Dewiride.Inventory.Data --startup-project src/Dewiride.Inventory.Api
dotnet ef database update --project src/Dewiride.Inventory.Data --startup-project src/Dewiride.Inventory.Api
Migrations/…_RestrictCategoryDelete.cs (Up)
migrationBuilder.DropForeignKey(
name: "FK_products_categories_CategoryId",
table: "products");

migrationBuilder.AddForeignKey(
name: "FK_products_categories_CategoryId",
table: "products",
column: "CategoryId",
principalTable: "categories",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
Output (database update, the lines that matter)
Applying migration '20261103044319_RestrictCategoryDelete'.
ALTER TABLE `products` DROP CONSTRAINT `FK_products_categories_CategoryId`;
ALTER TABLE `products` ADD CONSTRAINT `FK_products_categories_CategoryId` FOREIGN KEY (`CategoryId`) REFERENCES `categories` (`Id`) ON DELETE RESTRICT;
Done.

MySQL can't change a foreign key's action in place, so the migration drops it and adds it again with ON DELETE RESTRICT. On a table with a lot of data, that takes a moment, during which the foreign key isn't checked.

Step 3: See the Two Refusals​

From MySQL, when the products aren't loaded in the DbContext. Deleting the Adapters category, which has two products:

mysql>
DELETE FROM categories WHERE Id = 2;
Output
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`inventory`.`products`, CONSTRAINT `FK_products_categories_CategoryId` FOREIGN KEY (`CategoryId`) REFERENCES `categories` (`Id`) ON DELETE RESTRICT)

Through EF Core, the same refusal arrives as a DbUpdateException from SaveChangesAsync, which the docs predict: "SaveChanges then wraps the database exception in a DbUpdateException." Its first lines, from a temporary API endpoint that loads a category and removes it:

Output
Microsoft.EntityFrameworkCore.DbUpdateException: Could not save changes. Please configure your entity type accordingly.
---> MySql.Data.MySqlClient.MySqlException (0x80004005): Cannot delete or update a parent row: a foreign key constraint fails (`inventory`.`products`, CONSTRAINT `FK_products_categories_CategoryId` FOREIGN KEY (`CategoryId`) REFERENCES `categories` (`Id`) ON DELETE RESTRICT)

From EF Core itself, when the products are loaded. The same endpoint with Include(c => c.Products):

Output
System.InvalidOperationException: The association between entity types 'Category' and 'Product' has been severed, but the relationship is either marked as required or is implicitly required because the foreign key is not nullable. If the dependent/child entity should be deleted when a required relationship is severed, configure the relationship to use cascade deletes. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values.

A terminal where deleting a category through the API fails with DbUpdateException wrapping MySQL error 1451 when products aren't loaded, and with InvalidOperationException about a severed required association when they are.

EF Core noticed in memory that the loaded products would lose their required category, and refused before sending anything to MySQL. The docs explain both cases: "EF Core always applies configured cascading behaviors to tracked entities", and with a non-cascade behavior on a required relationship, "Typically, this is an InvalidOperationException from EF Core since the invalid state is detected in the loaded children/dependents." The severed-association error has its own post, and so does MySQL's error 1451, including how to return 409 Conflict from an API.

Which to Choose​

DeleteBehaviorDatabaseUse it when
CascadeON DELETE CASCADEThe children can't exist without the parent and have no value on their own, such as an order's lines
RestrictON DELETE RESTRICTThe children matter on their own, such as products in a category
SetNullON DELETE SET NULLThe children can exist without a parent; the foreign key must be nullable
ClientSetNull (default for optional)No cascade in the databaseLike SetNull, but only for loaded entities

The docs make a point about the database side: "The only values that will cause cascading behaviors on the database are Cascade and SetNull. All other values will configure the database to not cascade any changes."

Common Mistakes​

MistakeWhat happensDo this instead
Leaving the default on every relationshipDeleting one row can silently delete manyChoose OnDelete for each relationship
SetNull on a non-nullable foreign keyMySQL refuses the foreign keyMake the foreign key nullable, or use Restrict
Expecting the same error whether children are loaded or notSometimes InvalidOperationException, sometimes DbUpdateExceptionHandle both
Relying on cascade for important dataOne mistaken delete loses the historyArchive instead of deleting

FAQ​

Do I need to configure every relationship?​

Only the ones where the default is wrong. Required relationships cascade by default, so review each one where the children matter.

What's the difference between Restrict and NoAction?​

In MySQL, none in practice. EF Core's docs note that "The behaviors of ON DELETE NO ACTION (the database default) and ON DELETE RESTRICT in relational databases are typically either identical or very similar."

Conclusion​

EF Core gives a required relationship ON DELETE CASCADE unless you choose otherwise, and in MySQL that silently deletes the children with the parent. OnDelete(DeleteBehavior.Restrict) makes the database refuse instead. After the change, a delete with children fails in one of two ways: DbUpdateException wrapping MySQL's error 1451 when the children aren't loaded, or EF Core's own InvalidOperationException when they are. Choose per relationship, and keep cascade for children that mean nothing without their parent.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.