You call context.Products.Remove(product) and the record is gone forever. No undo, no audit trail, no way to recover when the support team asks “what happened to order #4582?” at 2 AM. Sound familiar?
Soft delete solves this by marking records as deleted instead of physically removing them from the database. The record stays in the table but becomes invisible to normal queries. You can recover it, audit it, and keep referential integrity intact - all without losing data.
In this article, we’ll implement a production-ready soft delete system in EF Core 10 (Entity Framework Core 10) using SaveChangesInterceptor, named query filters for selective filtering, cascade soft delete for parent-child relationships, and an undo/restore endpoint. We’ll cover the common gotchas that trip developers up and compare the different implementation approaches so you can pick the right one for your project. Let’s get into it.
What Is Soft Delete?
Soft delete is a data persistence strategy where records are never physically removed from the database. Instead of executing a SQL DELETE statement, the application sets a flag - typically a boolean IsDeleted column - to true. The record remains in the table but is automatically excluded from normal queries using a global query filter. The recommended approach for implementing soft delete in EF Core 10 is to combine a SaveChangesInterceptor (to transparently convert deletes into updates) with named query filters (to automatically hide deleted records while allowing selective access).
This contrasts with hard delete, where context.Remove(entity) generates a DELETE FROM statement that permanently erases the row. Once executed, the data is gone - only database backups can recover it.
| Aspect | Soft Delete | Hard Delete |
|---|---|---|
| Database operation | UPDATE SET IsDeleted = true | DELETE FROM |
| Data recovery | Set IsDeleted = false | Restore from backup only |
| Referential integrity | Preserved - foreign keys remain valid | Requires cascade delete or nullable FKs |
| Storage | Grows over time - deleted rows stay in table | Table stays lean |
| Query complexity | Requires global query filter to hide deleted rows | No extra filtering needed |
| Audit trail | Built-in - you know when and who deleted | Requires separate audit log |
| Unique constraints | Must account for deleted duplicates | No conflict |
When Should You Use Soft Delete?
Soft delete makes sense when:
- Data recovery is critical - Support teams need to restore accidentally deleted records
- Audit compliance requires it - Regulations like GDPR, HIPAA, or SOX mandate data retention
- Referential integrity matters - Other tables reference the deleted record via foreign keys
- You need a “recycle bin” - Users expect an undo/restore feature in the UI
When should you reconsider? In domain-driven systems, “deletion” is often the wrong concept entirely. An order isn’t deleted - it’s cancelled. A payment isn’t deleted - it’s refunded. If your domain has explicit state transitions, model those states instead of using a generic IsDeleted flag.
Prerequisites
Before we start, make sure you have:
- .NET 10 SDK installed (download here)
- Docker Desktop running (we’ll use PostgreSQL in a container)
- A code editor - Visual Studio 2026 or VS Code with the C# extension
- Familiarity with EF Core basics (DbContext, entities, migrations)
Setting Up the Project
Create a .NET 10 Web API project:
dotnet new webapi -n SoftDeletes.Api -o SoftDeletes.Api --use-controllers falseInstall the required NuGet packages:
dotnet add SoftDeletes.Api package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.0dotnet add SoftDeletes.Api package Microsoft.EntityFrameworkCore.Design --version 10.0.3dotnet add SoftDeletes.Api package Scalar.AspNetCore --version 2.11.9Define the ISoftDeletable Interface
The first step is defining a marker interface that any soft-deletable entity will implement. This keeps the soft delete logic DRY - you write the interception and filtering code once, and it works for every entity that implements the interface.
namespace SoftDeletes.Api.Entities;
public interface ISoftDeletable{ bool IsDeleted { get; set; } DateTime? DeletedOnUtc { get; set; } string? DeletedBy { get; set; }}We include three properties:
IsDeleted- The flag that marks a record as soft-deleted. This is what the global query filter checks.DeletedOnUtc- The UTC timestamp of when the deletion happened. Essential for audit trails and time-based queries like “show me everything deleted in the last 7 days.”DeletedBy- Who performed the deletion. In a multi-user system, this is critical for accountability.
Create the Entities
Now create a Product entity and a Category entity that implement ISoftDeletable:
namespace SoftDeletes.Api.Entities;
public class Category : ISoftDeletable{ public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public bool IsDeleted { get; set; } public DateTime? DeletedOnUtc { get; set; } public string? DeletedBy { get; set; } public List<Product> Products { get; set; } = [];}namespace SoftDeletes.Api.Entities;
public class Product : ISoftDeletable{ public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } public Guid CategoryId { get; set; } public Category Category { get; set; } = null!; public bool IsDeleted { get; set; } public DateTime? DeletedOnUtc { get; set; } public string? DeletedBy { get; set; }}Notice both entities implement ISoftDeletable. This means our interceptor and query filter logic will automatically apply to both - and to any future entity that implements the interface.
Implementing Soft Delete with SaveChangesInterceptor
There are multiple ways to intercept delete operations in EF Core and convert them to soft deletes. The two most common approaches are:
SaveChangesInterceptor- A dedicated interceptor class registered with theDbContext. This is the recommended modern approach because it follows the single responsibility principle and is testable in isolation.SaveChangesAsyncoverride - OverrideSaveChangesAsyncdirectly in theDbContext. Simpler for small projects but mixes concerns.
We’ll use the interceptor approach. Here’s why it’s better:
| Approach | Pros | Cons |
|---|---|---|
SaveChangesInterceptor | Testable independently, follows SRP, reusable across DbContexts | Slightly more setup (register interceptor) |
SaveChangesAsync override | Less code, everything in one class | Mixes persistence and business logic, harder to test |
| Domain events | Full decoupling, event-driven | Over-engineered for most soft delete scenarios |
Create the SoftDeleteInterceptor
Create a SaveChangesInterceptor that intercepts any entity marked for deletion and converts it to a soft delete:
using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Diagnostics;using SoftDeletes.Api.Entities;
namespace SoftDeletes.Api.Data;
public class SoftDeleteInterceptor : SaveChangesInterceptor{ public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { if (eventData.Context is null) return ValueTask.FromResult(result);
foreach (var entry in eventData.Context.ChangeTracker.Entries<ISoftDeletable>()) { if (entry.State != EntityState.Deleted) continue;
entry.State = EntityState.Modified; entry.Entity.IsDeleted = true; entry.Entity.DeletedOnUtc = DateTime.UtcNow; }
return ValueTask.FromResult(result); }}Here’s what’s happening step by step:
- We inherit from
SaveChangesInterceptorand override theSavingChangesAsyncmethod. This fires before EF Core sends changes to the database. - We use the
ChangeTrackerto find all entries that implementISoftDeletableand are in theDeletedstate. - For each deleted entry, we change the state from
DeletedtoModified. This tells EF Core to generate anUPDATEstatement instead of aDELETE. - We set
IsDeleted = trueandDeletedOnUtc = DateTime.UtcNowon the entity. EF Core includes these changed properties in the generatedUPDATE.
This interceptor is database-agnostic - it works with any database provider supported by EF Core (SQL Server, PostgreSQL, SQLite, MySQL) because it operates on EF Core’s ChangeTracker before any database-specific code runs. The SaveChangesInterceptor API was introduced in EF Core 5 and is documented in the EF Core interceptors documentation.
The result? When your code calls context.Products.Remove(product), the database receives:
UPDATE "Products" SET "IsDeleted" = true, "DeletedOnUtc" = '2026-02-12T10:30:00Z' WHERE "Id" = @p0Instead of:
DELETE FROM "Products" WHERE "Id" = @p0The calling code doesn’t need to know about soft deletes at all. It calls Remove() as normal, and the interceptor handles the rest transparently.
What about
DeletedBy? We’ll set that from the HTTP context when we wire up the endpoints. The interceptor handles the core soft delete logic; the caller provides the “who.”
Configuring Named Query Filters for Soft Delete
Marking records as deleted is only half the solution. You also need to ensure soft-deleted records are automatically excluded from all queries. This is where global query filters come in.
In EF Core 10, we use named query filters so we can selectively disable the soft delete filter without affecting other filters (like multi-tenancy). This is configured in OnModelCreating:
using Microsoft.EntityFrameworkCore;using SoftDeletes.Api.Entities;
namespace SoftDeletes.Api.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options){ public DbSet<Product> Products => Set<Product>(); public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { // Named query filter - can be selectively disabled modelBuilder.Entity<Product>() .HasQueryFilter("SoftDelete", p => !p.IsDeleted);
modelBuilder.Entity<Category>() .HasQueryFilter("SoftDelete", c => !c.IsDeleted);
// Index on IsDeleted for query performance modelBuilder.Entity<Product>().HasIndex(p => p.IsDeleted); modelBuilder.Entity<Category>().HasIndex(c => c.IsDeleted);
// Relationship configuration modelBuilder.Entity<Product>() .HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId);
modelBuilder.Entity<Product>() .Property(p => p.Price) .HasColumnType("decimal(18,2)"); }}The key details:
- Named filter
"SoftDelete"- By giving the filter a name, we can later callIgnoreQueryFilters(["SoftDelete"])to see deleted records without disabling other filters. This is an EF Core 10 feature tracked in What’s New in EF Core 10. - Index on
IsDeleted- Since this filter runs on every query against these entities, theIsDeletedcolumn must be indexed. Without it, every query triggers a full table scan on this column.
With this setup, every query automatically excludes soft-deleted records:
// Generated SQL includes WHERE "IsDeleted" = falsevar products = await context.Products.ToListAsync(cancellationToken);Applying Filters Dynamically with the ISoftDeletable Interface
If you have many entities implementing ISoftDeletable, configuring the filter on each one individually gets repetitive. Use a loop in OnModelCreating to apply the filter to all soft-deletable entities automatically:
protected override void OnModelCreating(ModelBuilder modelBuilder){ foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { if (!typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType)) continue;
var parameter = Expression.Parameter(entityType.ClrType, "e"); var property = Expression.Property(parameter, nameof(ISoftDeletable.IsDeleted)); var condition = Expression.Equal(property, Expression.Constant(false)); var lambda = Expression.Lambda(condition, parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda); }}This uses System.Linq.Expressions to build the filter dynamically for any entity that implements the interface. Add ISoftDeletable to a new entity, and the filter is applied automatically - zero additional configuration.
Note: The dynamic approach above creates an unnamed filter via
HasQueryFilter(lambda). If you need named filters for selective disabling (which we recommend in EF Core 10), you’ll need to call the named overload on each entity explicitly or use reflection to invoke the genericHasQueryFilter<T>(string, Expression<Func<T, bool>>)method.
Querying Soft-Deleted Records
By default, soft-deleted records are invisible. But there are legitimate scenarios where you need to see them - admin panels, recycle bin features, audit reports, or data recovery.
Disable the Soft Delete Filter
In EF Core 10, use the named filter approach to disable only the soft delete filter:
// Show all products including deleted onesvar allProducts = await context.Products .IgnoreQueryFilters(["SoftDelete"]) .ToListAsync(cancellationToken);If you have other filters (like multi-tenancy), they remain active. This is the key advantage of named query filters - you get granular control without the “all-or-nothing” limitation of pre-EF Core 10.
Query Only Deleted Records
To show a “recycle bin” view with only deleted records:
var deletedProducts = await context.Products .IgnoreQueryFilters(["SoftDelete"]) .Where(p => p.IsDeleted) .OrderByDescending(p => p.DeletedOnUtc) .ToListAsync(cancellationToken);This pattern is useful for building admin dashboards that show recently deleted items with options to restore them.
Undo/Restore Soft-Deleted Records
One of the biggest advantages of soft delete over hard delete is the ability to restore records. Since the data is still in the database, restoring is just setting IsDeleted back to false.
app.MapPost("/products/{id:guid}/restore", async (Guid id, AppDbContext context, CancellationToken cancellationToken) =>{ var product = await context.Products .IgnoreQueryFilters(["SoftDelete"]) .FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
if (product is null) return Results.NotFound(); if (!product.IsDeleted) return Results.BadRequest("Product is not deleted.");
product.IsDeleted = false; product.DeletedOnUtc = null; product.DeletedBy = null;
await context.SaveChangesAsync(cancellationToken); return Results.Ok(product);});The key here is IgnoreQueryFilters(["SoftDelete"]) - without it, the query won’t find the deleted record because the filter excludes it. We find the record with filters bypassed, verify it’s actually deleted, clear the deletion metadata, and save.
Cascade Soft Delete - Handling Related Entities
Here’s a question that most soft delete tutorials skip: what happens to child entities when you soft-delete a parent?
If you soft-delete a Category, the products in that category are still in the database with IsDeleted = false. They’re technically “active” but point to a deleted parent - an orphaned state that can cause confusing behavior in your application.
The Problem
// Soft-delete the "Electronics" categoryvar category = await context.Categories .Include(c => c.Products) .FirstOrDefaultAsync(c => c.Name == "Electronics", cancellationToken);
context.Categories.Remove(category!); // Interceptor converts to soft deleteawait context.SaveChangesAsync(cancellationToken);
// Products still show up! Their category is soft-deleted, but they aren't.var orphanedProducts = await context.Products.ToListAsync(cancellationToken);The Solution: Cascade Soft Delete in the Interceptor
Extend the SoftDeleteInterceptor to automatically soft-delete loaded child entities:
public class SoftDeleteInterceptor : SaveChangesInterceptor{ public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { if (eventData.Context is null) return ValueTask.FromResult(result);
foreach (var entry in eventData.Context.ChangeTracker.Entries<ISoftDeletable>()) { if (entry.State != EntityState.Deleted) continue;
entry.State = EntityState.Modified; entry.Entity.IsDeleted = true; entry.Entity.DeletedOnUtc = DateTime.UtcNow;
// Cascade soft delete to loaded child entities CascadeSoftDelete(eventData.Context, entry.Entity); }
return ValueTask.FromResult(result); }
private static void CascadeSoftDelete(DbContext context, ISoftDeletable parentEntity) { var parentEntry = context.Entry(parentEntity);
foreach (var navigation in parentEntry.Navigations) { if (navigation.CurrentValue is null) continue;
if (navigation.CurrentValue is IEnumerable<ISoftDeletable> children) { foreach (var child in children) { if (child.IsDeleted) continue; child.IsDeleted = true; child.DeletedOnUtc = DateTime.UtcNow; context.Entry(child).State = EntityState.Modified; } } else if (navigation.CurrentValue is ISoftDeletable child && !child.IsDeleted) { child.IsDeleted = true; child.DeletedOnUtc = DateTime.UtcNow; context.Entry(child).State = EntityState.Modified; } } }}The CascadeSoftDelete method iterates through all navigation properties of the deleted parent entity. For each child entity that implements ISoftDeletable, it sets the soft delete flags. This only works for loaded navigation properties - you must Include() the children before removing the parent.
Important: This cascade only affects entities currently tracked by the
ChangeTracker. If you have 10,000 products under a category and only loaded 50 withInclude(), only those 50 get cascade-soft-deleted. For large-scale cascade operations, consider usingExecuteUpdateAsyncdirectly:
// Bulk cascade soft delete - no need to load entities into memoryawait context.Products .Where(p => p.CategoryId == categoryId) .ExecuteUpdateAsync(s => s .SetProperty(p => p.IsDeleted, true) .SetProperty(p => p.DeletedOnUtc, DateTime.UtcNow), cancellationToken);This generates a single UPDATE statement that sets all products under the category as deleted - efficient even for millions of rows.
Wiring Everything Together
Register Services in Program.cs
using Microsoft.EntityFrameworkCore;using Scalar.AspNetCore;using SoftDeletes.Api.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<SoftDeleteInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>{ options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")); options.AddInterceptors(sp.GetRequiredService<SoftDeleteInterceptor>());});
var app = builder.Build();
app.MapOpenApi();app.MapScalarApiReference();Note that we register the SoftDeleteInterceptor as a singleton - it’s stateless and thread-safe, so one instance serves all requests.
Add the API Endpoints
app.MapGet("/categories", async (AppDbContext context, CancellationToken cancellationToken) => Results.Ok(await context.Categories .Include(c => c.Products) .ToListAsync(cancellationToken)));
app.MapPost("/categories", async (string name, AppDbContext context, CancellationToken cancellationToken) =>{ var category = new Category { Name = name }; context.Categories.Add(category); await context.SaveChangesAsync(cancellationToken); return Results.Created($"/categories/{category.Id}", category);});
app.MapDelete("/categories/{id:guid}", async (Guid id, AppDbContext context, CancellationToken cancellationToken) =>{ var category = await context.Categories .Include(c => c.Products) // Load children for cascade soft delete .FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
if (category is null) return Results.NotFound();
context.Categories.Remove(category); await context.SaveChangesAsync(cancellationToken); return Results.NoContent();});
app.MapPost("/categories/{id:guid}/restore", async (Guid id, AppDbContext context, CancellationToken cancellationToken) =>{ var category = await context.Categories .IgnoreQueryFilters(["SoftDelete"]) .Include(c => c.Products.Where(p => p.IsDeleted)) .FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
if (category is null) return Results.NotFound(); if (!category.IsDeleted) return Results.BadRequest("Category is not deleted.");
category.IsDeleted = false; category.DeletedOnUtc = null; category.DeletedBy = null;
// Restore cascade-deleted children foreach (var product in category.Products) { product.IsDeleted = false; product.DeletedOnUtc = null; product.DeletedBy = null; }
await context.SaveChangesAsync(cancellationToken); return Results.Ok(category);});
app.MapGet("/categories/deleted", async (AppDbContext context, CancellationToken cancellationToken) => Results.Ok(await context.Categories .IgnoreQueryFilters(["SoftDelete"]) .Where(c => c.IsDeleted) .OrderByDescending(c => c.DeletedOnUtc) .ToListAsync(cancellationToken)));
app.Run();This gives us a complete API with:
- GET /categories - Returns only active (non-deleted) categories with their products
- POST /categories - Creates a new category
- DELETE /categories/
{id}- Soft-deletes a category and cascade-soft-deletes its products - POST /categories/
{id}/restore - Restores a soft-deleted category and its products - GET /categories/deleted - Shows the “recycle bin” - all soft-deleted categories
Soft Delete and Unique Constraints
A common gotcha with soft delete is unique constraints. Consider an email column with a unique index:
modelBuilder.Entity<User>().HasIndex(u => u.Email).IsUnique();If a user with email [email protected] is soft-deleted, the row still exists in the database. A new user trying to register with the same email will violate the unique constraint - even though the original user is “deleted” from the application’s perspective.
The Fix: Filtered Unique Index
PostgreSQL (and SQL Server) support filtered indexes that only include non-deleted rows:
modelBuilder.Entity<User>() .HasIndex(u => u.Email) .IsUnique() .HasFilter("\"IsDeleted\" = false");This generates:
CREATE UNIQUE INDEX "IX_Users_Email" ON "Users" ("Email") WHERE "IsDeleted" = false;Now the uniqueness constraint only applies to active records. Soft-deleted records are excluded from the index entirely, so you can have multiple soft-deleted users with the same email without conflicts.
Performance Considerations
Soft delete has performance implications that you need to plan for:
Index the IsDeleted Column
This is non-negotiable. Since the query filter runs on every query, the IsDeleted column must be indexed:
modelBuilder.Entity<Product>().HasIndex(p => p.IsDeleted);For tables with additional frequently-queried columns, use a composite index:
modelBuilder.Entity<Product>().HasIndex(p => new { p.IsDeleted, p.CategoryId });Table Bloat Over Time
Soft-deleted records accumulate. A table with 1 million rows where 80% are soft-deleted is wasteful - the database scans and indexes include dead weight. Consider an archival strategy:
- Periodically move soft-deleted records older than N days to an archive table
- Use
ExecuteDeleteAsyncfor permanent purging after the retention period - Monitor table sizes and index bloat in production
Query Plan Caching
When the filter references a constant (!p.IsDeleted), EF Core generates the same SQL every time, and the database caches the query plan efficiently. This is better than dynamic filters that reference instance fields - constant filters let the database optimizer make stronger assumptions about data distribution.
Key Takeaways
- Soft delete marks records as deleted instead of physically removing them, enabling data recovery, audit compliance, and referential integrity preservation.
- Use
SaveChangesInterceptorto transparently convertRemove()calls into soft deletes - the calling code doesn’t need to know about the implementation. - Named query filters in EF Core 10 let you define a
"SoftDelete"filter that can be selectively disabled withIgnoreQueryFilters(["SoftDelete"])without affecting other filters. - Cascade soft delete ensures child entities are soft-deleted when a parent is deleted. Use
Include()for tracked entities orExecuteUpdateAsyncfor bulk operations. - Undo/restore is straightforward - query with
IgnoreQueryFilters, setIsDeleted = false, and save. - Index the
IsDeletedcolumn and use filtered unique indexes to handle uniqueness constraints on soft-deletable entities.
Troubleshooting Common Issues
Soft-Deleted Records Still Appearing in Queries
Verify that the global query filter is configured in OnModelCreating for the entity. If using the dynamic interface-based approach, confirm the entity implements ISoftDeletable. Also check that you haven’t accidentally called IgnoreQueryFilters() somewhere in the query chain.
Remove() Still Generates a DELETE Statement
The SoftDeleteInterceptor must be registered with the DbContext via AddInterceptors. Verify the registration in Program.cs:
options.AddInterceptors(sp.GetRequiredService<SoftDeleteInterceptor>());If the interceptor isn’t registered, EF Core falls back to the default behavior - a hard delete.
Cascade Soft Delete Not Working for Child Entities
The cascade only affects tracked entities. You must Include() the children before calling Remove() on the parent. If children aren’t loaded, they won’t be in the ChangeTracker and the interceptor can’t modify them. For unloaded children, use ExecuteUpdateAsync as shown in the cascade section.
Unique Constraint Violation When Creating a Record
If a soft-deleted record has the same value as a new record in a unique-indexed column, you’ll get a constraint violation. Use a filtered unique index (HasFilter("\"IsDeleted\" = false")) to exclude soft-deleted rows from the uniqueness check.
Restore Endpoint Returns 404
When querying for a soft-deleted record to restore it, you must include IgnoreQueryFilters(["SoftDelete"]) in the query. Without it, the global filter excludes the deleted record and EF Core returns null.
Performance Degradation Over Time
As soft-deleted records accumulate, table size grows and queries slow down. Add an index on IsDeleted, monitor table bloat, and implement an archival or purging strategy for records past their retention period.
What is soft delete in Entity Framework Core?
Soft delete is a data persistence pattern where records are marked as deleted by setting a boolean flag (typically IsDeleted) to true instead of physically removing them from the database. In EF Core, this is implemented using a SaveChangesInterceptor that converts DELETE operations into UPDATE operations, combined with a global query filter that automatically excludes soft-deleted records from all queries.
What is the difference between soft delete and hard delete in EF Core?
Hard delete uses the EF Core Remove method to generate a SQL DELETE statement that permanently removes the row from the database. Soft delete converts the Remove call into an UPDATE that sets IsDeleted to true, keeping the row in the database but hiding it from normal queries via a global filter. Soft delete preserves data for recovery and auditing, while hard delete is irreversible without database backups.
How do I implement soft delete in EF Core 10?
Create an ISoftDeletable interface with IsDeleted, DeletedOnUtc, and DeletedBy properties. Implement it on your entities. Create a SaveChangesInterceptor that converts EntityState.Deleted entries to EntityState.Modified and sets the soft delete properties. Register the interceptor with your DbContext via AddInterceptors. Finally, add a named query filter with HasQueryFilter(SoftDelete, e => !e.IsDeleted) in OnModelCreating.
How do I query soft-deleted records in EF Core?
Use the IgnoreQueryFilters method to bypass the soft delete global query filter. In EF Core 10, you can selectively disable only the soft delete filter with IgnoreQueryFilters([SoftDelete]) while keeping other filters like multi-tenancy active. To show only deleted records, chain a Where(e => e.IsDeleted) after IgnoreQueryFilters.
How do I restore a soft-deleted record in EF Core?
Query the record using IgnoreQueryFilters to bypass the soft delete filter, then set IsDeleted to false, DeletedOnUtc to null, and DeletedBy to null. Call SaveChangesAsync to persist the changes. The record will reappear in normal queries since the global filter no longer excludes it.
What is cascade soft delete and how does it work?
Cascade soft delete automatically marks child entities as deleted when a parent entity is soft-deleted, similar to how database cascade delete works for hard deletes. It is implemented by iterating through the parent entity navigation properties in the SaveChangesInterceptor and setting IsDeleted to true on all loaded child entities that implement ISoftDeletable. For bulk operations, use ExecuteUpdateAsync to cascade without loading entities into memory.
Should I add an index on the IsDeleted column?
Yes, indexing the IsDeleted column is essential for performance. Since the global query filter adds a WHERE IsDeleted = false clause to every query against the entity, the database needs an index to avoid full table scans. For tables with additional frequently-queried columns, use a composite index that includes IsDeleted alongside those columns.
How do I handle unique constraints with soft delete in EF Core?
Use a filtered unique index that only includes non-deleted records. In EF Core, configure this with HasIndex(u => u.Email).IsUnique().HasFilter(IsDeleted = false). This creates a SQL index that enforces uniqueness only for active records, allowing soft-deleted records with duplicate values to coexist without constraint violations.
Summary
Soft delete is one of those patterns that seems simple - just add an IsDeleted flag, right? - but the implementation details matter. You need interception to convert deletes transparently, global query filters to hide deleted records automatically, cascade logic to handle related entities, restore capability for recovery, and indexed columns for performance. Miss any of these, and you’ll end up with subtle bugs or data leaks.
EF Core 10’s named query filters make the pattern significantly cleaner than before. You can define a "SoftDelete" filter alongside other filters and disable them independently - solving the longstanding “all-or-nothing” problem that made soft delete and multi-tenancy awkward to combine in older EF Core versions.
The full source code for this article is available on GitHub. If you found this helpful, share it with your colleagues - and if there’s a topic you’d like to see covered next, drop a comment and let me know.
Happy Coding :)


