Soft Delete in .NET with EF Core: How to Safely Remove Data Using Query Filters and Client Cascade
Learn how to implement a safe and efficient soft delete pattern in .NET using Entity Framework Core. Discover why it matters, how to use global query filters and the ClientCascade feature, explore the pros and cons, and see how to stay compliant with user privacy laws like GDPR.
Data deletion is a deceptively complex problem in modern software systems.
Pressing delete in your application might seem harmless, but in production, it can mean permanent data loss, broken relationships, and compliance nightmares.
That’s where soft delete comes in — a development pattern that marks data as deleted instead of removing it entirely. It lets you safely hide, recover, or audit records without losing critical information.
In this article, we’ll explore:
- Why soft delete is important in real-world .NET applications
- How to implement it in Entity Framework Core using query filters and client-side cascade deletion
- Pros and cons of soft delete versus hard delete
- The privacy challenge it introduces under regulations like GDPR and how to handle user deletion requests correctly
By the end, you’ll understand not just how to implement soft delete in .NET, but also when to use it — and how to stay both safe and compliant.
Why Soft Delete Is Important
In any system with users, orders, or transactions, deleting records outright can be risky. Accidental deletions, debugging needs, or audit trails often require access to “deleted” data.
Soft delete solves this by introducing a simple boolean flag (IsDeleted) or timestamp (DeletedAt) that hides data from queries instead of removing it.
Key benefits include:
- Easy recovery from accidental deletions
- Full audit and compliance traceability
- Preserved referential integrity between related entities
- Safe testing and debugging environments
But it’s not just about convenience — it’s about data safety and trust.
How to Implement Soft Delete in EF Core
Entity Framework Core (EF Core) makes implementing soft delete straightforward with global query filters and client cascade relationships.
- Add a soft delete flag to your entities (
IsDeleted,DeletedAt). - Apply a global filter with
HasQueryFilter()to exclude soft-deleted rows from all queries. - Configure relationships with
.OnDelete(DeleteBehavior.ClientCascade)so related entities are also marked as deleted automatically. - Override
SaveChanges()to intercept EF’sEntityState.Deletedentries and turn them into soft deletes instead of physical deletes.
With this pattern, calling _db.Remove(entity) doesn’t issue a DELETE SQL statement.
Instead, EF Core updates the entity and its dependents to set IsDeleted = true — safe, reversible, and consistent.
Define the Soft Deletable Interface and Base Class
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
}
public abstract class SoftDeletableEntity : ISoftDeletable
{
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}This base abstraction makes it easy to apply the same logic across all your entities.
Create Your Entities
public class Customer : SoftDeletableEntity
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public List<Order> Orders { get; set; } = new();
}
public class Order : SoftDeletableEntity
{
public int Id { get; set; }
public string Description { get; set; } = default!;
public int CustomerId { get; set; }
public Customer Customer { get; set; } = default!;
}Both entities inherit from the soft delete base class, meaning they’ll automatically support the IsDeleted flag and deletion timestamp.
Configure EF Core Model and Query Filters
public class AppDbContext : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply soft delete global filters
modelBuilder.Entity<Customer>().HasQueryFilter(c => !c.IsDeleted);
modelBuilder.Entity<Order>().HasQueryFilter(o => !o.IsDeleted);
// Relationship: Client-side cascade for soft delete propagation
modelBuilder.Entity<Order>()
.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.OnDelete(DeleteBehavior.ClientCascade);
}
// Intercept SaveChanges to convert deletes into soft deletes
public override int SaveChanges()
{
ConvertDeletesToSoftDeletes();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ConvertDeletesToSoftDeletes();
return base.SaveChangesAsync(cancellationToken);
}
private void ConvertDeletesToSoftDeletes()
{
// EF Core automatically marks related entities as Deleted due to ClientCascade
var deletedEntries = ChangeTracker.Entries()
.Where(e => e.State == EntityState.Deleted && e.Entity is ISoftDeletable)
.ToList();
foreach (var entry in deletedEntries)
{
var entity = (ISoftDeletable)entry.Entity;
entity.IsDeleted = true;
entity.DeletedAt = DateTime.UtcNow;
// Prevent EF from issuing a physical DELETE
entry.State = EntityState.Modified;
}
}
}Key points:
- The global query filters ensure deleted rows are hidden from normal queries.
- The
ClientCascadeensures related entities are also marked as deleted in the change tracker. - The
ConvertDeletesToSoftDeletes()method intercepts before EF runs SQL, converting allDeletedentities into updates instead.
Usage Example
public class CustomerService
{
private readonly AppDbContext _db;
public CustomerService(AppDbContext db)
{
_db = db;
}
public async Task DeleteCustomerAsync(int customerId)
{
var customer = await _db.Customers
.Include(c => c.Orders)
.FirstOrDefaultAsync(c => c.Id == customerId);
if (customer == null)
throw new InvalidOperationException("Customer not found");
_db.Customers.Remove(customer);
await _db.SaveChangesAsync();
}
public async Task<List<Customer>> GetActiveCustomersAsync()
{
// Soft-deleted entities are automatically excluded
return await _db.Customers.Include(c => c.Orders).ToListAsync();
}
public async Task<List<Customer>> GetAllCustomersIncludingDeletedAsync()
{
// Use IgnoreQueryFilters to view all entities, even deleted ones
return await _db.Customers
.IgnoreQueryFilters()
.Include(c => c.Orders)
.ToListAsync();
}
}When you call:
await customerService.DeleteCustomerAsync(1);EF Core:
- Marks the
Customerand relatedOrdersas deleted in the ChangeTracker (due toClientCascade). - The
ConvertDeletesToSoftDeletes()method intercepts these changes. - EF executes UPDATE statements, not DELETEs:
UPDATE Customers SET IsDeleted = 1, DeletedAt = '2025-10-23' WHERE Id = 1;
UPDATE Orders SET IsDeleted = 1, DeletedAt = '2025-10-23' WHERE CustomerId = 1;All without manually traversing relationships or risking physical data loss.
Pros and Cons of Soft Delete
| Pros | Cons |
|---|---|
| ✅ Prevents accidental data loss | ⚠️ Data still exists (can be privacy-sensitive) |
| ✅ Enables recovery and undo | ⚠️ Requires custom handling for unique constraints |
| ✅ Maintains relational integrity | ⚠️ Can bloat database size over time |
| ✅ Improves auditability | ⚠️ Adds logic complexity for queries and updates |
The biggest downside? Privacy compliance — which brings us to the tricky part.
The Privacy Challenge: Handling User Deletion Requests with Anonymization
Soft delete ensures you don’t lose data accidentally — but for privacy compliance (GDPR, CCPA, etc.), you need to ensure personally identifiable information (PII) can’t be reconstructed once a user asks to be forgotten.
Instead of deleting rows entirely, you can anonymize user-related fields.
This way:
- You preserve relationships and historical data (for audits or reports)
- You remove personal identifiers
- You satisfy privacy requirements
Updated User Entity for Anonymization
Here’s an example of a user entity that supports both soft delete and anonymization:
public class User : SoftDeletableEntity
{
public int Id { get; set; }
public string Email { get; set; } = default!;
public string FullName { get; set; } = default!;
public bool IsAnonymized { get; set; }
}Anonymization Service
Instead of deleting, we update sensitive fields with anonymized values. This preserves the row but renders it untraceable to the original user.
public class UserService
{
private readonly AppDbContext _db;
public UserService(AppDbContext db)
{
_db = db;
}
public async Task SoftDeleteUserAsync(int userId)
{
var user = await _db.Users.FindAsync(userId);
if (user == null) return;
user.IsDeleted = true;
user.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
public async Task AnonymizeUserAsync(int userId)
{
var user = await _db.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null || user.IsAnonymized)
return;
user.IsDeleted = true;
user.IsAnonymized = true;
user.DeletedAt = DateTime.UtcNow;
// Anonymize identifiable information
user.FullName = "Deleted User";
user.Email = $"deleted-{Guid.NewGuid()}@example.com";
await _db.SaveChangesAsync();
}
}What happens here:
- The record remains in the database (maintains foreign keys, reports, logs).
- Personal data is scrubbed.
- The record is clearly flagged as deleted and anonymized.
Optional: Automate Anonymization for Deletion Requests
If your application needs to delay anonymization (e.g., allow a “grace period” for account recovery), you can extend your model:
public class User : SoftDeletableEntity
{
public bool PendingAnonymization { get; set; }
public bool IsAnonymized { get; set; }
public string Email { get; set; } = default!;
public string FullName { get; set; } = default!;
}Then schedule anonymization through a background job (e.g., Hangfire, Quartz.NET):
public class AnonymizationJob : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public AnonymizationJob(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff = DateTime.UtcNow.AddDays(-30); // 30-day retention
var users = db.Users
.IgnoreQueryFilters()
.Where(u => u.PendingAnonymization && !u.IsAnonymized && u.DeletedAt < cutoff);
await foreach (var user in users.AsAsyncEnumerable())
{
user.IsAnonymized = true;
user.Email = $"deleted-{Guid.NewGuid()}@example.com";
user.FullName = "Deleted User";
}
await db.SaveChangesAsync();
await Task.Delay(TimeSpan.FromHours(12), stoppingToken);
}
}
}Why Anonymization Is Better Than Hard Delete
| Aspect | Hard Delete | Anonymization |
|---|---|---|
| Data loss | Permanent | Reversible for non-sensitive data |
| Referential integrity | Can break foreign keys | Preserved |
| Audit trails | Destroyed | Maintained |
| Privacy compliance | ✅ (if total removal) | ✅ (if properly scrubbed) |
| Business continuity | ⚠️ Risky | ✅ Safe and compliant |
In most enterprise or SaaS systems, anonymization is preferred because you often need to:
- Keep invoices, logs, or analytics records
- Retain relational data for business operations
- Ensure that data cannot identify the original user
Anonymization gives you privacy without losing data integrity.
Conclusion
Soft delete with EF Core gives you a safety net against accidental data loss — but it’s not the full story.
To stay compliant with privacy regulations, you must ensure that user data can’t be reconstructed after a deletion request.
That’s where anonymization shines:
it lets you keep business-critical and relational data intact while ensuring no personally identifiable information remains in your database.
In summary:
- Use soft delete for reversible, application-level deletes
- Use ClientCascade for automatic relationship propagation
- Use anonymization for privacy-compliant user deletion
Together, they form a robust, compliant, and developer-friendly data deletion strategy that protects both your system’s integrity and your users’ privacy.