Remigiusz ZalewskiRemigiusz Zalewski

How to Implement Soft Delete in EF Core

ef-core-soft-delete

Introduction

"Deleted" and "gone forever" don't have to be the same thing. Soft delete flips a DELETE FROM into an UPDATE ... SET IsDeleted = 1, keeping the row around for audit trails, undo support, or simply because a support ticket six months later asks "what was in this order before it got removed." This video builds soft delete in EF Core with a global query filter, so every existing query in the app keeps working without touching a single Where clause.

🎬 Watch the full video here:


The marker interface

A shared interface flags which entities participate in soft delete, rather than repeating IsDeleted on every entity by convention alone:

public interface ISoftDelete
{
    bool IsDeleted { get; set; }
    DateTime? DeletedAt { get; set; }
}

One filter, applied to every ISoftDelete entity

Rather than hand-writing a query filter per entity, OnModelCreating walks the model and applies one automatically to anything implementing ISoftDelete:

foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
    if (typeof(ISoftDelete).IsAssignableFrom(entityType.ClrType))
    {
        modelBuilder.Entity(entityType.ClrType)
            .HasQueryFilter(BuildSoftDeleteFilter(entityType.ClrType));
    }
}

BuildSoftDeleteFilter uses Expression.Lambda to construct e => !e.IsDeleted for each type reflectively, since the entity type varies at runtime. Once this is wired up, every dbContext.Orders.ToListAsync() anywhere in the codebase automatically excludes soft-deleted rows - nobody writing a query has to remember to filter them out.

Deleting becomes an update, not a delete

The delete operation itself is intercepted, most cleanly by overriding SaveChangesAsync and rewriting any entity marked EntityState.Deleted:

foreach (var entry in ChangeTracker.Entries<ISoftDelete>().Where(e => e.State == EntityState.Deleted))
{
    entry.State = EntityState.Modified;
    entry.Entity.IsDeleted = true;
    entry.Entity.DeletedAt = DateTime.UtcNow;
}

Calling code still writes dbContext.Orders.Remove(order) exactly as before - the interception happens centrally, so nothing about how the rest of the app deletes entities has to change.

When you actually need the deleted rows

IgnoreQueryFilters() bypasses the global filter for the cases where you genuinely need it - an admin "recover deleted item" screen, or an audit report:

var allOrders = await dbContext.Orders.IgnoreQueryFilters().ToListAsync();

The part global query filters don't solve: cascades

EF Core's query filter hides soft-deleted rows from queries, but it says nothing about relationships. If an Order is soft-deleted, its OrderItems are not automatically marked deleted by the filter - you either soft-delete children explicitly in the same operation, or accept that a soft-deleted parent can still have "active" children floating around, which is usually not what you want. This is the detail that trips people up the most: soft delete is not a database-level cascade, it's an application-level convention that has to be applied consistently.

Common pitfalls

  • Forgetting IgnoreQueryFilters() exists and hand-writing raw SQL for "show me deleted rows" instead.
  • Unique indexes that don't account for soft-deleted rows. A unique email constraint will reject a new signup reusing an email from a soft-deleted account unless the index is filtered (WHERE IsDeleted = 0).
  • Not soft-deleting related entities. A soft-deleted parent with still-active children is usually a bug waiting to surface.
  • Physically deleting anyway from a raw SQL migration or a background job that bypasses EF Core's change tracker entirely.
  • No DeletedAt timestamp, making it impossible to answer "when was this actually removed."

Key Takeaways

  • Soft delete rewrites a delete into an IsDeleted = true update via an ISoftDelete marker interface and a SaveChangesAsync override.
  • A global query filter, applied reflectively to every ISoftDelete entity, keeps deleted rows out of ordinary queries automatically.
  • IgnoreQueryFilters() is the escape hatch for admin/audit scenarios that need the deleted rows back.
  • Query filters don't cascade - soft-deleting a parent doesn't automatically soft-delete its children.
  • Filtered unique indexes (WHERE IsDeleted = 0) avoid false uniqueness conflicts against soft-deleted rows.

Get the Full Source Code

The complete runnable solution - the ISoftDelete interface, the reflective query filter setup, and the SaveChangesAsync override - is available to Patreon supporters. If you want the soft-delete convention ready to drop into your own DbContext instead of rebuilding it from the walkthrough above, you can find it on Patreon.

Resources