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.
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, andCardHolderName, whileBankName,AccountNumber, andWalletAddressare NULL. - A bank transfer row fills
Amount,Currency,Discriminator = 'BankTransfer',BankName, andAccountNumber, 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:
| TPH | TPT | TPC | |
|---|---|---|---|
| Tables | One table for the whole hierarchy | Base table plus one table per subclass | One standalone table per concrete type |
| Querying all types | One table scan, the fastest | A LEFT JOIN per subclass, the slowest | UNION ALL across the tables |
| Querying a single type | Filter on the discriminator column | One JOIN to the base table | One dedicated table, the fastest |
| Inserting a row | One INSERT | Two INSERTs, base plus subclass | One INSERT |
| Subclass constraints | Subclass columns must be nullable | NOT NULL per subclass table | NOT NULL per concrete table |
| Sparse data | Carries NULLs for the other types' columns | Only the columns each type needs | Only the columns each type needs |
| Adding a new type | A migration for the new columns, the least invasive change | A migration for the new subclass table | A migration for the new concrete table |
| EF Core support | The default strategy | Per-type ToTable calls | Added in EF Core 7 |
| Best for | Most hierarchies, and polymorphic queries in particular | Many subclass columns that would be NULL, or database-level constraints | Concrete 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.



