Soft Delete with EF Core: Delete Data Without Losing It
Learn soft delete in EF Core the right way. Use an interceptor and global query filters to hide deleted rows automatically, with simple examples, diagrams, code, and best practices for .NET 10.
The recycle bin on your computer
When you delete a file on your computer, it does not vanish instantly. It goes to the Recycle Bin. The file is out of your way — you do not see it in your folders anymore — but it is still there. If you deleted it by mistake, you can restore it. Only when you "empty the bin" is it truly gone.
Soft delete in a database works exactly like the Recycle Bin. Instead of really removing a row, you just mark it as deleted with a flag like IsDeleted = true. The row stays safely in the table, but your application pretends it is gone — it disappears from normal lists and searches. You keep the history, you can restore mistakes, and you never lose important data by accident.
This is extremely common in real business apps, where deleting a customer, an order, or an invoice forever is risky. Let us learn how to do soft delete the right way in EF Core — automatically, so you never forget.
What soft delete looks like
A "hard delete" runs DELETE FROM Orders WHERE Id = 5 and the row is gone for good. A "soft delete" instead runs UPDATE Orders SET IsDeleted = 1 WHERE Id = 5 — the row stays, just flagged.
To support this, your entities carry a few extra columns. A small interface keeps it tidy:
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTime? DeletedOnUtc { get; set; }
string? DeletedBy { get; set; }
}
public class Order : ISoftDeletable
{
public int Id { get; set; }
public decimal Total { get; set; }
// Soft delete columns
public bool IsDeleted { get; set; }
public DateTime? DeletedOnUtc { get; set; }
public string? DeletedBy { get; set; }
}The IsDeleted flag does the hiding. The DeletedOnUtc and DeletedBy columns give you a simple audit trail — when it was deleted and by whom.
The naive way (and why it is painful)
A beginner might handle soft delete by hand everywhere:
// Hiding deleted rows manually — easy to forget!
var orders = await db.Orders
.Where(o => !o.IsDeleted) // you must add this to EVERY query
.ToListAsync();This works, but it is fragile. You have to remember Where(o => !o.IsDeleted) on every single query, in every part of the app, forever. Miss it once and deleted orders suddenly reappear in a report. We can do far better by letting EF Core handle it automatically.
Step 1: Hide deleted rows with a global query filter
EF Core has a feature called global query filters. You declare a filter for an entity once, and EF Core adds it to every query for that entity automatically. For soft delete, the filter is "only show rows that are not deleted":
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter(o => !o.IsDeleted); // applied to every Order query
}Now this simple query:
var orders = await db.Orders.ToListAsync();…automatically becomes SELECT * FROM Orders WHERE IsDeleted = 0. You never write the filter by hand again. Deleted rows are invisible everywhere, by default.
How Automatic Soft Delete Works
Steps
Remove
Your code calls db.Orders.Remove(order) as usual
Intercept
A SaveChanges interceptor catches the delete
Flag
It sets IsDeleted = true instead of deleting
Hide
The global query filter excludes deleted rows
Keep
The row stays safely in the table for history
Step 2: Turn real deletes into soft deletes with an interceptor
The query filter hides deleted rows, but we still need to make Remove() perform a soft delete instead of a hard one. We do this with a SaveChanges interceptor, which runs just before EF Core saves to the database.
The interceptor looks through the change tracker, finds entities you marked for deletion that implement ISoftDeletable, and quietly changes them from "delete" to "update with IsDeleted = true":
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var context = eventData.Context;
if (context is null) return base.SavingChangesAsync(eventData, result, cancellationToken);
foreach (var entry in context.ChangeTracker.Entries<ISoftDeletable>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified; // do not really delete
entry.Entity.IsDeleted = true; // flag it instead
entry.Entity.DeletedOnUtc = DateTime.UtcNow;
}
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}Register it on your DbContext:
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(new SoftDeleteInterceptor());
});Now your normal code works without change:
var order = await db.Orders.FindAsync(5);
db.Orders.Remove(order); // looks like a real delete...
await db.SaveChangesAsync(); // ...but the interceptor makes it a soft deleteYour developers call Remove() as they always do. Behind the scenes, the interceptor turns it into a flag update, and the query filter hides it. Everything is automatic.
Reading and restoring deleted rows
Sometimes you genuinely need the deleted rows — for an admin "Recycle Bin" screen, or to restore something. You can tell EF Core to ignore the query filter for one query:
// Show deleted orders too (e.g., an admin recovery page)
var deleted = await db.Orders
.IgnoreQueryFilters()
.Where(o => o.IsDeleted)
.ToListAsync();Restoring is then just flipping the flag back:
order.IsDeleted = false;
order.DeletedOnUtc = null;
await db.SaveChangesAsync();In EF Core 10, you can give filters names so an entity can have several independent filters — for example, one for soft delete and one for multi-tenancy. Named filters let you disable just the soft-delete filter while keeping the tenant filter active, which the old single-filter approach could not do.
Hard delete vs soft delete
| Hard delete | Soft delete | |
|---|---|---|
| Row in database | Removed forever | Stays, flagged |
| Can recover? | No | Yes |
| Keeps history / audit? | No | Yes |
| Table size | Smaller | Grows over time |
| Query speed | Slightly simpler | Needs an index on IsDeleted |
Soft delete buys you safety and history, at the cost of a growing table and one extra filter on every query. For most business data, that trade is well worth it.
A tricky case: cascade soft delete
What should happen to child rows when you soft delete a parent? If you soft delete an Order, should its OrderItems be soft deleted too? This is the cascade question, and you must decide it deliberately.
Deciding Cascade Behaviour for Soft Delete
Steps
Delete parent
An Order is soft deleted
Has children?
It owns OrderItems, payments, etc.
Cascade
Also flag the children as deleted (most common)
Keep
Leave children visible if they make sense alone
Block
Refuse the delete while active children exist
The most common choice is cascade: when the parent is hidden, the children should be hidden too, so you do not show orphaned line items for an order that no longer exists. You can extend the interceptor to walk the parent's children and flag them as well.
Here is a quick guide for which kind of data suits which delete style:
| Data type | Suggested delete | Why |
|---|---|---|
| Customers, orders, invoices | Soft delete | History, recovery, audit, legal records |
| User accounts | Soft delete | Recover mistakes, keep audit trail |
| Temporary logs, sessions | Hard delete | No history needed; keep tables lean |
| Cache / expiry rows | Hard delete | Short-lived by nature |
Best practices
- Index the
IsDeletedcolumn. Every query now filters on it, so an index keeps things fast as the table grows. - Use a filtered unique index where needed. If a column must be unique only among active rows (like an email), use a filtered unique index
WHERE IsDeleted = 0, so a deleted row does not block a new one. - Decide on cascade behaviour. When you soft delete an order, should its line items be soft deleted too? Plan this — EF Core 10 supports cascade soft delete patterns.
- Purge old data eventually. A soft-deleted table grows forever. Have a background job that hard-deletes or archives rows older than your retention policy.
- Test it in CI. Integration tests should confirm the interceptor flags rows, the filter hides them, and
IgnoreQueryFiltersbrings them back — run on every migration change.
Remember that soft-deleted rows still take up space and can slow queries if the table grows huge. Soft delete is not "free storage forever" — pair it with a sensible purge/archival policy for old records.
When to use soft delete
Soft delete is a great default for important business data: customers, orders, invoices, accounts, documents — anything where accidental loss would be painful or where you need an audit trail. Regulations sometimes even require you to keep records for years, which soft delete supports naturally.
You can skip it for throwaway or high-volume data where history does not matter — like temporary logs, expired sessions, or cache entries. For those, a real hard delete (or automatic expiry) keeps tables lean.
Quick recap
- Soft delete marks a row as deleted (
IsDeleted = true) instead of removing it — like the Recycle Bin keeps your files. - A global query filter (
HasQueryFilter(o => !o.IsDeleted)) hides deleted rows from every query automatically. - A SaveChanges interceptor turns normal
Remove()calls into soft deletes, so your code does not change. - Use
IgnoreQueryFilters()to read or restore deleted rows, and EF Core 10 named filters to combine soft delete with other filters. - Index
IsDeleted, plan cascade behaviour, and purge old rows so the table does not grow forever.
With one interceptor and one query filter, EF Core gives every entity its own Recycle Bin — safe deletes, easy recovery, and a clean audit trail, all without changing how your developers write code. Set it up once, index the flag, decide your cascade rules, and add a purge job for old records, and you get all the safety of "nothing is ever really lost" without scattering IsDeleted checks across your whole codebase. For real business data, that peace of mind is well worth the small extra column.
References and further reading
- Global Query Filters — EF Core (Microsoft Learn) — the official documentation for query filters.
- Implementing Soft Delete With EF Core — Milan Jovanović — a clear, practical .NET walkthrough.
- Soft Deletes in EF Core 10 — codewithmukesh — a recent guide covering interceptors, named filters, and cascade delete.
Related Posts
Global Query Filters in EF Core: Soft Delete and Multi-Tenancy Made Easy
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in .NET 10.
How to Use EF Core Interceptors: A Beginner-Friendly Guide
Learn EF Core interceptors step by step. Add auditing, soft delete, logging, and timing to your DbContext with clean, reusable code and zero clutter.
How to Implement an Audit Trail in ASP.NET Core with EF Core
Build an automatic audit trail in ASP.NET Core with EF Core using a SaveChanges interceptor and the ChangeTracker. Simple examples, diagrams, and best practices for .NET 10.
Multi-Tenant Applications With EF Core: A Beginner's Guide
Learn multi-tenancy in EF Core the simple way. Isolate tenant data with global query filters, ITenantService, and named filters in .NET 10, with diagrams and code.
EF Core Migrations: A Detailed Beginner Guide for .NET
Learn EF Core migrations step by step. Add, apply, revert, and ship database changes safely with simple examples, diagrams, tables, and best practices for .NET 10.
5 Hidden EF Core NuGet Packages That Make Your .NET Code Better
Five lesser-known EF Core NuGet packages for clean exceptions, naming conventions, bulk speed, dynamic queries, and auditing — with simple examples and diagrams.