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:

Example.cs
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:

Example.cs
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:

Cons:

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:

Example.cs
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:

Cons:

Performance Comparison

The performance difference is significant. A polymorphic query with TPH:

query.sql
SELECT * FROM Contents WHERE ContentType = 'article'

The same query with TPT:

query.sql
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):

Example.cs
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:

Cons:

Example.cs
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.

Example.cs
// 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.