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.

👉 Try it now — and feel the difference

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:

ApproachTenant Column?Schema per Tenant?Multiple Databases?EF Core Support
Discriminator (Shared Schema)YesNoNoBuilt-in via global query filter
Database-per-TenantNoNoYesBuilt-in via configuration
Schema-per-TenantNoYesNoNot 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:

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

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

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

C#
// Program.cs
builder.Services.AddDbContextFactory<AppDbContext>(
    options => { /* connection string resolved per-context */ },
    lifetime: ServiceLifetime.Transient);
ScenarioSingle Database (Shared Schema)Multiple Databases (Per-Tenant)
User stays in one tenant per sessionScopedScoped
User can switch tenants mid-sessionScopedTransient

Note: If you’re building on Blazor Server, be extra careful here. The default DbContextFactory registration 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 as Scoped (or Transient if 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
EF Core performance optimization sponsor banner showing bulk insert, update, delete, and merge features with 14x faster data operations.

Approach 2: Shared Schema (Discriminator Column)

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:

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

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

C#
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 TenantId later)

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

Database-per-Tenant vs Shared Schema — Comparison Table

FactorDatabase-per-TenantShared Schema
Data IsolationStrongest — physically separateWeaker — logical, filter-dependent
Infrastructure CostHigher (scales with tenant count)Lower — one database
Migration ComplexityMust run per tenant databaseSingle migration path
ScalabilityPer-tenant scaling possibleShared resources, noisy-neighbor risk
Backup/RestoreSimple — per tenantComplex — must filter by tenant
Compliance FitBest for strict regulatory needsWorkable, needs extra safeguards
Best ForEnterprise, healthcare, finance clientsHigh-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.

👉 Insert data 14x faster with Bulk Insert

Found this article useful? Share it with your network and spark a conversation.