Table Per Hierarchy vs Table Per Type in EF Core

Table Per Hierarchy vs Table Per Type in EF Core

8 min read··

dotnetef-core

TPH (Table Per Hierarchy) maps a whole class hierarchy to one table with a discriminator column, and it is EF Core's default. TPT (Table Per Type) gives the base class and each subclass its own table, joined by foreign key. TPH keeps polymorphic queries to one table scan, while TPT normalizes the schema and can enforce NOT NULL on subclass columns. Start with TPH unless you have a strong reason not to.

Object inheritance has no single natural relational representation. TPH, TPT, and TPC distribute columns and joins differently, so the choice affects query shape long after the mapping code is written. Decide from the reads you need and the constraints you value, then verify the generated SQL.

Inheritance Mapping in EF Core

When your domain model uses inheritance - a base Payment class with CreditCardPayment and BankTransferPayment subclasses - EF Core needs a strategy to map this to relational tables. The two main approaches are Table Per Hierarchy (TPH) and Table Per Type (TPT).

The wrong strategy becomes expensive to change once the hierarchy contains production data. The choice affects query performance, schema complexity, and which constraints the database can express.

The same Payment hierarchy mapped three ways: TPH into one table with a discriminator, TPT into a base table plus subclass tables joined by foreign key, and TPC into three standalone concrete tables

Table Per Hierarchy (TPH)

TPH stores all types in a single table with a discriminator column that indicates the type:

public abstract class Payment
{
    public Guid Id { get; set; }
    public decimal Amount { get; set; }
    public DateTime CreatedAt { get; set; }
    public string Currency { get; set; } = "USD";
}

public class CreditCardPayment : Payment
{
    public string CardNumber { get; set; } = string.Empty;
    public string CardHolderName { get; set; } = string.Empty;
    public string ExpiryDate { get; set; } = string.Empty;
}

public class BankTransferPayment : Payment
{
    public string BankName { get; set; } = string.Empty;
    public string AccountNumber { get; set; } = string.Empty;
    public string RoutingNumber { get; set; } = string.Empty;
}

public class CryptoPayment : Payment
{
    public string WalletAddress { get; set; } = string.Empty;
    public string Network { get; set; } = string.Empty;
}

TPH is the default in EF Core. One Payments table stores everything:

  • A credit card payment row fills Amount, Currency, Discriminator = 'CreditCard', CardNumber, and CardHolderName, while BankName, AccountNumber, and WalletAddress are NULL.
  • A bank transfer row fills Amount, Currency, Discriminator = 'BankTransfer', BankName, and AccountNumber, while all the credit card and crypto columns are NULL.

Every subclass-specific column exists on every row, and rows of other types leave them NULL.

Configure the discriminator:

public class PaymentConfiguration : IEntityTypeConfiguration<Payment>
{
    public void Configure(EntityTypeBuilder<Payment> builder)
    {
        builder.ToTable("Payments");

        builder.HasDiscriminator<string>("PaymentType")
            .HasValue<CreditCardPayment>("CreditCard")
            .HasValue<BankTransferPayment>("BankTransfer")
            .HasValue<CryptoPayment>("Crypto");

        builder.Property("PaymentType")
            .HasMaxLength(50);
    }
}

Table Per Type (TPT)

TPT uses a separate table for each type. The base class gets one table, each subclass gets another table with a foreign key back to the base:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Payment>().ToTable("Payments");
    modelBuilder.Entity<CreditCardPayment>().ToTable("CreditCardPayments");
    modelBuilder.Entity<BankTransferPayment>().ToTable("BankTransferPayments");
    modelBuilder.Entity<CryptoPayment>().ToTable("CryptoPayments");
}

On EF Core 7+, you can also state the intent explicitly with modelBuilder.Entity<Payment>().UseTptMappingStrategy();, but the per-type ToTable calls alone are enough to trigger TPT.

This creates four tables:

  • Payments (Id, Amount, Currency, CreatedAt)
  • CreditCardPayments (Id → FK to Payments, CardNumber, CardHolderName, ExpiryDate)
  • BankTransferPayments (Id → FK to Payments, BankName, AccountNumber, RoutingNumber)
  • CryptoPayments (Id → FK to Payments, WalletAddress, Network)

Performance Comparison

This is where the two strategies diverge significantly.

Querying all payments (TPH):

SELECT * FROM Payments

One table scan. Fast.

Querying all payments (TPT):

SELECT p.*, cc.*, bt.*, cr.*
FROM Payments p
LEFT JOIN CreditCardPayments cc ON p.Id = cc.Id
LEFT JOIN BankTransferPayments bt ON p.Id = bt.Id
LEFT JOIN CryptoPayments cr ON p.Id = cr.Id

Multiple LEFT JOINs. Gets slower with each type added.

Querying a specific type (TPH):

var creditCardPayments = await dbContext.Set<CreditCardPayment>()
    .Where(p => p.Amount > 100)
    .ToListAsync();
SELECT * FROM Payments WHERE PaymentType = 'CreditCard' AND Amount > 100

Fast - just a filter on the discriminator column.

Querying a specific type (TPT):

SELECT p.*, cc.*
FROM Payments p
INNER JOIN CreditCardPayments cc ON p.Id = cc.Id
WHERE p.Amount > 100

Still needs a JOIN, but only one.

Inserting Data

TPH: Single INSERT to one table.

dbContext.Set<CreditCardPayment>().Add(new CreditCardPayment
{
    Amount = 100,
    Currency = "USD",
    CardNumber = "4111111111111111",
    CardHolderName = "John Doe",
    ExpiryDate = "12/28"
});
await dbContext.SaveChangesAsync();

TPT: Two INSERTs - one to the base table, one to the subclass table (in a transaction).

The INSERT overhead matters at high write volumes. TPH is consistently better for writes.

Data Integrity

TPH disadvantage: Subclass-specific columns must be nullable. You can't enforce that CardNumber is required at the database level because BankTransferPayment rows don't have it. You need application-level validation.

TPT advantage: Each subclass table can enforce its own NOT NULL constraints. CreditCardPayments.CardNumber can be NOT NULL.

// TPT allows proper constraints
modelBuilder.Entity<CreditCardPayment>(b =>
{
    b.Property(p => p.CardNumber).IsRequired().HasMaxLength(19);
    b.Property(p => p.CardHolderName).IsRequired().HasMaxLength(100);
});

Table Per Concrete Type (TPC)

EF Core 7 introduced TPC as a third option. Each concrete type gets its own table with all columns - no foreign keys between them:

modelBuilder.Entity<Payment>().UseTpcMappingStrategy();
modelBuilder.Entity<CreditCardPayment>().ToTable("CreditCardPayments");
modelBuilder.Entity<BankTransferPayment>().ToTable("BankTransferPayments");
modelBuilder.Entity<CryptoPayment>().ToTable("CryptoPayments");

TPC queries use UNION ALL instead of JOINs:

SELECT * FROM CreditCardPayments
UNION ALL
SELECT * FROM BankTransferPayments
UNION ALL
SELECT * FROM CryptoPayments

TPC is good when you rarely query across all types and mostly query specific subtypes.

One caveat: TPC works best with client-generated keys like Guid. A plain identity column cannot guarantee uniqueness across separate tables, so EF Core generates integer keys from a single shared sequence instead.

Side-by-Side Comparison

Here's how the three strategies compare across the factors that matter:

TPHTPTTPC
TablesOne table for the whole hierarchyBase table plus one table per subclassOne standalone table per concrete type
Querying all typesOne table scan, the fastestA LEFT JOIN per subclass, the slowestUNION ALL across the tables
Querying a single typeFilter on the discriminator columnOne JOIN to the base tableOne dedicated table, the fastest
Inserting a rowOne INSERTTwo INSERTs, base plus subclassOne INSERT
Subclass constraintsSubclass columns must be nullableNOT NULL per subclass tableNOT NULL per concrete table
Sparse dataCarries NULLs for the other types' columnsOnly the columns each type needsOnly the columns each type needs
Adding a new typeA migration for the new columns, the least invasive changeA migration for the new subclass tableA migration for the new concrete table
EF Core supportThe default strategyPer-type ToTable callsAdded in EF Core 7
Best forMost hierarchies, and polymorphic queries in particularMany subclass columns that would be NULL, or database-level constraintsConcrete types queried independently

Which Strategy Should You Choose?

My recommendation: Start with TPH unless you have a strong reason not to. The performance advantage is significant, and the nullable column issue is manageable with proper validation.

Use TPT when:

  • You have many subclass-specific columns and most would be NULL in TPH
  • Database-level constraints on subclass properties are critical
  • You rarely query across all types

Use TPC when:

  • Each concrete type is mostly queried independently
  • You need strong constraints without JOINs

Querying Gotchas Worth Knowing

A few things that surprise people in production:

OfType<T>() translates to a discriminator filter with TPH, so this stays a single-table query:

var cardPayments = await dbContext.Payments
    .OfType<CreditCardPayment>()
    .Where(p => p.Amount > 100)
    .ToListAsync();

Global query filters apply to the whole hierarchy. You can only define a query filter on the root type, and it applies to every subclass. You can't filter just one payment type globally.

The discriminator column has no index by default. If you frequently query a rare subtype in a huge table, add an index on the discriminator (or a filtered index for that discriminator value). This is the same class of problem as any other query performance mistake: measure first, then index.

Switching Strategies Later

Changing the mapping strategy is a schema migration:

// To switch from TPH to TPT:
modelBuilder.Entity<CreditCardPayment>().ToTable("CreditCardPayments");
modelBuilder.Entity<BankTransferPayment>().ToTable("BankTransferPayments");

// dotnet ef migrations add SwitchToTpt

Here's the critical part: the generated migration only changes the schema. It creates the new subclass tables and drops the subclass columns from the old table, but it does not copy your existing data across.

On a production table, you need to add custom SQL to the migration that moves the data before the old columns are dropped. Follow the usual migration best practices: review the generated migration, add the data-copy step, and test it against a restored production backup first.

Summary

TPH is the simplest starting point and usually gives polymorphic queries the least join overhead. TPT trades additional joins for normalized subclass tables, while TPC duplicates base columns to keep concrete-type reads independent. Choose from the actual query mix and required database constraints, then benchmark the generated SQL before the schema becomes expensive to change.

Frequently Asked Questions

What is the default inheritance mapping strategy in EF Core?

Table Per Hierarchy (TPH). EF Core maps the whole class hierarchy to a single table with a discriminator column, unless you explicitly configure TPT or TPC.

Which is faster in EF Core, TPH or TPT?

TPH usually has the lowest join overhead for polymorphic queries because the hierarchy lives in one table. TPT joins subclass tables and can become expensive as the hierarchy grows, but the real difference depends on the hierarchy and query mix, so inspect and benchmark the generated SQL.

When should I use TPT instead of TPH?

Use TPT when subclasses have many type-specific columns that would be mostly NULL in a single table, or when you need database-level NOT NULL constraints on subclass properties. Accept that queries over the base type will be slower.

What is TPC in EF Core?

Table Per Concrete type, added in EF Core 7. Each concrete class gets its own complete table with no shared base table. Queries over the base type use UNION ALL instead of JOINs, which performs well when you mostly query one concrete type at a time.

Can I change from TPH to TPT after going to production?

Yes, but the generated migration only changes the schema. It will not move existing row data into the new subclass tables, so you must write custom SQL in the migration to copy the data before dropping the old columns.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.