Remigiusz ZalewskiRemigiusz Zalewski

EF Core Inheritance - TPH, TPT and TPC

ef-core-inheritance-tph-tpt-tpc

Introduction

You have a Vehicle base class and Car, Truck, and Motorcycle deriving from it, each with its own extra properties. That models cleanly in C#. The question is what it becomes in a relational database, which has no concept of inheritance at all.

EF Core gives you three mapping strategies for exactly this: Table-Per-Hierarchy (TPH), Table-Per-Type (TPT), and Table-Per-Concrete-type (TPC). They produce very different schemas and very different query plans from the same class hierarchy. This video builds the vehicle model once and switches between all three so you can see the tables, the generated SQL, and the trade-offs.

🎬 Watch the full video here:


The model

The setup is a standard hierarchy - an abstract base with shared columns and three concrete types that each add their own:

public abstract class Vehicle
{
    public int Id { get; set; }
    public string Color { get; set; } = string.Empty;
    public int Year { get; set; }
    public required string Model { get; set; }
    public required string Make { get; set; }
    public int EngineSizeInCc { get; set; }
}

public class Car : Vehicle          { /* NumberOfDoors, IsConvertible, FuelType */ }
public class Truck : Vehicle        { /* PayloadCapacityInKg, TowingCapacityInKg, ... */ }
public class Motorcycle : Vehicle   { /* HasSidecar, NumberOfGears */ }

The DbContext exposes DbSet<Vehicle> plus a DbSet for each concrete type. Which strategy EF Core uses is decided by one line in OnModelCreating.

Table-Per-Hierarchy (TPH) - the default

If you do nothing, EF Core uses TPH. The entire hierarchy goes into one table with the union of every subclass's columns, plus a discriminator column (a string by default, named Discriminator) that records which type each row actually is.

  • Columns that only belong to one subclass are nullable in the table, because rows of other types have nothing to put there.
  • Queries are fast: no joins, ever. context.Cars.ToList() is a single SELECT ... WHERE Discriminator = 'Car'.
  • The cost is a wide, sparse table and the loss of NOT NULL constraints on subclass properties. The database can no longer guarantee a Car has NumberOfDoors.

TPH is the right default for most hierarchies. Pick something else only when you have a concrete reason.

Table-Per-Type (TPT)

modelBuilder.Entity<Vehicle>().UseTptMappingStrategy();

TPT gives you a Vehicles table with the shared columns and a separate table per subclass (Cars, Trucks, Motorcycles) holding only that type's extra columns, linked one-to-one by a shared primary key.

  • The schema is normalized and every column can be NOT NULL. A database purist likes this.
  • Every query pays for joins. Reading a Car joins Vehicles to Cars. Reading DbSet<Vehicle> polymorphically joins to all three subclass tables. On large tables this adds up.
  • Inserts touch multiple tables per row.

TPT looks tidy and is frequently a performance trap. The EF Core team explicitly recommends against it as a default.

Table-Per-Concrete-type (TPC)

modelBuilder.Entity<Vehicle>().UseTpcMappingStrategy();

TPC is the strategy the demo settles on. There is no base table. Each concrete type gets one table containing all its columns - shared and specific - so Cars has Color, Year, Make, Model and NumberOfDoors.

  • Querying a single concrete type is as fast as TPH: one table, no joins, no discriminator.
  • Querying the base type polymorphically (DbSet<Vehicle>) becomes a UNION ALL across every concrete table - still no joins.
  • The catch is the primary key. Auto-increment identity columns will not work, because Car id 1 and Truck id 1 would collide in the polymorphic view. The demo uses ValueGeneratedOnAdd() with a strategy EF Core can keep unique across tables (a Hi-Lo sequence, or database sequences), not per-table IDENTITY.
  • Shared columns are duplicated in every table - a schema-maintenance cost when the base changes.

TPC is a strong choice when you mostly query concrete types, rarely query the base polymorphically, and want to avoid both TPT's joins and TPH's nullable sprawl.

Choosing

StrategyTablesJoins on readSubclass NOT NULLPolymorphic queryBest when
TPH1NoneNo (nullable)WHERE Discriminator = ...Default; most hierarchies
TPT1 + NYes, alwaysYesJoins to all subclass tablesNormalization matters more than read speed
TPCNNoneYesUNION ALLQuery concrete types often, base type rarely

A practical rule: start with TPH. Move to TPC if the nullable columns genuinely bother you or the table gets uncomfortably wide. Reach for TPT only when a specific requirement forces full normalization.

Common pitfalls

  • Switching strategy on an existing database. Each strategy is a completely different schema. Changing it means a real migration that moves data between table shapes, not a one-liner.
  • Expecting IDENTITY keys under TPC. They cannot stay unique across sibling tables. Configure a shared value generator.
  • Assuming TPT is "the clean one" and defaulting to it. It is the one most likely to surprise you with slow polymorphic queries.
  • Large polymorphic DbSet<Vehicle> scans under TPT. The multi-join query over three subclass tables is the classic slow endpoint.

Key Takeaways

  • The same C# hierarchy can map three ways; you choose with UseTphMappingStrategy (default), UseTptMappingStrategy, or UseTpcMappingStrategy.
  • TPH: one table, a discriminator, nullable subclass columns, no joins - the right default.
  • TPT: normalized tables joined by shared key - clean schema, join cost on every read, recommended against as a default.
  • TPC: one table per concrete type with all columns duplicated - fast concrete queries, UNION ALL for polymorphic ones, needs non-identity keys.
  • Migrating between strategies is a schema rewrite, so decide before you have production data.

Get the Full Source Code

The complete runnable solution - the vehicle hierarchy, the DbContext configuration for each strategy, the migrations, and the endpoints that let you inspect the generated SQL - is available to Patreon supporters. If you want to flip between TPH, TPT, and TPC and watch the schema change instead of rebuilding it from the walkthrough above, you can find it on Patreon.

Resources