Three months into a new multi-tenant SaaS project, we shipped a simple delete feature. Hours later, our support channels lit up. Customers were confused—they’d delete an invoice, but it would mysteriously reappear in their quarterly reports.

The bug was a classic. A single, forgotten Where clause. A reporting query, written by someone new to the domain, didn’t include our manual !IsDeleted filter. Boom. Soft-deleted records were leaking straight into the UI.

That incident taught me a hard lesson: manual soft deletes are a ticking time bomb. EF Core’s global query filters are the only way I’ll implement them now. They’re not just a convenience; they’re a safety net.

The ‘Just Add an IsDeleted Flag’ Trap

Everyone starts here. You add an IsDeleted property to your entity and tell the team, “Just remember to filter it out.” It works, for a while.

// This works... until it doesn't.
var activeUsers = context.Users
    .Where(u => !u.IsDeleted)
    .ToList();

// Oops. A developer writing a new report forgets the filter.
var userReport = context.Users
    .Include(u => u.Orders)
    .ToList(); // Deleted users are back from the dead.

In a growing codebase with multiple developers, someone will forget the Where clause. It’s inevitable. In our case, it wasn’t just a weird bug; it broke customer trust. They thought our software was unreliable.

EF Core’s Global Query Filters to the Rescue

This is where you stop relying on memory and start enforcing the rule at the DbContext level. You define the filter once in OnModelCreating, and EF Core applies it to every single LINQ query for that entity. Automatically.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<User>()
        .HasQueryFilter(u => !u.IsDeleted);
    
    modelBuilder.Entity<Order>()
        .HasQueryFilter(o => !o.IsDeleted);
}

With this in place, those previous queries now behave correctly without any changes.

// Both queries now automatically exclude deleted records. No code change needed.
var activeUsers = context.Users.ToList();
var userWithOrders = context.Users.Include(u => u.Orders).ToList();

The real magic is that it even works on navigation properties. When you load a user and Include(u => u.Orders), EF Core automatically adds the !o.IsDeleted filter to the join. Set it and forget it.

How I Set Up Soft Deletes in New Projects

Here’s the boilerplate I use to build this out in a scalable way.

Step 1: A Base Entity for Everything

First, I create a base class that all my main entities will inherit. This keeps things consistent.

public abstract class AuditableEntity
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
}

public class User : AuditableEntity
{
    public string Email { get; set; }
    public string Name { get; set; }
    public List<Order> Orders { get; set; } = new();
}

I don’t recommend this unless you really need it: Some people argue against base entities. For me, the consistency of having audit fields on every table is worth it, especially for soft deletes and tenancy.

Pro Tip: I always include a DeletedAt timestamp. When a customer asks, “What happened to invoice #123?”, being able to give them an exact deletion timestamp is a lifesaver for debugging and auditing.

Step 2: Apply the Filter Automatically

Instead of adding HasQueryFilter for every single entity, you can use a little reflection to apply it to everything that inherits from AuditableEntity.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Find all types that inherit from AuditableEntity and apply the filter
    foreach (var entityType in modelBuilder.Model.GetEntityTypes()
        .Where(e => typeof(AuditableEntity).IsAssignableFrom(e.ClrType)))
    {
        var parameter = Expression.Parameter(entityType.ClrType, "e");
        var property = Expression.Property(parameter, nameof(AuditableEntity.IsDeleted));
        var condition = Expression.Equal(property, Expression.Constant(false));
        var lambda = Expression.Lambda(condition, parameter);
        
        modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
    }
}

This is a clean, DRY (Don’t Repeat Yourself) way to handle it. Now any new entity that needs soft-delete capability just has to inherit from AuditableEntity.

Step 3: Combine with Multi-Tenancy

For multi-tenant apps, this gets even more powerful. You can chain the soft-delete filter with your tenant filter.

// In your DbContext, assuming you have a _currentTenantId field
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<User>()
        .HasQueryFilter(u => !u.IsDeleted && u.TenantId == _currentTenantId);
}

Now, you’ve got automatic data isolation and soft-delete protection in one shot. This is huge for preventing both security issues and bugs.

When You Need to See the Ghosts: Bypassing Filters

Sometimes, you need to see the “deleted” data. An admin dashboard, a data recovery tool, or an audit log are common examples. For that, EF Core gives you IgnoreQueryFilters().

public async Task<List<User>> GetAllUsersIncludingDeletedAsync()
{
    // This temporarily disables the HasQueryFilter for this query only
    return await _context.Users
        .IgnoreQueryFilters()
        .ToListAsync();
}

public async Task<User> GetJustOneDeletedUserAsync(int userId)
{
    return await _context.Users
        .IgnoreQueryFilters()
        .FirstOrDefaultAsync(u => u.Id == userId && u.IsDeleted);
}

Use this method with caution. It’s a backdoor for a reason. I typically restrict its usage to specific admin-level services and repositories.

Where This Can Bite You: The Gotchas

Global query filters are great, but they aren’t foolproof. Here are a few things that have burned me in production.

1. Index your IsDeleted column. When your tables get big, that extra WHERE IsDeleted = 0 clause can slow things down if the column isn’t indexed. Most of your data will have IsDeleted = 0, so a standard index isn’t always effective. A filtered index is usually the way to go.

-- SQL Server syntax
CREATE NONCLUSTERED INDEX IX_Users_IsDeleted_Filtered
ON Users (Id, Email) -- Include columns you query on
WHERE (IsDeleted = 0);

2. Raw SQL doesn’t care about your filters. This one is sneaky. Global query filters only apply to LINQ queries. If you drop down to raw SQL, you’re on your own.

// DANGER: This WILL return soft-deleted records.
var users = context.Users
    .FromSqlRaw("SELECT * FROM Users")
    .ToList();

// You have to add the filter manually.
var activeUsers = context.Users
    .FromSqlRaw("SELECT * FROM Users WHERE IsDeleted = 0")
    .ToList();

3. Cascading deletes need to be manual. If deleting a User should also soft-delete their Orders, you have to code that logic yourself. The query filter only prevents you from seeing deleted data; it doesn’t change your C# write logic.

public async Task SoftDeleteUserAndTheirOrdersAsync(int userId)
{
    var user = await _context.Users
        .Include(u => u.Orders)
        .FirstOrDefaultAsync(u => u.Id == userId);
        
    if (user != null)
    {
        user.IsDeleted = true;
        user.DeletedAt = DateTime.UtcNow;
        
        foreach (var order in user.Orders)
        {
            order.IsDeleted = true;
            order.DeletedAt = DateTime.UtcNow;
        }
        
        await _context.SaveChangesAsync();
    }
}

So, When Should You Use It?

Here’s my final take.

Use global query filters when:

  • You are implementing soft deletes, multi-tenancy, or any other cross-cutting data rule.
  • You have more than one developer working on the codebase.
  • Data integrity is critical, and you can’t afford to accidentally leak filtered-out data.

Avoid it or be careful when:

  • Your application logic frequently needs to interact with “deleted” data. Constantly using IgnoreQueryFilters() can be a code smell.
  • You have extremely complex, performance-sensitive queries. While usually fine, the automatically applied WHERE clause can sometimes lead to suboptimal query plans. Always profile.
  • You rely heavily on raw SQL queries, as the filters won’t apply.

For me, the safety and consistency they provide are non-negotiable for any application that uses soft deletes. They turn a common source of bugs into a solved problem, letting the team focus on building features instead of hunting down forgotten Where clauses.

References

FAQ

What is a soft delete in EF Core?

A soft delete in EF Core marks a record as deleted (usually with an IsDeleted flag) instead of physically removing it from the database. This allows the data to be retained for auditing, recovery, or compliance purposes.

Why should I use global query filters for soft deletes in EF Core?

Global query filters automatically apply your soft delete condition to all queries, reducing the risk of accidentally returning deleted records. They simplify code maintenance, especially in large applications or multi-developer teams.

Can I bypass EF Core global query filters?

Yes, you can use IgnoreQueryFilters() in EF Core to bypass global filters when necessary, such as for admin reporting, data recovery, or system audits. This should be done carefully to avoid exposing sensitive or deleted data.

Do global query filters impact performance?

On large datasets, global query filters can add overhead to queries. You can improve performance by indexing the IsDeleted column, using partial indexes, or optimizing queries with projection instead of full entity loading.

Do raw SQL queries respect EF Core global query filters?

No. Raw SQL queries in EF Core bypass global query filters completely, so you must manually include the IsDeleted condition in your SQL statements.

How do I cascade soft deletes in EF Core?

To cascade soft deletes, you must explicitly mark related entities as deleted in your code, since EF Core does not automatically cascade soft deletes like it does with hard deletes.

About the Author

@CodeCrafter is a software engineer who builds real-world systems , from resilient ASP.NET Core backends to clean, maintainable Angular frontend. With 11+ years in production development, he shares what actually works when shipping software that has to last.