Start Debugging

TPH vs TPT vs TPC inheritance mapping in EF Core 11: which should you pick?

On EF Core 11, default to TPH for almost every hierarchy, reach for TPC only when you mostly query one leaf type and a benchmark proves it wins, and use TPT only when an external constraint forces you to.

On EF Core 11 (with .NET 11 and C# 14), map a class hierarchy with table-per-hierarchy (TPH) unless you have a measured reason not to. TPH puts the whole hierarchy in one table with a discriminator column, so reads are single-table scans with no joins. Reach for table-per-concrete-type (TPC) only when your code overwhelmingly queries a single leaf type and a benchmark on your data shows it beating TPH. Use table-per-type (TPT) only when an external constraint forces it, because Microsoft’s own benchmark puts TPT at roughly twice the time and nearly double the allocations of TPH for a base-type query. The one-line rule: TPH by default, TPC for leaf-heavy workloads that measure faster, TPT never by choice.

This post is the decision, not the full configuration walkthrough. If you want the discriminator API, shared columns, and the nullable-column mechanics in depth, read how to configure table-per-hierarchy (TPH) inheritance mapping in EF Core 11. Here we put the three strategies side by side, show the schema each one generates, and name the constraints that make the choice for you.

The one-screen feature matrix

Take a two-level hierarchy: a base Blog and a derived RssBlog that adds an RssUrl. The three strategies map it to three completely different schemas, and every tradeoff below flows from that shape.

DimensionTPHTPTTPC
Tables generatedone, whole hierarchyone per type (abstract included)one per concrete type only
Discriminator columnyesnono
Derived-type columnsnullable, shared tableown table, can be NOT NULLown table, can be NOT NULL
Base-type query (context.Blogs)single SELECT, no joinLEFT JOIN across all tablesUNION ALL across concrete tables
Single-leaf query (OfType<RssBlog>)discriminator predicatejoin base + leaf tablesingle table, no filter
Storage shapewide, sparse, many nullsnormalized, no nullsdenormalized, columns repeated
Key generationany (Identity fine)any (Identity on base)shared sequence, no plain Identity
FK constraint to the base typeyesyesno (key lives in leaf table)
Complex types / JSON columnsyesyes (new in EF Core 11)yes (new in EF Core 11)
Base-type read: relative speedfastest (baseline)~2x slower~same as TPH
Microsoft’s stancerecommended default”only if constrained to”good for single-leaf queries

The pattern is not subtle. TPH wins or ties on almost every row that matters, TPC matches it except when you query across types, and TPT trades a cleaner-looking schema for joins that cost you at query time. Three of those cells changed in EF Core 11: complex types and JSON columns now work on TPT and TPC hierarchies, which was previously unsupported and pushed people back to owned entities for any inherited value object. That closes one of the last non-performance reasons to avoid TPT and TPC, but it does not change the performance verdict.

What each strategy actually writes to the database

The schemas make the abstract tradeoffs concrete. TPH is one table with a discriminator and nullable derived columns:

-- TPH: EF Core 11, SQL Server
CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL IDENTITY,
    [Url] nvarchar(max) NULL,
    [Discriminator] nvarchar(max) NOT NULL,
    [RssUrl] nvarchar(max) NULL,          -- nullable: base Blogs have no RssUrl
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

TPT splits every type into its own table, linked by a foreign key on the shared primary key:

-- TPT: EF Core 11, SQL Server
CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL IDENTITY,
    [Url] nvarchar(max) NULL,
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

CREATE TABLE [RssBlogs] (
    [BlogId] int NOT NULL,
    [RssUrl] nvarchar(max) NULL,
    CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId]),
    CONSTRAINT [FK_RssBlogs_Blogs_BlogId] FOREIGN KEY ([BlogId])
        REFERENCES [Blogs] ([BlogId]) ON DELETE NO ACTION
);

TPC gives every concrete type a self-contained table with every inherited column repeated, keyed off a shared sequence:

-- TPC: EF Core 11, SQL Server
CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
    [Url] nvarchar(max) NULL,
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

CREATE TABLE [RssBlogs] (
    [BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
    [Url] nvarchar(max) NULL,             -- inherited column, repeated here
    [RssUrl] nvarchar(max) NULL,
    CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId])
);

Configuring each one is a single line on the root entity. TPH is the default and needs nothing; TPT and TPC opt in with a mapping strategy call:

// EF Core 11: choosing a strategy on the root entity type
modelBuilder.Entity<Blog>().UseTphMappingStrategy(); // default, can be omitted
modelBuilder.Entity<Blog>().UseTptMappingStrategy(); // one table per type
modelBuilder.Entity<Blog>().UseTpcMappingStrategy(); // one table per concrete type

When to pick TPH

TPH is the right answer for the large majority of hierarchies. Choose it when:

The one cost you accept is that a property required on a derived type still maps to a nullable column, because sibling rows leave it empty. If database-enforced non-null on derived properties is a hard requirement, that is the classic reason to leave TPH, and it points at TPT.

When to pick TPC

TPC is the specialist. It matches TPH on cross-type queries closely and pulls ahead in one specific shape:

The price is a denormalized schema and awkward keys. TPC cannot use a plain Identity column, because there is no single table to own the sequence; EF Core 11 defaults to a shared database sequence (NEXT VALUE FOR [BlogSequence]) so keys stay unique across sibling tables. On SQLite, which has no sequences, integer key generation is unavailable for TPC and you fall back to client-side GUIDs. And because a base-type primary key can live in any concrete table, a foreign key that references the base type cannot be enforced by a database constraint at all. If all your writes go through EF Core with navigations, that is usually fine, but it is a real loss of database-level integrity.

When to pick TPT (and why the answer is usually “don’t”)

TPT produces the schema that looks most like your class diagram: one table per type, joined on the key. That aesthetic is the trap. Reach for TPT only when:

Do not pick TPT because it feels cleaner. Every base-type query joins across the whole set of tables, and joins are one of the primary sources of relational performance problems. The numbers back this up, which is the next section.

The benchmark: TPT costs roughly 2x

This is not hand-waving. Microsoft’s own inheritance benchmark sets up a 7-type hierarchy, seeds 5000 rows per type (35000 rows total), and loads every row from the database. The results:

MethodMeanAllocated
TPH149.0 ms40 MB
TPT312.9 ms75 MB
TPC158.2 ms46 MB

TPT is roughly 2.1x slower than TPH and allocates nearly double the memory, because loading the hierarchy joins seven tables together. TPC lands within about 6 percent of TPH on this all-types query, and it would pull ahead of TPH on a single-leaf query where it reads one table and TPH still scans the shared table with a discriminator filter. The methodology matters: this is a base-type query that touches every table, which is TPC’s weakest case and TPT’s, so the gap you see on your workload depends on how often you query across types versus one leaf. The takeaway is stable across runs though: TPT pays a join tax that TPH and TPC do not, and no schema-aesthetics argument buys that back.

Run the benchmark against your own model before you make an irreversible call. Changing an inheritance strategy after you have production data means a schema migration that moves rows between tables, so this is a decision worth measuring once, early.

The gotchas that pick for you

Three constraints can decide the strategy regardless of preference.

The first is database-enforced non-null on a derived property. TPH cannot do it, because the shared column has to be nullable for sibling rows. If you need the database (not just your application) to guarantee that every CardPayment has a Last4, you need that column in its own table, which means TPT or TPC.

The second is key generation on your database. TPC needs sequences for integer keys. On SQL Server that is automatic, but on SQLite you cannot use integer identity keys with TPC at all and must switch to GUIDs. If you are on SQLite and want integer keys, TPC is off the table.

The third is foreign-key integrity to the base type. If other tables reference your base type and you want the database to enforce those references, TPC cannot give you the constraint. TPH and TPT can. This alone rules out TPC for many normalized schemas.

One thing that is the same across all three: you cannot change an entity’s type at runtime. Turning a CardPayment into a BankTransferPayment is a delete plus insert in every strategy, because the discriminator (or the table itself) encodes the type. That is a modeling reality, not a differentiator.

The recommendation, stated plainly

Default to TPH. It is the fastest for the common cross-type query, the simplest to write against, the only strategy with zero key-generation friction, and Microsoft’s recommended default for a wide range of scenarios. Reach for TPC only when your workload is dominated by single-leaf-type queries and a benchmark on your data shows it beating TPH, and accept the denormalized schema, shared-sequence keys, and missing base-type FK constraint that come with it. Use TPT only when an external factor leaves you no choice, and go in knowing you are paying a roughly 2x query tax for a schema that looks tidier.

The mental model is the same one the numbers enforce: one table is fast, many tables joined are slow, and many tables not joined are fast but denormalized. If this decision is part of a broader version bump, the inheritance and mapping changes tend to surface alongside the ones in the EF Core 6 to EF Core 11 migration guide.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back