When a user deletes a record from an application, permanently removing it from the database is not always the best choice. A customer might delete an order by mistake, an administrator might need to review an old record, or a business may need to retain information for reporting.
Entity Framework Core supports a practical approach called soft delete. Instead of removing a database row, the application marks it as deleted. Normal queries then hide that record, while authorized operations can still retrieve it when needed.
Soft deletes are useful in applications that manage customers, orders, invoices, products, and other business records. However, they need to be implemented carefully. A deleted record should not accidentally appear in normal queries, and users should not be able to restore or modify records they do not own.
In this article, we will implement soft deletes using Entity Framework Core, global query filters, and a centralized SaveChanges override. We will also cover restoring records, common mistakes, and production considerations.
What Is a Soft Delete?
A soft delete marks a record as deleted without physically removing its database row.
For example, a products table might contain these records:
| Id | Name | IsDeleted |
|---|
| 1 | Keyboard | false |
| 2 | Mouse | true |
| 3 | Monitor | false |
The mouse record still exists in the database, but normal application queries can exclude it.
A common implementation uses an IsDeleted property. Another option is to store a DeletedAt timestamp, which records when the deletion occurred. Some applications use both fields.
Soft deletes are different from database backups. A soft-deleted record remains in the current database, while a backup is a separate recovery mechanism.
Step 1: Add Soft-Delete Properties to the Entity
Start by creating a shared interface for entities that support soft deletes.
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
}
The interface defines two properties. IsDeleted indicates whether the record is deleted, and DeletedAt stores the time when the deletion occurred.
Next, implement the interface in your entity.
public sealed class Product : ISoftDeletable
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
The nullable timestamp is useful because an active record has no deletion time.
If you use SQL Server, Entity Framework Core can map these properties to database columns through its normal conventions. If the table already exists, create and apply a migration to add the new columns.
For an existing application, check how the new columns will be populated for current records. A default value of false for IsDeleted is generally appropriate for records that have not previously been soft-deleted.
Step 2: Configure a Global Query Filter
Adding an IsDeleted property does not automatically hide deleted records. Without additional configuration, ordinary queries can still return them.
Entity Framework Core global query filters solve this problem by applying a condition to queries for a particular entity.
Create a DbContext and configure the filter.
using Microsoft.EntityFrameworkCore;
public sealed class AppDbContext : DbContext
{
public AppDbContext(
DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.HasQueryFilter(product => !product.IsDeleted);
}
}
The filter tells EF Core to include only products whose IsDeleted property is false in normal queries.
For example:
var products = await dbContext.Products
.AsNoTracking()
.ToListAsync();
This query excludes soft-deleted products automatically.
You do not need to add the same condition to every ordinary query. However, the filter is an application-level query behavior, not a complete security boundary. Raw SQL, explicitly bypassed filters, and other data access paths still require careful review.
Step 3: Convert Delete Operations into Soft Deletes
The next step is to prevent normal EF Core deletion operations from physically removing records.
Without additional logic, calling Remove marks an entity for deletion, and SaveChanges normally generates a database DELETE statement.
You can override SaveChanges to convert deletions of soft-deletable entities into updates.
Add the following methods to AppDbContext:
public override int SaveChanges()
{
ApplySoftDeletes();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
ApplySoftDeletes();
return base.SaveChangesAsync(cancellationToken);
}
private void ApplySoftDeletes()
{
var deletedEntries = ChangeTracker
.Entries<ISoftDeletable>()
.Where(entry =>
entry.State == EntityState.Deleted);
foreach (var entry in deletedEntries)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTimeOffset.UtcNow;
}
}
Add these methods inside the AppDbContext class from the previous step.
When an application calls Remove on a tracked soft-deletable entity, the override changes its state from Deleted to Modified. EF Core then updates the deletion properties instead of physically deleting the row.
The UTC timestamp provides a consistent reference across servers and environments.
This implementation covers the standard SaveChanges and SaveChangesAsync paths. If your application also overrides other save methods or uses bulk operations that bypass the change tracker, make sure those paths enforce the same policy.
Step 4: Use the Soft Delete in an Application Service
Once the context is configured, a service can use the normal Remove method.
For example:
public sealed class ProductService
{
private readonly AppDbContext _dbContext;
public ProductService(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<bool> DeleteProductAsync(
int productId,
CancellationToken cancellationToken)
{
var product = await _dbContext.Products
.SingleOrDefaultAsync(
item => item.Id == productId,
cancellationToken);
if (product is null)
{
return false;
}
_dbContext.Products.Remove(product);
await _dbContext.SaveChangesAsync(
cancellationToken);
return true;
}
}
The query retrieves only active products because the global filter excludes deleted records.
The Remove call marks the product for deletion in the change tracker. The SaveChangesAsync override converts that operation into a soft delete.
If the product has already been deleted, the query normally returns no result. The service therefore avoids processing the same active record again.
For production applications, enforce the appropriate authorization rules before allowing a user to delete a product. The example demonstrates persistence behavior, not a complete authorization implementation.
Step 5: Retrieve Deleted Records When Needed
Sometimes an administrator needs to inspect deleted records or restore an item that was removed by mistake.
Entity Framework Core provides IgnoreQueryFilters for queries that need to bypass global filters.
For example:
var deletedProducts = await dbContext.Products
.IgnoreQueryFilters()
.Where(product => product.IsDeleted)
.AsNoTracking()
.ToListAsync();
This query includes soft-deleted products and then selects only those marked as deleted.
Use this operation carefully. IgnoreQueryFilters bypasses configured filters, which may include more than the soft-delete condition.
If your application uses tenant filters, a query that ignores all filters may expose records belonging to other tenants unless additional restrictions are applied explicitly.
Deleted-record queries should therefore be limited to authorized operations and should preserve tenant and access-control requirements.
Step 6: Restore a Soft-Deleted Record
Restoring a record means changing its deletion state back to active.
The query must first retrieve the deleted record. The following example demonstrates the basic operation:
public async Task<bool> RestoreProductAsync(
int productId,
CancellationToken cancellationToken)
{
var product = await _dbContext.Products
.IgnoreQueryFilters()
.SingleOrDefaultAsync(
item => item.Id == productId &&
item.IsDeleted,
cancellationToken);
if (product is null)
{
return false;
}
product.IsDeleted = false;
product.DeletedAt = null;
await _dbContext.SaveChangesAsync(
cancellationToken);
return true;
}
This method resets the deletion flag and clears the timestamp.
Before using this pattern, ensure that the operation is restricted to authorized users. If the application uses multiple query filters, explicitly preserve the tenant and authorization conditions rather than relying on the soft-delete condition alone.
Also consider what should happen if the restored record conflicts with an active record. For example, restoring a product with a unique code may fail if another active product now uses that code.
Step 7: Create and Apply the Database Migration
If the table already exists, Entity Framework Core needs a migration to add the soft-delete columns.
Create a migration:
dotnet ef migrations add AddProductSoftDelete
Review the generated migration before applying it. Confirm that it adds the expected columns and does not make unrelated schema changes.
Apply the migration to the development database:
dotnet ef database update
Test the migration against a representative copy of your database before applying it in production.
For production deployments, use your normal database migration process. Depending on your environment, this may involve reviewed SQL scripts or a dedicated deployment step rather than running migrations automatically when the application starts.
Common Mistakes to Avoid
One common mistake is adding IsDeleted without configuring a query filter. The property alone does not prevent deleted records from appearing in query results.
Another is assuming that every delete operation passes through SaveChanges. Bulk operations and direct SQL can bypass the change tracker and physically remove rows unless explicitly handled.
Be careful with IgnoreQueryFilters because it may bypass tenant filters as well as soft-delete filters. Also, do not assume that soft deletion automatically handles related entities. Decide whether related records should remain visible, become hidden, or be soft-deleted according to your business rules.
Finally, soft deletion does not replace backups, retention policies, or permanent data removal requirements.
Best Practices for Production Applications
Use a shared interface or base type for entities that support soft deletes.
Apply query filters consistently to all applicable entity types.
Keep deletion timestamps in UTC.
Restrict restore and deleted-record queries to authorized users.
Review bulk updates, raw SQL, and background jobs for filter bypasses.
Test entity relationships and unique constraints during deletion and restoration.
Add audit information when the application needs to record who performed an operation.
Define when soft-deleted data should be permanently removed.
Test migrations and recovery procedures before production deployment.
Advantages and Disadvantages
Soft deletes make it easier to recover records removed by mistake, retain historical information, and preserve references used by other application records.
However, deleted rows continue to occupy database storage. Queries that bypass filters can expose records that users should not see, and unique constraints or relationships may need additional design work.
Soft deletes are useful when recovery or historical retention is required, but they should be combined with a clear retention policy and appropriate access controls.
Summary
Soft deletes in Entity Framework Core allow an application to mark records as deleted without immediately removing them from the database.
By combining an IsDeleted property, a global query filter, and a SaveChanges override, you can hide deleted records from normal queries and convert standard Remove operations into updates.
You can also retrieve and restore deleted records when needed, provided those operations enforce the right permissions and tenant restrictions.
The important part is to treat soft deletion as a data lifecycle feature, not just a Boolean property. Review every data access path, test restoration behavior, and define when records should eventually be removed permanently.
Join the conversation! Your thoughts help the community grow.