Multi-Tenant Architecture with EF Core: Schema Per Tenant vs Shared

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:

  1. Shared schema with a TenantId
  2. 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:

Plaintext
Company A
Company B
Company C

Each 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:

C#
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:

SQL
dbo.Orders
dbo.Customers
dbo.Products
dbo.Invoices

Every 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.

C#
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:

C#
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:

C#
public interface ITenantContext
{
    Guid TenantId { get; }
}

Then inject it into our DbContext.

C#
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:

C#
var orders = await dbContext.Orders.ToListAsync();

conceptually becomes:

SQL
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:

C#
public interface ITenantEntity
{
    Guid TenantId { get; set; }
}

Then:

C#
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:

JSON
{
  "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:

C#
order.TenantId = request.TenantId;

we would normally do something closer to:

C#
order.TenantId = tenantContext.TenantId;

Some systems go further and enforce this centrally inside SaveChanges.

A simplified example:

C#
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:

SQL
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:

C#
var order = await dbContext.Orders
    .SingleOrDefaultAsync(x => x.OrderNumber == orderNumber);

Because of the global query filter, the effective query includes both:

Plaintext
TenantId
OrderNumber

A composite index can make sense:

C#
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:

Plaintext
ORD-0001

A unique index only on OrderNumber would incorrectly prevent two tenants from using the same number.

Instead:

Plaintext
TenantId + OrderNumber

should be unique.

Conceptually:

SQL
CREATE UNIQUE INDEX IX_Orders_TenantId_OrderNumber
ON Orders (TenantId, OrderNumber);

This pattern appears everywhere in shared-schema applications.

For example:

Plaintext
TenantId + EmployeeNumber
TenantId + ProductCode
TenantId + InvoiceNumber
TenantId + ExternalReference

I 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:

Plaintext
1 Orders table
1 Customers table
1 Products table

If we add:

Plaintext
Orders.DiscountAmount

we 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:

Bash
dotnet ef migrations add AddDiscountAmount

Then 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:

Plaintext
Tenant
Users
Configuration
Subscription

No 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().

C#
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:

SQL
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.Products

The same database server may still be used.

The separation is happening at the schema level.

A simplified SQL example could look like:

SQL
SELECT *
FROM tenant_a.Orders;

instead of:

SQL
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:

SQL
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:

C#
modelBuilder.Entity<Order>()
    .ToTable("Orders", "tenant_a");

or:

C#
modelBuilder.HasDefaultSchema("tenant_a");

The difficulty is that our schema is not actually static.

Tenant A needs:

Plaintext
tenant_a

Tenant B needs:

Plaintext
tenant_b

So the application needs tenant-aware model configuration.

Conceptually:

C#
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:

SQL
Tenant A
Schema = tenant_a

EF Core creates a model mapping:

SQL
Order -> tenant_a.Orders

The next request belongs to:

SQL
Tenant B
Schema = tenant_b

If 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:

C#
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:

C#
public string SchemaName { get; }

Now:

Plaintext
Tenant A -> model for tenant_a
Tenant B -> model for tenant_b

can 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:

Plaintext
500 tenants

and release a feature requiring:

C#
Orders.DiscountAmount

With a shared schema, we alter one table.

With schema-per-tenant, conceptually we need:

SQL
tenant_001.Orders
tenant_002.Orders
tenant_003.Orders
...
tenant_500.Orders

updated.

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:

Bash
dotnet ef database update

They 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:

Plaintext
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:

Plaintext
TenantMigrationStatus
-------------------------------------------------
TenantId      Migration             Status
-------------------------------------------------
A             202609_AddDiscount    Completed
B             202609_AddDiscount    Completed
C             202609_AddDiscount    Failed

This 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:

SQL
SELECT TenantId, SUM(Amount)
FROM Orders
GROUP BY TenantId;

Very simple.

With schema-per-tenant:

SQL
tenant_a.Orders
tenant_b.Orders
tenant_c.Orders

there 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:

Plaintext
Tenant Schemas


Data Pipeline


Reporting / Analytics Database

Operational 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:

Plaintext
10,000 tenants
5,000 orders per tenant

That is 50 million rows in Orders.

Queries need proper indexes, usually starting with tenant-aware access patterns.

For example:

Plaintext
(TenantId, CreatedAt)
(TenantId, Status)
(TenantId, OrderNumber)

Poor indexing can make shared-schema performance degrade quickly.

Schema-per-tenant produces smaller tables:

SQL
tenant_a.Orders = 5,000 rows
tenant_b.Orders = 5,000 rows

That 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:

Plaintext
Tenant A -> Database A
Tenant B -> Database B
Tenant C -> Database C

This 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:

Plaintext
Shared Schema

     │ more isolation

Schema Per Tenant

     │ more isolation

Database Per Tenant

At 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:

Plaintext
Standard tenants


Shared Database

Enterprise Tenant A


Dedicated Database

Enterprise Tenant B


Dedicated Database

This 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:

C#
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:

HTTP
https://company-a.example.com

could resolve:

Plaintext
TenantId = 42
Schema = tenant_42

Or a token might contain:

JSON
{
  "sub": "user-1001",
  "tenant_id": "tenant-a"
}

Once resolved, tenant context should normally remain stable for that request.

Something like:

C#
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:

Plaintext
Tenant A

I would never change its tenant to:

Plaintext
Tenant B

and 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:

C#
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:

Plaintext
product:100

because Product 100 may exist for several tenants.

Prefer something tenant-aware:

Plaintext
tenant:A:product:100
tenant:B:product:100

The 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:

Plaintext
Shared tables
+ TenantId
+ Query filters
+ Tenant-aware indexes

Schema-per-tenant gives us stronger structural separation:

Plaintext
Tenant A schema
Tenant B schema
Tenant C schema

but 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

Assi Arai
Assi Arai
Articles: 58