Skip to main content
SEMastery
Data Accessintermediate

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.

11 min readUpdated June 4, 2026

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.

Figure 1: Hard delete removes the row forever. Soft delete keeps the row but flags it as deleted, like moving a file to the Recycle Bin.

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

Call Remove()
Interceptor catches it
Set IsDeleted = true
Query Filter hides it
Row stays in DB

Steps

1

Remove

Your code calls db.Orders.Remove(order) as usual

2

Intercept

A SaveChanges interceptor catches the delete

3

Flag

It sets IsDeleted = true instead of deleting

4

Hide

The global query filter excludes deleted rows

5

Keep

The row stays safely in the table for history

An interceptor turns deletes into flag updates. A global query filter hides flagged rows from every query.

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 delete

Your 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.

Figure 2: The lifecycle of a soft-deleted row — created, hidden when deleted, and optionally restored.

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 deleteSoft delete
Row in databaseRemoved foreverStays, flagged
Can recover?NoYes
Keeps history / audit?NoYes
Table sizeSmallerGrows over time
Query speedSlightly simplerNeeds 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

Soft delete parent
Children?
Cascade flag
Keep children
Block delete

Steps

1

Delete parent

An Order is soft deleted

2

Has children?

It owns OrderItems, payments, etc.

3

Cascade

Also flag the children as deleted (most common)

4

Keep

Leave children visible if they make sense alone

5

Block

Refuse the delete while active children exist

When a parent is soft deleted, choose what happens to its children: cascade the flag, keep them, or block the delete.

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.

Figure 3: A query with the filter sees only active rows; IgnoreQueryFilters reveals the hidden, soft-deleted rows for admin or restore screens.

Here is a quick guide for which kind of data suits which delete style:

Data typeSuggested deleteWhy
Customers, orders, invoicesSoft deleteHistory, recovery, audit, legal records
User accountsSoft deleteRecover mistakes, keep audit trail
Temporary logs, sessionsHard deleteNo history needed; keep tables lean
Cache / expiry rowsHard deleteShort-lived by nature

Best practices

  • Index the IsDeleted column. 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 IgnoreQueryFilters brings 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

Related Posts