Soft Deletes with Entity Framework Core – Bulk Deletes


This is post 4 on a series of posts on soft deletes with EF Core. On none of my posts on this series did I mention that these solutions do not work with bulk deletes, introduced in EF Core 7, but they really don't. That is because they rely on the change tracking mechanism and the bulk delete and bulk update functionality does not use it. But as with most of EF Core, there is certainly a way around it, and in this post I will present one, using IQueryExpressionInterceptor, one of the new interceptors introduced in EF Core 7. There is also an open ticket for adding an interceptor mechanism for bulk updates, which will probably also include bulk deletes.

Shadow Properties

First, a reminder: I chose to use a marker interface for soft-deletable entities like this:

public interface ISoftDeletable

And not like this:

public interface ISoftDeletable
bool IsDeleted { get; set; }

Why did I skip the IsDeleted property, I hear you ask? Well, because I don’t want people to mess with it directly; chances are, if they see the property, they’ll start setting values to it, and that’s what I don’t want. So, I chose to have it as a shadow property instead. It still exists as a physical column on the database, but it just isn’t visible in the model. If we want to access it, provided we know it exists, we must do so using the EF.Property method:

var deletedProducts = ctx.Products.Where(x => EF.Property<bool>(x, "IsDeleted") == true).ToList();

Bulk Deletes

EF Core since version 7 allows deleting records without first loading them. We start from a common LINQ query and them we finalise with a ExecuteDelete method:

var deletedRecords = ctx.Products.Where(x => x.Reference == "xxx").ExecuteDelete();

ExecuteDelete returns the number of records affected, in this case, deleted. It does fire the interceptors that work directly with the database connections, commands and transactions and also the new LINQ Expression interceptor, and it also does use global query filters.

LINQ Expression Interceptor

The LINQ Expression Interceptor was introduced in EF Core 7 and it allows us to inspect and modify a LINQ expression tree before it is turned into SQL. In practice, it is specified as the IQueryExpressionInterceptor. and we add instances of classes implementing it to the DbContextOptionsBuilder class. It has a single method that we need to implement, QueryCompilationStarting, let’s see how in a moment.

Turning Bulk Deletes Into Bulk Updates

So, what we want to accomplish is to turn a bulk delete – a call to ExecuteDelete – into a bulk update – a call to ExecuteUpdate – for those entities that are marked as soft-deletable, where the update sets the deleted column (IsDeleted) to true. Let’s create a custom LINQ Expression Interceptor that does this for us. The class can be like this:

class SoftDeleteQueryExpressionInterceptor : IQueryExpressionInterceptor
public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
return new SoftDeletableQueryExpressionVisitor().Visit(queryExpression);

class SoftDeletableQueryExpressionVisitor : ExpressionVisitor
private const string _isDeletedProperty = "IsDeleted";
private static readonly MethodInfo _executeDeleteMethod = typeof(RelationalQueryableExtensions).GetMethod(nameof(RelationalQueryableExtensions.ExecuteDelete), BindingFlags.Public | BindingFlags.Static)!;
private static readonly MethodInfo _executeUpdateMethod = typeof(RelationalQueryableExtensions).GetMethod(nameof(RelationalQueryableExtensions.ExecuteUpdate), BindingFlags.Public | BindingFlags.Static)!;
private static readonly MethodInfo _propertyMethod = typeof(EF).GetMethod(nameof(EF.Property), BindingFlags.Static | BindingFlags.Public)!;

protected override Expression VisitMethodCall(MethodCallExpression node)
if (node.Method.IsGenericMethod && node.Method.GetGenericMethodDefinition() == _executeDeleteMethod)
var entityType = node.Method.GetGenericArguments()[0];
var isSoftDeletable = typeof(ISoftDeletable).IsAssignableFrom(entityType);

if (isSoftDeletable)
var setPropertyMethod = typeof(SetPropertyCalls<>).MakeGenericType(entityType).GetMethods().Single(m =>
m.Name == nameof(SetPropertyCalls<object>.SetProperty)
&& m.IsGenericMethod
&& m.GetGenericArguments().Length == 1
&& m.GetParameters().Length == 2
&& m.GetParameters()[1].ParameterType.IsGenericMethodParameter
&& m.GetParameters()[1].Name == "valueExpression")

var setterParameter = Expression.Parameter(typeof(SetPropertyCalls<>).MakeGenericType(entityType), "setters");
var parameter = Expression.Parameter(entityType, "p");
var propertyCall = Expression.Call(null, _propertyMethod.MakeGenericMethod(typeof(bool)), parameter, Expression.Constant(_isDeletedProperty));
var propertyCallLambda = Expression.Lambda(propertyCall, parameter);
var setPropertyCall = Expression.Call(setterParameter, setPropertyMethod, propertyCallLambda, Expression.Constant(true));
var lambda = Expression.Lambda(setPropertyCall, setterParameter);

return Expression.Call(node.Object, _executeUpdateMethod.MakeGenericMethod(entityType), node.Arguments[0], lambda);

return base.VisitMethodCall(node);

To use it, we must add to the DbContextOptionsBuilder using the AddInterceptors method:

var optionsBuilder = new DbContextOptionsBuilder<ProductContext>()
.AddInterceptors(new SoftDeleteQueryExpressionInterceptor())

It’s worth explaining a bit what it does.

Inside QueryCompilationStarting, we just instantiate and return a new instance of our ExpressionVisitor-derived class. An expression visitor allows us to inspect and possibly modify all aspects of a LINQ expression tree, in our case, we are interested in looking for method calls (VisitMethodCall) and here we see if the target method is the one we’re interested in (ExecuteDelete). If so, and if the target type is ISoftDeletable, we start the process of creating a new lambda function to replace the original method call for a call to ExecuteUpdate taking as its parameter a call to the shadow property IsDeleted through the EF.Property method and setting it to true.

So, when we do:

ctx.Products.Where(x => x.Reference == "xxx").ExecuteDelete();

We effectively end up with this instead, thanks to the expression visitor:

ctx.Products.Where(x => x.Reference == "xxx").ExecuteUpdate(setters => setters.SetProperty(p => EF.Property<bool>(p, "IsDeleted"), true));

And that’s it. No other queries are affected, other than bulk deletes for soft-deletable entities!


I hope you enjoyed this post, as you can see, there always seems to be a way with EF Core! I will make all code, including the one from the previous posts, available, and even release it as a Nuget package. As always, keen to year from you, get your comments coming!


No Comments

Add a Comment

As it will appear on the website

Not displayed

Your website