TPH vs TPT: Choosing an Inheritance Strategy in EF Core
When your domain model uses inheritance, EF Core needs a strategy to map that hierarchy onto relational tables. The two main options are table-per-hierarchy (TPH) and table-per-type (TPT). EF Core 7 also added table-per-concrete-type (TPC). Each has distinct trade-offs for query performance, storage efficiency, and schema clarity.
The Domain Model
Consider a content management system with different content types:
public abstract class Content
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public DateTime PublishedAt { get; set; }
}
public class Article : Content
{
public string Body { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
}
public class Video : Content
{
public string VideoUrl { get; set; } = string.Empty;
public int DurationSeconds { get; set; }
}
public class Podcast : Content
{
public string AudioUrl { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public int EpisodeNumber { get; set; }
}
Table-Per-Hierarchy (TPH)
TPH is the default strategy. All types in the hierarchy share a single table, with a discriminator column to identify the type:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Content>()
.HasDiscriminator<string>("ContentType")
.HasValue<Article>("article")
.HasValue<Video>("video")
.HasValue<Podcast>("podcast");
}
The resulting table:
| Id | Title | PublishedAt | ContentType | Body | Author | VideoUrl | DurationSeconds | AudioUrl | Host | EpisodeNumber |
|---|
Pros:
- Fastest queries — no JOINs needed
- Polymorphic queries are simple
SELECTstatements with aWHEREon the discriminator - Single table makes indexing straightforward
Cons:
- Columns for other types are nullable (an
Articlerow hasNULLforVideoUrl,DurationSeconds, etc.) - Wide tables with many subtypes
- Cannot enforce
NOT NULLconstraints on subtype-specific columns at the database level
Table-Per-Type (TPT)
TPT creates a separate table for each type. The base table holds shared properties, and derived tables hold type-specific properties linked by a shared primary key:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Content>().ToTable("Contents");
modelBuilder.Entity<Article>().ToTable("Articles");
modelBuilder.Entity<Video>().ToTable("Videos");
modelBuilder.Entity<Podcast>().ToTable("Podcasts");
}
This gives you four tables. Articles has columns Id, Body, Author where Id is both the PK and a FK to Contents.Id.
Pros:
- Normalised schema — no nullable columns for other types
- Database constraints can enforce
NOT NULLon subtype columns - Cleaner table structure
Cons:
- Polymorphic queries require JOINs across all type tables
- Performance degrades as the hierarchy grows
- Insert/update operations touch multiple tables
Performance Comparison
The performance difference is significant. A polymorphic query with TPH:
SELECT * FROM Contents WHERE ContentType = 'article'
The same query with TPT:
SELECT c.Id, c.Title, c.PublishedAt, a.Body, a.Author,
v.VideoUrl, v.DurationSeconds, p.AudioUrl, p.Host, p.EpisodeNumber
FROM Contents c
LEFT JOIN Articles a ON c.Id = a.Id
LEFT JOIN Videos v ON c.Id = v.Id
LEFT JOIN Podcasts p ON c.Id = p.Id
With three subtypes, TPT generates three LEFT JOINs. With ten subtypes, it's ten JOINs. The EF Core team have explicitly warned that TPT can cause significant performance issues in large hierarchies.
Table-Per-Concrete-Type (TPC)
Added in EF Core 7, TPC creates a separate table for each concrete type with all properties (including inherited ones):
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Content>().UseTpcMappingStrategy();
modelBuilder.Entity<Article>().ToTable("Articles");
modelBuilder.Entity<Video>().ToTable("Videos");
modelBuilder.Entity<Podcast>().ToTable("Podcasts");
}
Articles now has columns Id, Title, PublishedAt, Body, Author — all columns in one table. No base table exists.
Pros:
- No JOINs needed for single-type queries
- No nullable columns
- Each table is self-contained
Cons:
- Polymorphic queries use
UNION ALLacross all type tables - Shared columns are duplicated across tables
- Key generation requires care — you need sequences or GUIDs to avoid ID collisions across tables
modelBuilder.Entity<Content>()
.Property(c => c.Id)
.UseSequence("ContentIdSequence");
Which Strategy to Choose
| Scenario | Recommended |
|---|---|
| Few subtypes, frequent polymorphic queries | TPH |
| Many subtype-specific columns, rarely query polymorphically | TPT or TPC |
| Need database-level NOT NULL constraints | TPT or TPC |
| Performance is the priority | TPH or TPC |
| Simple schema, small hierarchy | TPH |
For most applications, TPH is the right default. The nullable columns are a minor inconvenience compared to the JOIN overhead of TPT. Switch to TPC if you need non-nullable constraints and mostly query single types. Reserve TPT for cases where schema normalisation is a hard requirement.
// Quick query — works the same regardless of strategy
var articles = await context.Set<Article>()
.Where(a => a.PublishedAt > DateTime.UtcNow.AddDays(-30))
.OrderByDescending(a => a.PublishedAt)
.ToListAsync();
The mapping strategy is a persistence concern. Your LINQ queries look the same regardless of which strategy you choose — which is one of EF Core's genuine strengths.