
Multi-Tenant EF Core – Database-per-Tenant vs Shared Schema Approaches
Article Sponsors
EF Core too slow? Insert data up to 14x faster and cut save time by 94%. Boost performance with extension methods fully integrated into EF Core — Bulk Insert, Update, Delete, and Merge.
Join 5,000+ developers who’ve trusted our library since 2014.
If you’re building a SaaS product on .NET, sooner or later you’ll hit the multi-tenancy question: how do you keep each customer’s data separate while running one codebase? EF Core gives you two main paths — a dedicated database per tenant, or a shared database with a tenant discriminator column. Both work. Both have trade-offs that can bite you later if you pick wrong.
This article explains why tenant isolation matters in plain terms, then walks through both approaches with real code, a comparison table, and a practical before/after scenario — so you can make the right call for your project.
Why Do We Need Tenant Isolation?
Let’s start simple. Imagine you run one app, but ten different companies use it. Company A should never be able to see Company B’s customer list, invoices, or reports — even by accident. That separation is called tenant isolation.
Without it, a single bug — a missing WHERE clause, a forgotten filter — can leak one customer’s data to another. That’s not just embarrassing. It can end contracts, trigger legal action, and destroy trust in your product.
Tenant isolation gives you three things:
- Privacy — one customer’s data stays invisible to every other customer, always
- Safety — a mistake in your code affects one tenant, not all of them
- Trust — customers can confidently sign contracts knowing their data is protected
Why This Matters Even More for Enterprise and Compliance
Enterprise customers — banks, hospitals, insurance companies, government agencies — don’t just want isolation. They’re often legally required to demand it before they’ll even sign a contract.
Here’s why compliance-heavy clients care so much:
- Regulations demand it. Laws like HIPAA (healthcare), GDPR (EU data privacy), PCI-DSS (payment data), and SOC 2 (security audits) all require proof that customer data is properly separated and protected.
- Audits check for it. Enterprise security teams will ask: “Show me exactly how Tenant A’s data is kept away from Tenant B.” If your answer is “we filter it in code,” that’s a weaker answer than “each tenant has its own database.”
- Data residency rules apply. Some countries require that certain data physically stays within their borders. A dedicated database per tenant makes this much easier to guarantee.
- “Right to be forgotten” is easier. GDPR gives users the right to have their data deleted. Deleting one tenant’s entire database is simple. Deleting scattered rows from a shared table — safely, completely, without affecting others — is much harder to prove.
- Breach impact is contained. If something goes wrong, isolated tenants limit the blast radius. A shared-schema breach can expose every customer at once.
Note: This is exactly why many SaaS companies start with a shared schema (cheaper, simpler) but offer database-per-tenant as a premium tier for enterprise clients who need it for compliance reasons.
Now that you understand why isolation matters, let’s look at how to implement it in EF Core.
What Is Multi-Tenancy in EF Core?
Multi-tenancy means one application instance serves multiple customers (“tenants”), while keeping each tenant’s data logically or physically isolated. A tenant could be a company, a department, or even an individual user account, depending on your product.
EF Core doesn’t have built-in multi-tenancy — you build it using one of two patterns:
- Database-per-tenant: each tenant gets its own database
- Shared schema: all tenants share one database, rows are tagged with a
TenantId
There’s a third option worth knowing about too — schema-per-tenant, where one database hosts a separate table schema for each tenant (for example, tenant1.Orders and tenant2.Orders). Here’s how the three compare at a glance:
| Approach | Tenant Column? | Schema per Tenant? | Multiple Databases? | EF Core Support |
|---|---|---|---|---|
| Discriminator (Shared Schema) | Yes | No | No | Built-in via global query filter |
| Database-per-Tenant | No | No | Yes | Built-in via configuration |
| Schema-per-Tenant | No | Yes | No | Not directly supported |
Note: Schema-per-tenant isn’t directly supported by EF Core’s tooling — you’d need to manually override the table schema per DbContext instance, and migrations don’t handle it cleanly. Because of this, it’s rarely used in practice. This article focuses on the two approaches EF Core supports well: database-per-tenant and shared schema.
Approach 1: Database-per-Tenant
How it works: Each tenant has a completely separate database. Your DbContext resolves the correct connection string at runtime, usually based on the logged-in user’s tenant claim or subdomain.
Tenant connection resolution:
namespace Tenancy;
public interface ITenantConnectionProvider
{
string GetConnectionString();
}
public sealed class TenantConnectionProvider(
IHttpContextAccessor httpContextAccessor,
ITenantRegistry tenantRegistry) : ITenantConnectionProvider
{
public string GetConnectionString()
{
var tenantId = httpContextAccessor.HttpContext?.User
.FindFirst("tenant_id")?.Value
?? throw new InvalidOperationException("Tenant claim not found.");
// Resolve from a cached tenant registry (e.g. backed by a small
// control-plane database), never build connection strings from
// raw user input.
return tenantRegistry.GetConnectionString(tenantId);
}
}
public interface ITenantRegistry
{
string GetConnectionString(string tenantId);
}DbContext setup:
namespace Data;
public sealed class AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantConnectionProvider tenantConnectionProvider) : DbContext(options)
{
public required DbSet<Order> Orders { get; init; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseSqlServer(tenantConnectionProvider.GetConnectionString());
}
}
}Applying migrations across all tenant databases:
namespace Data.Migrations;
public sealed class TenantMigrationRunner(
ITenantRegistry tenantRegistry,
IDbContextFactory<AppDbContext> dbContextFactory)
{
public async Task MigrateAllTenantsAsync(
IEnumerable<string> tenantIds,
CancellationToken cancellationToken = default)
{
foreach (var tenantId in tenantIds)
{
await using var context = await dbContextFactory
.CreateDbContextAsync(cancellationToken);
await context.Database.MigrateAsync(cancellationToken);
}
}
}Choosing the right DbContextFactory lifetime:
When you register IDbContextFactory for the database-per-tenant approach, the service lifetime matters more than it first appears. If a user can switch tenants without restarting their session (for example, an admin managing multiple client accounts), a Scoped factory can hold onto a stale connection string. Register it as Transient in that case, so the tenant and connection string are re-resolved every time a context is requested:
// Program.cs
builder.Services.AddDbContextFactory<AppDbContext>(
options => { /* connection string resolved per-context */ },
lifetime: ServiceLifetime.Transient);| Scenario | Single Database (Shared Schema) | Multiple Databases (Per-Tenant) |
|---|---|---|
| User stays in one tenant per session | Scoped | Scoped |
| User can switch tenants mid-session | Scoped | Transient |
Note: If you’re building on Blazor Server, be extra careful here. The default
DbContextFactoryregistration is a singleton, which means it’s shared across every connected user — fine for the factory itself, but not for a connection string that varies by tenant. Register it asScoped(orTransientif tenants can switch mid-session) so each user’s circuit gets the correct configuration.
Pros:
- Strongest data isolation — one tenant can never accidentally query another’s data
- Easier to meet strict compliance requirements (HIPAA, finance, government contracts)
- Simple to back up, restore, or delete a single tenant’s data
- Per-tenant scaling is possible (move a heavy tenant to a bigger server)
Cons:
- Migrations must run across every tenant database — this gets slow at scale
- Higher infrastructure cost (hundreds of tenants = hundreds of databases)
- Connection pooling and DbContext caching need extra care
- Cross-tenant reporting (admin dashboards) becomes harder
How it works: All tenants share one database and one set of tables. Every tenant-owned entity has a TenantId column, and EF Core’s global query filters automatically scope every query to the current tenant.
Entity setup:
namespace Domain;
public sealed class Order
{
public required Guid Id { get; init; }
public required Guid TenantId { get; init; }
public required string CustomerName { get; init; }
public required decimal Total { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}Current tenant service:
namespace Tenancy;
public interface ICurrentTenantService
{
Guid TenantId { get; }
}
public sealed class CurrentTenantService(IHttpContextAccessor httpContextAccessor)
: ICurrentTenantService
{
public Guid TenantId =>
Guid.TryParse(
httpContextAccessor.HttpContext?.User.FindFirst("tenant_id")?.Value,
out var tenantId)
? tenantId
: throw new InvalidOperationException("Tenant claim not found.");
}Applying the global query filter and auto-stamping TenantId:
namespace Data;
public sealed class AppDbContext(
DbContextOptions<AppDbContext> options,
ICurrentTenantService currentTenantService) : DbContext(options)
{
public required DbSet<Order> Orders { get; init; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(builder =>
{
builder.HasQueryFilter(o => o.TenantId == currentTenantService.TenantId);
builder.HasIndex(o => new { o.TenantId, o.CreatedAt });
});
base.OnModelCreating(modelBuilder);
}
public override int SaveChanges()
{
StampTenantId();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
StampTenantId();
return base.SaveChangesAsync(cancellationToken);
}
private void StampTenantId()
{
var newOrders = ChangeTracker.Entries<Order>()
.Where(e => e.State == EntityState.Added);
foreach (var entry in newOrders)
{
entry.Property(o => o.TenantId).CurrentValue = currentTenantService.TenantId;
}
}
}Note: Global query filters are convenient, but they’re not a security boundary by themselves. Always pair them with row-level checks in critical paths (payments, admin exports) — never rely on the filter alone for compliance-sensitive data.
Pros:
- One database, one set of migrations — much simpler to maintain
- Lower infrastructure cost, especially for many small tenants
- Cross-tenant analytics and admin reporting are straightforward
- Easier to scale horizontally (read replicas, sharding by
TenantIdlater)
Cons:
- Weaker isolation — a missed query filter can leak data across tenants
- Noisy-neighbor risk: one heavy tenant can slow down queries for everyone
- Harder to meet strict per-tenant compliance or “right to be forgotten” deletion cleanly
- Table sizes grow faster, which can affect indexing strategy over time
| Factor | Database-per-Tenant | Shared Schema |
|---|---|---|
| Data Isolation | Strongest — physically separate | Weaker — logical, filter-dependent |
| Infrastructure Cost | Higher (scales with tenant count) | Lower — one database |
| Migration Complexity | Must run per tenant database | Single migration path |
| Scalability | Per-tenant scaling possible | Shared resources, noisy-neighbor risk |
| Backup/Restore | Simple — per tenant | Complex — must filter by tenant |
| Compliance Fit | Best for strict regulatory needs | Workable, needs extra safeguards |
| Best For | Enterprise, healthcare, finance clients | High-volume SaaS with many small tenants |
Choosing the Right Approach
There’s no single “correct” answer — it depends on your product:
- Pick database-per-tenant if you have a small number of large, compliance-sensitive clients (enterprise, healthcare, government)
- Pick shared schema if you have hundreds or thousands of smaller tenants and cost efficiency matters
- Consider a hybrid model — shared schema by default, with the option to move specific tenants to dedicated databases when contracts demand it
A Note on Performance
Creating a new DbContext instance per operation — which both approaches rely on — is lightweight by design and shouldn’t be a bottleneck for most apps. If profiling shows DbContext creation is actually impacting performance at scale, look into DbContext pooling as a next step rather than avoiding the factory pattern altogether.
Summary
Tenant isolation isn’t just a technical detail — it’s a trust and compliance requirement that shapes how enterprise customers evaluate your product. Multi-tenancy in EF Core comes down to a trade-off between isolation and operational simplicity. Database-per-tenant protects you from cross-tenant data leaks at the cost of infrastructure overhead, while shared schema keeps things lean but demands discipline around query filters and tenant stamping. Most growing SaaS products start with shared schema and introduce dedicated databases only when a client’s contract or compliance need requires it.
This article is sponsored by ZZZ Projects.
Thousands of developers fixed EF Core performance — with one library: Entity Framework Extensions.

