Multi-Tenant Applications With EF Core: A Beginner's Guide
Learn multi-tenancy in EF Core the simple way. Isolate tenant data with global query filters, ITenantService, and named filters in .NET 10, with diagrams and code.
One building, many families
Picture a big apartment building in your city. There is one building, with one front gate, one water tank, and one lift. But inside live many different families. Each family has their own flat with their own lock and key. The Sharma family on the third floor can never walk into the Khan family's flat on the fifth floor. They share the building, but their homes are private.
A multi-tenant application works exactly like that building. It is one program, running on one set of servers, often using one database. But it serves many different customers — we call each customer a tenant. Each tenant's data must stay locked away from every other tenant. Customer A must never, ever see Customer B's invoices, users, or orders.
The hard part is the locks. If you forget to lock even one door, a family could wander into the wrong flat. In software, forgetting one filter means one customer could see another customer's private data. That is a serious bug. The good news: EF Core gives us a way to lock every door automatically, so we cannot forget. Let us learn how.
What "tenant" means in real apps
Most software you use every day is multi-tenant. Your school's online portal serves many schools from one website. A billing app serves thousands of small businesses. Each school, each business, is a tenant.
One app, many tenants
Steps
Shared App
One codebase for everyone
Tenant Router
Finds who is asking
Tenant A Data
Only A's rows
Tenant B Data
Only B's rows
Tenant C Data
Only C's rows
The job of multi-tenancy code is simple to say but important to get right: when Tenant A sends a request, only Tenant A's data comes back. Nothing else.
Three ways to separate tenant data
There are three common strategies. They trade off cost against how strongly data is separated.
| Strategy | How data is split | Isolation | Cost & effort | Good for |
|---|---|---|---|---|
Shared database, TenantId column | One database, every table has a TenantId | Logical (a filter) | Lowest cost, easiest | Many small tenants |
| Schema per tenant | One database, separate schema per tenant | Stronger | Medium | Medium count of tenants |
| Database per tenant | One database per tenant | Strongest (physical) | Highest cost & ops | Few large or strict tenants |
Most apps start with the shared database + TenantId column approach because it is cheap and simple. We will spend most of our time there, then look at database-per-tenant at the end.
The shared-database approach, step by step
In this approach, every tenant's rows live in the same tables. To tell them apart, each table carries a TenantId column. Here is what the data looks like for an Orders table:
| Id | TenantId | Customer | Total |
|---|---|---|---|
| 1 | tenant-a | Riya | 500 |
| 2 | tenant-b | Aman | 1200 |
| 3 | tenant-a | Karan | 300 |
If Tenant A asks for orders, they should only see rows 1 and 3. The rule is always the same: WHERE TenantId = 'tenant-a'. We could write that WHERE clause by hand in every query, but humans forget. EF Core can add it for us, every single time, using a global query filter.
Step 1: Mark which entities belong to a tenant
A tiny interface keeps things tidy. Any entity that holds tenant data carries a TenantId.
public interface ITenantEntity
{
string TenantId { get; set; }
}
public class Order : ITenantEntity
{
public int Id { get; set; }
public string TenantId { get; set; } = string.Empty;
public string Customer { get; set; } = string.Empty;
public decimal Total { get; set; }
}Step 2: Know who the current tenant is
EF Core needs to know which tenant is asking right now. We wrap that in a small service. In a web app, you usually read the tenant from the request — a subdomain like acme.app.com, a header, or a claim in the user's token.
public interface ITenantService
{
string TenantId { get; }
}
public class TenantService : ITenantService
{
public string TenantId { get; }
public TenantService(IHttpContextAccessor accessor)
{
// Example: read tenant id from a request header.
TenantId = accessor.HttpContext?
.Request.Headers["X-Tenant-Id"].ToString() ?? string.Empty;
}
}You register it as scoped, so each request gets its own tenant value:
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantService, TenantService>();Step 3: Add the global query filter
Now the magic. We inject ITenantService into the DbContext and add one filter that EF Core applies to every query for tenant entities.
public class AppDbContext : DbContext
{
private readonly string _tenantId;
public AppDbContext(DbContextOptions<AppDbContext> options,
ITenantService tenantService)
: base(options)
{
_tenantId = tenantService.TenantId;
}
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenantId);
}
}From now on, when you write dbContext.Orders.ToListAsync(), EF Core quietly turns it into SELECT * FROM Orders WHERE TenantId = @currentTenant. You never type that WHERE again. Even a Find, an Include, or a join gets the filter added.
Step 4: Set the tenant when saving new data
The query filter handles reads. For writes, we still need to stamp the right TenantId on new rows so they belong to the correct tenant. The cleanest way is to override SaveChanges and set it automatically.
public override async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<ITenantEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.TenantId = _tenantId;
}
}
return await base.SaveChangesAsync(cancellationToken);
}Now when you add a new Order, you do not pass a TenantId at all. EF Core stamps it for you, just before saving. This stops a sneaky bug where someone creates a row but forgets to set its tenant.
Save flow for a new row
Steps
Add Order
No TenantId set yet
SaveChanges
Interceptor runs
Stamp TenantId
Set from current tenant
Write to DB
Row safely owned
A worry: what if the filter is bypassed?
Global query filters are powerful, but you should understand their limits so you do not get a false sense of safety.
- Raw SQL bypasses filters. If you run
FromSqlRaw(...), EF Core does not add the tenant filter. You must add theWHERE TenantIdclause yourself. IgnoreQueryFilters()turns the filter off. That is sometimes useful (an admin tool), but be very careful where you use it.- The filter is only as safe as
TenantId. If yourITenantServicereads the wrong tenant (for example, trusting a header a user can fake), the filter happily returns the wrong data. Get the tenant from a trusted source, like a verified token claim.
Treat the query filter as a strong, automatic seatbelt — not as the only thing keeping you safe. Validate the tenant at the edge of your app too.
Named query filters (new in EF Core 10)
Here is a common problem. Many apps want two filters on the same entity: a soft-delete filter (IsDeleted == false) and a tenant filter (TenantId == current). Before EF Core 10, an entity could have only one query filter. Calling HasQueryFilter twice overwrote the first one. People worked around it by mashing both rules into a single expression.
EF Core 10 fixes this with named query filters. You give each filter its own name, so they live side by side.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter("Tenant", o => o.TenantId == _tenantId)
.HasQueryFilter("SoftDelete", o => !o.IsDeleted);
}Both filters apply together by default: EF Core adds WHERE TenantId = @t AND IsDeleted = 0. The real win is that you can now switch off just one filter by name, leaving the other in place:
// See soft-deleted orders for THIS tenant only.
// Tenant filter stays on; soft-delete filter is turned off.
var deleted = await dbContext.Orders
.IgnoreQueryFilters(["SoftDelete"])
.ToListAsync();This is a big deal for multi-tenant apps. An admin "recycle bin" screen can show deleted orders without accidentally leaking other tenants' data, because the tenant filter is still active. Before EF Core 10, turning off one filter meant turning off both.
| Behaviour | EF Core 9 and earlier | EF Core 10 |
|---|---|---|
| Filters per entity | One only (second overwrites first) | Many, each with a name |
| Disable one filter | Not possible — all or nothing | IgnoreQueryFilters(["Name"]) |
| Tenant + soft delete together | Hand-merge into one expression | Two clean named filters |
Database per tenant: the strongest lock
Sometimes a TenantId column is not enough. A bank, a hospital, or a strict enterprise customer may demand that their data sit in a completely separate database. That is the database-per-tenant strategy.
The schema is the same for everyone, but each tenant has their own database file or server. Switching tenants is just switching the connection string. EF Core makes this clean: you choose the connection string in OnConfiguring, based on the current tenant.
public class AppDbContext : DbContext
{
private readonly ITenantService _tenantService;
private readonly IConfiguration _config;
public AppDbContext(ITenantService tenantService, IConfiguration config)
{
_tenantService = tenantService;
_config = config;
}
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
// Look up THIS tenant's own connection string.
var name = $"Tenant_{_tenantService.TenantId}";
var connectionString = _config.GetConnectionString(name);
options.UseSqlServer(connectionString);
}
}Now no query filter is needed for isolation — the data is physically apart. Tenant A literally cannot touch Tenant B's database. The trade-off is more work: more databases to back up, migrate, and monitor.
Migrations across many databases
With database-per-tenant, remember: when you change your model, you must run migrations against every tenant database, not just one. A common pattern is a small loop at startup or in a deploy script that opens each tenant's connection string and calls Migrate(). Plan for this early; it is easy to forget and end up with one tenant on an old schema.
Picking the right strategy
There is no single "best" answer. Use this quick guide:
- Just starting, many small customers, cost matters? Shared database with a
TenantIdcolumn and a global query filter. Simple and cheap. - Need stronger separation but still one database? Schema per tenant.
- Strict compliance, a few big customers, separate backups per customer? Database per tenant.
Many real systems even mix strategies: small customers share a database, while a few large enterprise customers get their own. EF Core can support both at once because the tenant lookup is just a service you control.
Common mistakes to avoid
- Trusting a faked tenant id. Read the tenant from a verified token, not just a header any caller can set.
- Forgetting the filter on a new entity. Every new tenant table needs its filter. A small loop over all
ITenantEntitytypes inOnModelCreatingis safer than adding them by hand. - Leaking with raw SQL. Always add
WHERE TenantIdyourself inFromSqlRaw. - Caching across tenants. If you cache data, include the tenant id in the cache key, or Tenant A may get Tenant B's cached result.
- Sharing a
DbContextacross requests. Keep it scoped, so each request has the correct tenant.
Quick recap
- A multi-tenant app is one program serving many customers (tenants), and each tenant's data must stay private — like families in one apartment building.
- Three common strategies: shared database +
TenantId, schema per tenant, and database per tenant. They trade cost for isolation. - For the shared approach, a global query filter adds
WHERE TenantId = currentto every query automatically, so you never forget. - Override
SaveChangesto stamp theTenantIdon new rows automatically. - Query filters do not cover raw SQL, and they are only as safe as your tenant lookup — validate the tenant from a trusted source.
- EF Core 10 adds named query filters, so you can run a tenant filter and a soft-delete filter together and disable just one by name with
IgnoreQueryFilters(["SoftDelete"]). - For database per tenant, swap the connection string in
OnConfiguring, and remember to migrate every tenant database.
References and further reading
- Multi-tenancy — EF Core (Microsoft Learn)
- Global Query Filters — EF Core (Microsoft Learn)
- Multi-Tenant Applications With EF Core — Milan Jovanović
- Named Query Filters in EF 10 — Milan Jovanović
- Global Query Filters in EF Core (Soft Delete, Multi-Tenancy, Named Filters) — codewithmukesh
- How to Implement Multitenancy in ASP.NET Core with EF Core — antondevtips
Related Posts
Soft Delete with EF Core: Delete Data Without Losing It
Learn soft delete in EF Core the right way. Use an interceptor and global query filters to hide deleted rows automatically, with simple examples, diagrams, code, and best practices for .NET 10.
Global Query Filters in EF Core: Soft Delete and Multi-Tenancy Made Easy
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in .NET 10.
Building a Multitenant Cloud Application With Azure Functions and Neon Postgres
A beginner-friendly guide to building a multitenant cloud app with Azure Functions and Neon serverless Postgres, using a database-per-tenant design in .NET.
EF Core Migrations: A Detailed Beginner Guide for .NET
Learn EF Core migrations step by step. Add, apply, revert, and ship database changes safely with simple examples, diagrams, tables, and best practices for .NET 10.
5 Hidden EF Core NuGet Packages That Make Your .NET Code Better
Five lesser-known EF Core NuGet packages for clean exceptions, naming conventions, bulk speed, dynamic queries, and auditing — with simple examples and diagrams.
How to Use Global Query Filters in EF Core (Beginner Guide)
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in EF Core 10.