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.

Soft Delete in .NET with EF Core: How to Safely Remove Data Using Query Filters and Client Cascade
Photo by Eduardo Casajús Gorostiaga / Unsplash

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.

  1. Add a soft delete flag to your entities (IsDeleted, DeletedAt).
  2. Apply a global filter with HasQueryFilter() to exclude soft-deleted rows from all queries.
  3. Configure relationships with .OnDelete(DeleteBehavior.ClientCascade) so related entities are also marked as deleted automatically.
  4. Override SaveChanges() to intercept EF’s EntityState.Deleted entries 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 ClientCascade ensures related entities are also marked as deleted in the change tracker.
  • The ConvertDeletesToSoftDeletes() method intercepts before EF runs SQL, converting all Deleted entities 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:

  1. Marks the Customer and related Orders as deleted in the ChangeTracker (due to ClientCascade).
  2. The ConvertDeletesToSoftDeletes() method intercepts these changes.
  3. 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

ProsCons
✅ 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

AspectHard DeleteAnonymization
Data lossPermanentReversible for non-sensitive data
Referential integrityCan break foreign keysPreserved
Audit trailsDestroyedMaintained
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.