Implementing Soft Delete in EF Core: A Practical Guide for Safer Data Management

Article Sponsors

EF Core too slow? Insert data up to 14x faster and cut save time by 94%. Boost performance with extension methods fully integrated into EF Core β€” Bulk Insert, Update, Delete, and Merge.

Join 5,000+ developers who’ve trusted our library since 2014.

πŸ‘‰ Try it now β€” and feel the difference

In many real-world applications, deleting data permanently is risky. Once a record is removed from the database, recovering it is difficult and sometimes impossible.

That’s why most production systems use soft delete β€” instead of removing data, records are marked as deleted while still remaining in the database.

In this article, we’ll implement soft delete in EF Core using a clean, scalable, and production-ready approach.

What is Soft Delete in EF Core

In traditional systems, when a record is deleted, it is physically removed from the database. This is called a hard delete.

But in many real-world applications, permanently deleting data is not always a good idea.

Soft delete solves this by not actually removing the record. Instead, it marks the record as deleted using a flag (usually IsDeleted).

Why Use Soft Delete

Soft delete is widely used in production systems:

  • Data Recovery – Easily restore deleted records
  • Audit & Compliance – Maintain historical data
  • Safety – Prevent accidental data loss
  • Business Needs – Keep references to deleted records

Basic Soft Delete Implementation

In real systems, it’s better to store additional metadata instead of just a boolean flag.

C#
public sealed class Customer
{
    public Guid Id { get; set; }
    public string Name { get; set; } = null!;

    public bool IsDeleted { get; set; }
    public DateTime? DeletedOnUtc { get; set; }
}

In this implementation, the system does not remove the record. Instead, it updates the IsDeleted flag to indicate the delete state. In addition, it records the deletion time using DeletedOnUtc.

Because of this, the application can track when the deletion happened. Moreover, it becomes easier to restore the record later if needed.

Creating a Reusable Soft Delete Contract

When building real applications, adding soft delete properties to each entity manually is not a good approach.

Instead, create a reusable contract that can be applied across all entities.

What is a Soft Delete Contract?

A contract defines a common structure that all soft-deletable entities must follow.

C#
public interface ISoftDeletable
{
    bool IsDeleted { get; set; }
    DateTime? DeletedOnUtc { get; set; }
}
Why Use an Interface?

Using an interface provides several benefits:

  • Consistency
    • All entities follow the same structure
    • No missing properties like IsDeleted
  • Scalability
    • Easily apply soft delete to new entities
    • No need to rewrite logic
  • Cleaner Code
    • Avoid duplication across entities
    • Centralized design
  • Generic Handling
    • EF Core can process all soft-deletable entities together
    • Useful for SaveChanges or Interceptors
Applying the Contract
C#
public sealed class Customer : ISoftDeletable
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;

    public bool IsDeleted { get; set; }
    public DateTime? DeletedOnUtc { get; set; }
}

By doing this, we create consistency across the application. Furthermore, EF Core can process all soft-deletable entities in a generic way. As a result, the code becomes cleaner and easier to maintain.

Applying Global Query Filters

With this configuration, EF Core automatically excludes deleted records from queries. Therefore, developers do not need to add filtering logic manually.

For example, when the application runs a simple query, EF Core internally applies the filter. As a result, only active records are returned.

C#
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .HasQueryFilter(c => !c.IsDeleted);
}
Named Query Filters in EF Core 10

In EF Core 10, we can assign names to query filters. This makes the system more flexible.

For example, when multiple filters exist, we can manage them independently. In addition, we can disable or adjust specific filters without affecting others. Therefore, this feature works well in complex systems that use soft delete, multi-tenancy, and security rules together.

C#
modelBuilder.Entity<Customer>()
    .HasQueryFilter("SoftDeleteFilter", c => !c.IsDeleted);

When soft delete is implemented with a global query filter, EF Core automatically hides records where IsDeleted is true. That is helpful for normal application behavior because deleted data does not appear by mistake.

However, there are situations where deleted records still need to be viewed. For example, an admin may want to see deleted customers, restore them, or check when something was deleted. In those cases, the global filter becomes a problem because it continues to hide those rows.

That is why IgnoreQueryFilters() is needed.

C#
var deletedCustomers = await dbContext.Customers
    .IgnoreQueryFilters()
    .Where(c => c.IsDeleted)
    .ToListAsync();

IgnoreQueryFilters() method tells EF Core not to apply the global query filters for this query.

EF Core performance optimization sponsor banner showing bulk insert, update, delete, and merge features with 14x faster data operations.

Overriding SaveChanges for Soft Delete

When EF Core processes changes, it tracks entity states such as Added, Modified, and Deleted. In this case, we intercept entities marked as Deleted.

Instead of allowing EF Core to remove them, we change their state to Modified. Then, we update the soft delete properties.

As a result, EF Core executes an UPDATE query instead of a DELETE query. Therefore, the data remains in the database while the system treats it as deleted.

C#
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
    var entries = ChangeTracker
        .Entries<ISoftDeletable>()
        .Where(e => e.State == EntityState.Deleted);

    foreach (var entry in entries)
    {
        entry.State = EntityState.Modified;
        entry.Entity.IsDeleted = true;
        entry.Entity.DeletedOnUtc = DateTime.UtcNow;
    }

    return await base.SaveChangesAsync(ct);
}

EF Core Interceptors

Interceptors provide a cleaner alternative to overriding SaveChanges. Instead of placing logic inside the DbContext, we move it into a separate component.

Because of this, the DbContext remains simple and focused. In addition, the same interceptor can be reused across multiple contexts. Therefore, this approach improves maintainability and scalability.

C#
public sealed class SoftDeleteInterceptor : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        DbContext? context = eventData.Context;

        if (context is null)
        {
            return base.SavingChangesAsync(eventData, result, cancellationToken);
        }
        
        var entries = context
            .ChangeTracker
            .Entries<ISoftDeletable>()
            .Where(e => e.State == EntityState.Deleted);

        foreach (var entry in entries)
        {
            entry.State = EntityState.Modified;
            entry.Entity.IsDeleted = true;
            entry.Entity.DeletedOnUtc = DateTime.UtcNow;
        }

        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}
Registering the Interceptor

To activate the interceptor, we register it during DbContext configuration. Once registered, EF Core automatically executes it whenever SaveChanges runs.

C#
builder.Services.AddScoped<SoftDeleteInterceptor>();

builder.Services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
    options.UseSqlServer(connectionString)
           .AddInterceptors(sp.GetRequiredService<SoftDeleteInterceptor>());
});

As a result, the application applies soft delete logic globally without additional code.

Performance Considerations

Soft delete adds a filter to almost every query. Therefore, indexing the IsDeleted column improves performance significantly.

C#
modelBuilder.Entity<Customer>()
    .HasIndex(c => c.IsDeleted);

When using soft delete, most queries in the system only need active records (where IsDeleted = false). However, if a normal index is used, the database still includes all rows β€” both active and deleted β€” inside the index.

This is where a filtered index helps. However, it’s important to know that filtered indexes depend on the database provider.

A filtered index only stores rows that match a specific condition. In this case, it only stores records where IsDeleted = 0 (active records). Because of that, the index becomes much smaller compared to a full index.

C#
modelBuilder.Entity<Customer>()
    .HasIndex(c => c.IsDeleted)
    .HasFilter("[IsDeleted] = 0");

Now think about how the database works during a query:

  • Without a filtered index – the database scans an index that includes both active and deleted records
  • With a filtered index – the database scans only active records

As a result, the database does less work.

Common Pitfalls and Best Practices

Soft delete is useful, but it also introduces a few things that need attention. If these are not handled correctly, the application can behave in unexpected ways.

Bulk operations can bypass soft delete logic

One common issue is bulk operations such as ExecuteDeleteAsync(). These operations send delete commands directly to the database. Because of that, EF Core does not go through the normal change tracking pipeline. As a result, the soft delete logic inside SaveChanges or an interceptor will not run.

That means a bulk delete can perform a real hard delete even when the application normally uses soft delete. So, if the application relies on soft delete, bulk delete operations should be used carefully. In many cases, it is better to use bulk update logic instead and set IsDeleted = true.

Raw SQL does not respect query filters

Another thing to remember is that global query filters only work with EF Core queries. If raw SQL is used, EF Core does not automatically apply the IsDeleted = false condition.

That means deleted rows can appear in results if the SQL query does not filter them manually. Because of that, when writing raw SQL, always remember to include the soft delete condition if only active data should be returned.

Relationships need special thinking

Soft delete becomes tricky when entities are related, like a customer and their orders.

If a customer is deleted, EF Core does not automatically decide what happens to related data. The orders can either stay active or be soft deleted as well. This decision depends on business rules. Some systems hide related data, while others keep it for reporting or history.

So, soft delete should always be designed with relationships in mind, not just individual tables.

Indexing matters for performance

Since soft delete adds filtering to most queries, performance can suffer on large tables if the database is not optimized. That is why indexing IsDeleted or using a filtered index is important.

Without proper indexing, the database may scan more data than necessary. Over time, this can slow down queries noticeably.

Use UTC time for deleted timestamps

When storing values such as DeletedOnUtc, using DateTime.UtcNow is the safer option. This avoids confusion across servers, environments, and users in different time zones.

If local time is used, it can become harder to compare timestamps or understand when a delete actually happened.

Keep the implementation consistent

Soft delete works well only when it is applied consistently. If some entities use soft delete and others use hard delete without a clear reason, the system can become confusing.

For example, one part of the application may still show deleted data, while another part hides it. That inconsistency can create bugs and make the behavior harder to understand.

A good practice is to decide clearly where soft delete should apply and then keep that rule consistent across the application.

Soft Delete vs Hard Delete: When to Use What

Soft delete and hard delete both solve different problems. Neither is always right or always wrong. The correct choice depends on the type of data and the business requirements.

When soft delete is a good choice

Soft delete works well when the data may be needed later. For example, if records may need to be restored, reviewed, or audited, soft delete is usually the better option.

It is also useful when deleted data still has business value. A deleted customer, user, or product might still be referenced in reports, logs, or related records. In these cases, keeping the row in the database helps preserve system history and prevents broken references.

Soft delete is also safer when accidental deletion is a concern. Instead of losing data permanently, the system can hide it and allow recovery later.

When hard delete makes more sense

Hard delete is better when the data truly has no future value and should be removed permanently. This is common for temporary data, cache data, old logs in some systems, or test records.

It can also make sense when storage size is important and keeping deleted data would create unnecessary growth.

In some cases, legal or privacy requirements may also require permanent deletion. In those situations, soft delete may not be enough because the data still remains in the database.

Summary

Soft delete is a practical approach to protect data from permanent loss. Instead of removing records, the system marks them as deleted and controls their visibility using EF Core features like global query filters.

By combining query filters, interceptors or SaveChanges logic, and proper indexing, soft delete can be implemented in a clean and scalable way. In addition, it allows restoring data easily and supports real-world needs like auditing and history tracking.

Takeaways

  • Soft delete keeps data instead of permanently removing it
  • Use IsDeleted and DeletedOnUtc to manage delete state
  • Global query filters automatically hide deleted records
  • Use IgnoreQueryFilters() when accessing deleted data
  • Interceptors provide a cleaner approach than overriding SaveChanges
  • Indexing improves performance for filtered queries
  • Always design soft delete with relationships in mind
  • Choose soft delete or hard delete based on business needs
This article is sponsored by ZZZ Projects.

Thousands of developers fixed EF Core performance β€” with one library: Entity Framework Extensions.

πŸ‘‰ Insert data 14x faster with Bulk Insert

Found this article useful? Share it with your network and spark a conversation.