One pattern that shows up almost everywhere in real systems is soft delete.
At first it looks trivial. Just add a column called IsDeleted and set it to true when someone deletes a record.
But after working on several production systems such as payroll systems, payment platforms, internal admin portals, I learned that soft delete is not just a database column. It affects the entire data access layer, especially when using Entity Framework Core.
In this article I want to walk through how I implement soft delete properly using EF Core Query Filters. The examples target .NET 9 and EF Core 9+, but the concept works in earlier versions too.
This is not a theoretical explanation. These are patterns I used in real production systems and also some mistakes I made before fixing the design.
The Real Problem Soft Delete Solves
Let’s start with a simple example.
You have a table:
Employees
---------
Id
Name
DepartmentA user deletes an employee record. If we perform a hard delete, the row is permanently removed. In many business systems this is not acceptable.
Examples where I saw this become a problem:
- HR systems where terminated employees must remain in records.
- Financial systems where audit requires historical data.
- Inventory systems where deleted products still appear in old orders.
- Payment systems where transaction references must never disappear.
Once the row is deleted, there is no easy way to recover it. This is why many systems implement soft delete instead.
Basic Soft Delete Concept
Instead of deleting the row, we mark it as deleted.
Example table:
Employees
---------
Id
Name
IsDeletedWhen deleting:
UPDATE Employees
SET IsDeleted = 1
WHERE Id = 100;The row still exists, but the application should treat it as removed.
In EF Core the entity may look like this:
public class Employee
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public bool IsDeleted { get; set; }
}Now the application must filter queries:
var employees = await _context.Employees
.Where(e => !e.IsDeleted)
.ToListAsync();This works.
But it introduces a serious problem. Someone will forget the filter. I have seen this happen many times.
One developer writes:
_context.Employees.ToListAsync();Suddenly deleted records appear again.
The fix for this is Global Query Filters.
Global Query Filters in EF Core
EF Core allows applying a filter automatically to every query for an entity. This is done using HasQueryFilter.
Example DbContext:
public class AppDbContext : DbContext
{
public DbSet<Employee> Employees => Set<Employee>();
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Employee>()
.HasQueryFilter(e => !e.IsDeleted);
}
}Now every query automatically includes the filter.
Example query:
var employees = await _context.Employees.ToListAsync();EF Core generates SQL like this:
SELECT [e].[Id], [e].[Name], [e].[IsDeleted]
FROM [Employees] AS [e]
WHERE [e].[IsDeleted] = 0This is the first step toward a safe soft delete implementation.
A reusable BaseEntity Approach
In most systems many entities need soft delete.
Instead of repeating the same properties everywhere, I usually create a base entity.
public abstract class BaseEntity
{
public int Id { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
}Then domain entities inherit from it.
public class Employee : BaseEntity
{
public string Name { get; set; } = default!;
}This allows us to standardize soft delete behavior across the system. The additional columns (DeletedAt, DeletedBy) are useful for auditing.
Intercepting Deletes with SaveChanges
Another issue I experienced earlier was this:
A developer still used:
_context.Employees.Remove(employee);EF Core would generate:
DELETE FROM Employees WHERE Id = @p0That completely bypasses the soft delete logic. The solution is to intercept deletes in SaveChanges()
Example implementation:
public override int SaveChanges()
{
ApplySoftDelete();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
ApplySoftDelete();
return base.SaveChangesAsync(cancellationToken);
}
private void ApplySoftDelete()
{
var entries = ChangeTracker
.Entries<BaseEntity>()
.Where(e => e.State == EntityState.Deleted);
foreach (var entry in entries)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
entry.Entity.DeletedBy = "system"; // normally current user
}
}Now even if someone calls Remove(), EF Core converts it to an update.
Generated SQL:
UPDATE Employees
SET IsDeleted = 1,
DeletedAt = GETUTCDATE()
WHERE Id = @p0This makes the soft delete mechanism much harder to bypass.
Ignoring Query Filters (Admin Scenarios)
Sometimes administrators need to view deleted records. EF Core allows bypassing query filters using IgnoreQueryFilters().
Example:
var allEmployees = await _context.Employees
.IgnoreQueryFilters()
.ToListAsync();This is useful for:
- admin dashboards
- restore operations
- audit investigation
Example restore logic:
var employee = await _context.Employees
.IgnoreQueryFilters()
.FirstOrDefaultAsync(e => e.Id == id);
if (employee != null)
{
employee.IsDeleted = false;
employee.DeletedAt = null;
employee.DeletedBy = null;
await _context.SaveChangesAsync();
}Multi-Tenant + Soft Delete
In multi-tenant systems the filter usually combines TenantId and IsDeleted
Example base entity:
public abstract class BaseEntity
{
public int Id { get; set; }
public Guid TenantId { get; set; }
public bool IsDeleted { get; set; }
}Then the query filter becomes:
modelBuilder.Entity<Employee>()
.HasQueryFilter(e =>
!e.IsDeleted &&
e.TenantId == _currentTenantId);Generated SQL looks like:
SELECT *
FROM Employees
WHERE IsDeleted = 0
AND TenantId = @tenantIdThis ensures:
- deleted records are hidden
- tenants cannot access each other’s data
Performance Considerations
One thing I learned later was that soft delete can impact performance. If a table contains millions of rows, filtering IsDeleted in every query can become expensive.
Proper indexing is important.
Example index:
CREATE INDEX IX_Employees_IsDeleted
ON Employees(IsDeleted);In multi-tenant systems I usually create a composite index:
CREATE INDEX IX_Employees_TenantId_IsDeleted
ON Employees(TenantId, IsDeleted);For read-heavy systems a filtered index can also help:
CREATE INDEX IX_Employees_Active
ON Employees(TenantId)
WHERE IsDeleted = 0;This reduces scanning of deleted rows.
Handling Related Entities
One tricky situations happens with relationships.
Example:
Department
EmployeesIf you soft delete a department, what should happen to employees?
Possible strategies:
- Prevent deletion if children exist
- Cascade soft delete
- Leave children untouched
EF Core does not cascade soft delete automatically. In some systems I implemented cascade manually inside SaveChanges(). But in most business systems we prevent deletion if dependent records exist.
Soft Delete vs Temporal Tables
SQL Server also offer temporal tables. Temporal tables automatically keep historical versions of rows.
Comparison:
Soft Delete
- Implemented in application
- Easy to restore deleted data
- Simple queries
- Works with any database provider
Temporal Tables
- Implemented in database
- Full history of changes
- More complex queries
- SQL Server specific
In some high audit environments I actually combine both. Soft delete hides records from normal queries, while temporal tables preserve the full change history.
Shadow Property Alternative
EF Core also supports shadow properties. This means the entity does not contain the IsDeleted property.
Example:
modelBuilder.Entity<Employee>()
.Property<bool>("IsDeleted");Query filter:
modelBuilder.Entity<Employee>()
.HasQueryFilter(e =>
EF.Property<bool>(e, "IsDeleted") == false);Personally I do not prefer this approach. It hides an important domain behavior from the entity model, which can confuse developers. For most systems I still prefer explicit properties.
Migration Strategy for Existing Tables
If the system already has production data, soft delete must be introduced carefully.
Typical steps:
- Add Is Deleted column with default value.
- Add DeletedAt and DeletedBy.
- Create indexes.
- Deploy application update with query filters
- Monitor queries and reports.
Rolling this out gradually avoids unexpected behavior.
Mistakes I Made Before
Looking back at older systems I worked on, I made several mistakes:
- Forgot to index IsDeleted
- Forgot to override SaveChanges
- Used IgnoreQueryFilters() in repository accidentally
- Soft deleted parent entities without considering child records
These issues usually appear only in production after the data grows.
Final Thoughts
Soft delete is easy to implement poorly. But when implemented correctly with EF Core Query Filters, it becomes a very clean and reliable pattern.
My typical setup today includes:
- BaseEntity with soft delete fields
- Global query filters
- SaveChanges() interception
- Proper indexing
- Audit columns (DeletedAt, DeletedBy)
Once this foundation is in place, the rest of the application becomes much safer. Developers can write normal queries without constantly remembering to filter deleted records.
And from my experience working on enterprise systems, that small architecture decision saves a log of headaches later.






