Multi-tenancy sounds simple when we first describe it.
We have one application serving multiple customers, organizations, merchants, or companies. Each customer is a tenant, and every tenant should only see its own data.
The difficult part starts when we need to decide how that separation should happen in the database.
Should every tenant have its own database schema?
Or should all tenants use the same tables, with a TenantId column separating their records?
Both approaches work. I have also seen architecture discussions where one approach was immediately considered more “enterprise” than the other. In practice, the better choice depends much more on tenant count, isolation requirements, deployment strategy, reporting needs, and how the application is expected to grow.
In this article, I want to compare two common approaches when building multi-tenant applications with Entity Framework Core:
- Shared schema with a TenantId
- Schema per tenant
We will look at how each design works, how it affects EF Core, migrations, performance, and operations, and where problems normally appear.
The code samples shown here are simplified examples created only for demonstration purposes and are not copied from any actual production project.
First, What Does Multi-Tenancy Actually Mean?
A multi-tenant application serves multiple independent customers using the same application.
Imagine we are building a SaaS platform used by different companies.
We might have:
Company A
Company B
Company CEach company has users, orders, invoices, products, and other business data.
The application may be shared:
┌─────────────────┐
Company A ─────────▶│ │
Company B ─────────▶│ Application │
Company C ─────────▶│ │
└────────┬────────┘
│
▼
Database
The important requirement is that Company A must never accidentally receive Company B’s data.
This sounds obvious, but this requirement influences almost every layer of the application.
Tenant information affects authentication, authorization, caching, background jobs, database queries, logging, file storage, migrations, reporting, and sometimes even deployment.
The database architecture is therefore not only a database decision. It becomes part of the security model of the application.
Takeaway: Multi-tenancy is not simply adding TenantId to a table. Tenant isolation becomes an application-wide concern.
Option 1: Shared Schema with TenantId
The shared-schema approach is probably the easiest architecture to understand.
All tenants use the same database tables.
For example:
Orders
-------------------------------------------------
Id TenantId OrderNumber Amount
-------------------------------------------------
1 TENANT-A ORD-001 500
2 TENANT-B ORD-001 750
3 TENANT-A ORD-002 300
Company A and Company B both store their orders in Orders.
The difference is the TenantId.
Your entity might look something like this:
public class Order
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string OrderNumber { get; set; } = string.Empty;
public decimal Amount { get; set; }
}The database structure remains simple:
dbo.Orders
dbo.Customers
dbo.Products
dbo.InvoicesEvery tenant shares these tables.
This model is attractive because EF Core works very naturally with it.
The Dangerous Version of Shared Schema
The simplest implementation would be to manually filter every query.
var orders = await dbContext.Orders
.Where(x => x.TenantId == tenantId)
.ToListAsync();Technically, this works.
I would not want to rely on it as the primary isolation mechanism.
Imagine having hundreds of queries across commands, reports, APIs, background jobs, exports, and scheduled processes.
Eventually someone writes:
var orders = await dbContext.Orders
.ToListAsync();The query is valid.
EF Core will execute it without complaining.
The problem is that we have just queried orders belonging to every tenant.
This is one of the things I learned when working with tenant-aware systems: if tenant isolation depends on every developer remembering to add a Where clause, eventually somebody will forget.
It may not happen during development. It might happen six months later when someone quickly creates an export feature.
Tenant isolation should therefore be difficult to bypass accidentally.
Using Global Query Filters
EF Core provides global query filters, which are very useful for shared-schema multi-tenancy.
Microsoft specifically documents multi-tenancy as one of the common scenarios for global query filters. A filter attached to an entity is automatically included when that entity is queried.
We could define a simple tenant service:
public interface ITenantContext
{
Guid TenantId { get; }
}Then inject it into our DbContext.
public class ApplicationDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
public ApplicationDbContext(
DbContextOptions<ApplicationDbContext> options,
ITenantContext tenantContext)
: base(options)
{
_tenantContext = tenantContext;
}
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter(x => x.TenantId == _tenantContext.TenantId);
}
}Now this query:
var orders = await dbContext.Orders.ToListAsync();conceptually becomes:
SELECT *
FROM Orders
WHERE TenantId = @TenantId;The application code becomes cleaner because tenant filtering happens automatically.
More importantly, the default behavior becomes tenant-aware.
That is what I normally want from this architecture.
Takeaway: With a shared schema, make tenant isolation automatic. Do not depend on developers remembering tenant filters in every query.
TenantId Should Be Part of Your Data Model
One mistake is treating TenantId as something added only to major tables.
For example:
Orders
TenantId ✓
Invoices
TenantId ✓
Customers
TenantId ✓
OrderItems
TenantId ?
Sometimes developers assume that OrderItem belongs to an Order, therefore its tenant can always be discovered through the parent.
That can work, but I prefer being explicit for entities that participate directly in tenant-scoped operations.
It becomes particularly useful for direct queries, reporting, validation, database constraints, and indexing.
A shared interface can help:
public interface ITenantEntity
{
Guid TenantId { get; set; }
}Then:
public class Order : ITenantEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string OrderNumber { get; set; } = string.Empty;
public decimal Amount { get; set; }
}This also gives us a place to build conventions or validation around tenant-aware entities.
Preventing TenantId from Being Assigned Incorrectly
Filtering reads is only half of the problem.
Writes also need protection.
Consider an API request containing:
{
"tenantId": "another-tenant-id",
"orderNumber": "ORD-1001",
"amount": 500
}We should normally not trust a client-provided tenant identifier.
The tenant should come from trusted application context, usually established after authentication or another trusted tenant-resolution mechanism.
Instead of doing this:
order.TenantId = request.TenantId;we would normally do something closer to:
order.TenantId = tenantContext.TenantId;Some systems go further and enforce this centrally inside SaveChanges.
A simplified example:
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<ITenantEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.TenantId = _tenantContext.TenantId;
}
if (entry.State == EntityState.Modified)
{
entry.Property(nameof(ITenantEntity.TenantId))
.IsModified = false;
}
}
return base.SaveChangesAsync(cancellationToken);
}Production code may require additional validation, especially for administrative processes that legitimately operate across tenants.
The important idea is that tenant ownership should not casually come from the request body.
Takeaway: Tenant isolation applies to writes as much as reads. Resolve tenant ownership from trusted server-side context.
Indexing Becomes Very Important
Once most queries contain:
WHERE TenantId = ...the tenant column becomes important for indexing.
EF Core supports both normal and composite indexes. Composite indexes are particularly useful when queries frequently filter on multiple columns.
Suppose our application commonly runs:
var order = await dbContext.Orders
.SingleOrDefaultAsync(x => x.OrderNumber == orderNumber);Because of the global query filter, the effective query includes both:
TenantId
OrderNumberA composite index can make sense:
modelBuilder.Entity<Order>()
.HasIndex(x => new
{
x.TenantId,
x.OrderNumber
})
.IsUnique();This also solves another subtle business problem.
Maybe every tenant is allowed to have:
ORD-0001A unique index only on OrderNumber would incorrectly prevent two tenants from using the same number.
Instead:
TenantId + OrderNumbershould be unique.
Conceptually:
CREATE UNIQUE INDEX IX_Orders_TenantId_OrderNumber
ON Orders (TenantId, OrderNumber);This pattern appears everywhere in shared-schema applications.
For example:
TenantId + EmployeeNumber
TenantId + ProductCode
TenantId + InvoiceNumber
TenantId + ExternalReferenceI remember this being easy to miss when first designing tenant-aware entities. We tend to think about filtering first, but uniqueness rules also need tenant scope.
Takeaway: In a shared schema, review both indexes and unique constraints from the perspective of the tenant boundary.
Shared Schema Has a Big Operational Advantage
The major advantage of shared schema is not really the query filter.
It is operational simplicity.
Suppose we have 2,000 tenants.
With a shared schema:
1 Orders table
1 Customers table
1 Products tableIf we add:
Orders.DiscountAmountwe migrate the table once.
EF Core migrations are designed to incrementally keep the database schema synchronized with the application’s data model while preserving existing data.
For example:
dotnet ef migrations add AddDiscountAmountThen deployment applies that migration to the database.
The number of tenants usually does not change the number of schema migrations we execute.
This makes onboarding new tenants very easy as well.
Creating a tenant may simply mean inserting:
Tenant
Users
Configuration
SubscriptionNo new database schema needs to be provisioned.
This is one reason shared-schema architectures work well for SaaS systems expecting many relatively similar tenants.
But Shared Schema Has a Bigger Blast Radius
The same feature that makes shared schema simple also creates its main risk.
Everybody is sharing the same tables.
If an application bug bypasses tenant filtering, data belonging to another tenant may become visible.
Global query filters reduce this risk, but they are not magic.
For example, EF Core allows query filters to be disabled with IgnoreQueryFilters().
var orders = await dbContext.Orders
.IgnoreQueryFilters()
.ToListAsync();There are legitimate reasons for this.
An administrator might need a cross-tenant report.
A background process may process all tenants.
But every use of IgnoreQueryFilters() deserves attention during code review.
I usually treat code like this as security-sensitive rather than as a normal LINQ query.
Raw SQL, bulk operations, reporting systems, and direct database integrations also need the same consideration.
Takeaway: Shared schema gives excellent operational simplicity, but the application carries more responsibility for enforcing tenant boundaries correctly.
Option 2: Schema Per Tenant
Now consider a different design.
Instead of sharing tables, every tenant gets its own database schema.
For example:
tenant_a.Orders
tenant_a.Customers
tenant_a.Products
tenant_b.Orders
tenant_b.Customers
tenant_b.Products
tenant_c.Orders
tenant_c.Customers
tenant_c.ProductsThe same database server may still be used.
The separation is happening at the schema level.
A simplified SQL example could look like:
SELECT *
FROM tenant_a.Orders;instead of:
SELECT *
FROM Orders
WHERE TenantId = 'tenant-a';That is a meaningful architectural difference.
Tenant separation now exists partly in the database structure itself rather than primarily through row filtering.
Why Teams Choose Schema Per Tenant
The strongest reason is isolation.
Suppose a query accidentally runs:
SELECT * FROM tenant_a.Orders;There are no Tenant B orders in that table.
The schema boundary gives us another layer of protection.
It can also be useful when customers require stronger logical separation, especially in enterprise systems where customers ask questions such as:
“Where exactly is our data stored?”
A schema gives us a clearer answer than:
“Your rows are stored together with other customers, but every row contains a tenant identifier.”
That does not automatically mean schema-per-tenant is more secure. Security still depends on database permissions, application code, credentials, backups, operations, and many other controls.
But it provides a stronger structural boundary than row filtering alone.
Mapping EF Core Entities to a Tenant Schema
EF Core can map tables to schemas.
A static configuration is straightforward:
modelBuilder.Entity<Order>()
.ToTable("Orders", "tenant_a");or:
modelBuilder.HasDefaultSchema("tenant_a");The difficulty is that our schema is not actually static.
Tenant A needs:
tenant_aTenant B needs:
tenant_bSo the application needs tenant-aware model configuration.
Conceptually:
public class TenantDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
public TenantDbContext(
DbContextOptions<TenantDbContext> options,
ITenantContext tenantContext)
: base(options)
{
_tenantContext = tenantContext;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(
_tenantContext.SchemaName);
base.OnModelCreating(modelBuilder);
}
}At first this looks easier than adding TenantId everywhere.
Unfortunately, there is an EF Core detail we need to understand before using this design.
EF Core Caches Its Model
EF Core does not normally rebuild the complete entity model every time we create a DbContext.
That would be wasteful.
EF Core caches model metadata and reuses it when compatible context instances are created.
This becomes important when model configuration itself depends on the tenant.
Imagine the first request belongs to:
Tenant A
Schema = tenant_aEF Core creates a model mapping:
Order -> tenant_a.OrdersThe next request belongs to:
Tenant B
Schema = tenant_bIf our architecture allows the previously cached model to be reused, we have a serious problem.
Changing a property on the context does not necessarily mean EF Core should consider it an entirely different model.
When the physical model changes by tenant, the model cache strategy must therefore be considered explicitly.
A common pattern is implementing IModelCacheKeyFactory so that the tenant schema participates in the model cache key.
A simplified example:
public sealed class TenantModelCacheKeyFactory
: IModelCacheKeyFactory
{
public object Create(
DbContext context,
bool designTime)
{
if (context is TenantDbContext tenantContext)
{
return (
context.GetType(),
tenantContext.SchemaName,
designTime);
}
return (
context.GetType(),
designTime);
}
}The context exposes:
public string SchemaName { get; }Now:
Tenant A -> model for tenant_a
Tenant B -> model for tenant_bcan be treated as different EF models.
This is one of those areas where schema-per-tenant looks very simple at the database level but introduces complexity inside the ORM.
Takeaway: If tenant information changes EF Core’s physical model, model caching becomes part of your architecture.
Migrations Become the Hard Part
This is usually where schema-per-tenant starts becoming expensive operationally.
Imagine we have:
500 tenantsand release a feature requiring:
Orders.DiscountAmountWith a shared schema, we alter one table.
With schema-per-tenant, conceptually we need:
tenant_001.Orders
tenant_002.Orders
tenant_003.Orders
...
tenant_500.Ordersupdated.
The migration itself may be simple.
Coordinating it safely across hundreds or thousands of schemas is not.
You need to know:
- Which tenants were migrated?
- Which migration version is each tenant running?
- What happens when tenant 387 fails?
- Can application version N run against schema version N-1?
- How do we retry?
- Can we roll back?
- How long does deployment take?
At that point migrations are no longer just:
dotnet ef database updateThey become part of the deployment platform.
Microsoft’s EF Core guidance also recommends inspecting and testing migrations before production deployment, and provides deployment options such as reviewed SQL scripts and migration bundles.
For a schema-per-tenant system, I would normally want explicit migration orchestration rather than casually migrating every tenant from application startup.
For example:
Deployment
│
▼
Tenant Migration Worker
│
├── tenant_001 OK
├── tenant_002 OK
├── tenant_003 FAILED
├── tenant_004 OK
└── ...The migration process should be observable and resumable.
For larger systems, migration status may deserve its own table:
TenantMigrationStatus
-------------------------------------------------
TenantId Migration Status
-------------------------------------------------
A 202609_AddDiscount Completed
B 202609_AddDiscount Completed
C 202609_AddDiscount FailedThis is additional infrastructure that shared-schema applications usually do not need.
Takeaway: Schema-per-tenant moves complexity away from query isolation and into provisioning, model management, migrations, and operations.
Cross-Tenant Reporting Is Also Different
Suppose management wants:
Show total sales across all tenants.
With shared schema:
SELECT TenantId, SUM(Amount)
FROM Orders
GROUP BY TenantId;Very simple.
With schema-per-tenant:
tenant_a.Orders
tenant_b.Orders
tenant_c.Ordersthere is no single shared Orders table.
You now need another strategy.
You could dynamically query every schema, but this becomes increasingly unattractive as the tenant count grows.
A better architecture may be:
Tenant Schemas
│
▼
Data Pipeline
│
▼
Reporting / Analytics DatabaseOperational transactions stay isolated while reporting data is copied into a centralized analytical model.
This can actually be a good design for larger platforms.
But again, we are adding infrastructure.
Performance: Which One Is Faster?
This question comes up frequently, but I do not think there is a universal winner.
With shared schema, tables can become very large.
Imagine:
10,000 tenants
5,000 orders per tenantThat is 50 million rows in Orders.
Queries need proper indexes, usually starting with tenant-aware access patterns.
For example:
(TenantId, CreatedAt)
(TenantId, Status)
(TenantId, OrderNumber)Poor indexing can make shared-schema performance degrade quickly.
Schema-per-tenant produces smaller tables:
tenant_a.Orders = 5,000 rows
tenant_b.Orders = 5,000 rowsThat may improve some tenant-local operations.
But we pay elsewhere.
A very large number of schemas and tables increases database metadata and operational complexity. EF model caching can become more expensive if every tenant requires its own model. Connection and context configuration also become more complicated.
So I would not choose schema-per-tenant simply because “smaller tables are faster.”
Likewise, I would not choose shared schema simply because “one table is simpler.”
Measure the actual workload.
Look at:
- Tenant count
- Rows per tenant
- Query patterns
- Indexes
- Reporting requirements
- Write volume
- Connection behavior
- Database engine
- Growth expectations
Performance is a workload question, not only a tenancy-pattern question.
What About Database Per Tenant?
There is a third architecture worth mentioning:
Tenant A -> Database A
Tenant B -> Database B
Tenant C -> Database CThis provides even stronger isolation.
It can be attractive for enterprise customers, regulatory requirements, geographic data residency, customer-specific backups, or customers with very different workloads.
But operational cost increases again.
Now we need to manage:
- Connection strings
- Database provisioning
- Migrations
- Backups
- Monitoring
- Failover
- Database versions
- Connection pools
for potentially many databases.
There is therefore a rough spectrum:
Shared Schema
│
│ more isolation
▼
Schema Per Tenant
│
│ more isolation
▼
Database Per TenantAt the same time, moving downward usually means:
- More infrastructure
- More provisioning
- More migration work
- More operational cost
Neither end is automatically correct.
A Hybrid Model Is Sometimes Better
Real applications do not always fit perfectly into one model.
A SaaS platform might start with:
- Shared Schema
for most customers.
Later, a large enterprise customer requires stronger isolation.
Instead of redesigning the entire system, the platform could support:
Standard tenants
│
▼
Shared Database
Enterprise Tenant A
│
▼
Dedicated Database
Enterprise Tenant B
│
▼
Dedicated DatabaseThis is sometimes called a hybrid tenancy model.
The difficult part is making sure business code does not care too much about where the tenant lives.
Ideally:
var tenant = tenantResolver.GetCurrentTenant();provides enough information for infrastructure to decide:
- Database
- Schema
- Connection
- Tenant identifier
without the domain layer constantly asking where data is physically stored.
That separation becomes valuable if the architecture needs to evolve later.
Tenant Resolution Should Happen Early
Regardless of database strategy, the application needs to answer one question very early:
Which tenant is making this request?
Tenant resolution might come from:
- Subdomain
- JWT claim
- API key
- Request header
- User membership
- Route
For example:
https://company-a.example.comcould resolve:
TenantId = 42
Schema = tenant_42Or a token might contain:
{
"sub": "user-1001",
"tenant_id": "tenant-a"
}Once resolved, tenant context should normally remain stable for that request.
Something like:
public sealed class TenantContext
{
public Guid TenantId { get; init; }
public string Name { get; init; } = string.Empty;
public string? SchemaName { get; init; }
public string? ConnectionString { get; init; }
}The rest of the application can depend on an abstraction instead of repeatedly parsing authentication claims or HTTP headers.
This becomes especially important for background workers because there may be no HTTP request at all.
A background job processing Tenant A should establish tenant context explicitly before accessing tenant-scoped data.
Be Careful with DbContext Lifetime
DbContext should not casually move between tenants.
If a context was created for:
Tenant AI would never change its tenant to:
Tenant Band continue using it.
Create a new context.
This matters even more with schema-per-tenant because the tenant may affect the EF model, connection, or schema mapping.
Conceptually:
await using var context =
await tenantDbContextFactory.CreateAsync(tenant);Then dispose it when that tenant operation finishes.
Keeping tenant boundaries aligned with context lifetime makes behavior much easier to reason about.
Common Mistakes I Would Watch For
The first mistake is allowing the API caller to decide its own tenant identity. Tenant context should come from trusted authentication or application configuration.
The second is remembering tenant filters for reads but forgetting tenant ownership during inserts and updates.
The third is using IgnoreQueryFilters() casually. It should be rare and obvious when code intentionally crosses tenant boundaries.
Another common problem is caching data without including tenant identity in the cache key.
This is dangerous:
product:100because Product 100 may exist for several tenants.
Prefer something tenant-aware:
tenant:A:product:100
tenant:B:product:100The same principle applies to distributed caches, output caching, files, queues, search indexes, and other storage outside the relational database.
Another mistake is choosing schema-per-tenant without designing migrations first.
Creating schemas is easy.
Maintaining 2,000 schemas for five years is the difficult part.
Finally, do not assume multi-tenancy ends at EF Core.
A tenant-aware database with a tenant-unaware file storage path can still leak customer data.
The boundary needs to continue through the whole system.
So Which Architecture Should You Choose?
For many SaaS applications, I would start by seriously considering a shared schema.
It works particularly well when:
- Tenant count is high
- Tenant structures are mostly identical
- Fast tenant provisioning matters
- Cross-tenant reporting is common
- Operational simplicity matters
EF Core global query filters can provide a convenient default tenant filter, while composite indexes and tenant-aware constraints keep queries and business rules efficient.
Schema-per-tenant becomes more interesting when:
- Tenant isolation requirements are stronger
- Tenant count is manageable
- Customers are relatively large
- Per-tenant maintenance is valuable
- Schemas may need independent operational handling
But I would only choose it after answering the migration question.
Not:
Can EF Core map to different schemas?
It can.
The more important question is:
Can our team reliably provision, migrate, monitor, troubleshoot, and recover hundreds or thousands of tenant schemas for the lifetime of this product?
That is usually where the architecture decision becomes clearer.
Final Thoughts
Multi-tenant database architecture is one of those decisions that looks technical at first but is actually strongly connected to the business model.
A system serving 20 large enterprise customers has different requirements from a SaaS platform expecting 50,000 small businesses.
Shared schema gives us simplicity:
Shared tables
+ TenantId
+ Query filters
+ Tenant-aware indexesSchema-per-tenant gives us stronger structural separation:
Tenant A schema
Tenant B schema
Tenant C schemabut asks us to solve harder problems around EF model configuration, migrations, reporting, and operations.
One thing I learned while working on tenant-scoped application designs is that the easiest architecture to demonstrate is not necessarily the easiest architecture to operate.
Adding a TenantId takes a few minutes.
Creating a schema takes a few minutes.
The real architecture appears later, when the application has hundreds of features, millions of records, multiple background processes, several developers, and years of database migrations.
That is the point I would design for.
Sources
- Microsoft Learn: Global Query Filters in EF Core. Covers EF Core query filters, multi-tenancy scenarios, disabling filters, and related limitations.
- Microsoft Learn: EF Core Migrations Overview. Overview of using migrations to keep database schemas synchronized with EF Core models.
- Microsoft Learn: Applying EF Core Migrations. Covers production migration strategies including SQL scripts, migration bundles, tooling, and runtime migration considerations.
- Microsoft Learn: Indexes in EF Core. Covers indexes, composite indexes, uniqueness, ordering, and related EF Core configuration.
- Microsoft Learn: Entity Framework documentation. Main documentation and learning resources for Entity Framework and EF Core.






