
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
- SQL Server – supports filtered indexes
- PostgreSQL – supports partial indexes
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.
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
IsDeletedandDeletedOnUtcto 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.

