Inheritance with EF Core 5 and migrations
Published: 2020-11-28
Entity Framework Core supports mapping .NET inheritance hierarchies to database tables.
By default, EF Core maps an entire inheritance hierarchy to a single database table. This table contains columns for properties declared in the base type as well as properties introduced by derived types. This mapping strategy is called Table-per-hierarchy (TPH).
Starting with Entity Framework Core 5, we also have another mapping strategy called Table-per-type (TPT), where each .NET type in the inheritance hierarchy is mapped to its own database table.
In this article, I’ll walk through a practical scenario and demonstrate how both TPH and TPT work with EF Core 5, including how to configure code-first migrations for each approach.
Scenario
Consider a system that allows an organization to manage document templates that its employees can use to generate policy documents.
The organization operates in multiple regions, and each region is further divided into countries.
The organization can define policy templates at the organization level. Individual regions can either use organization-level templates or define their own. Similarly, countries can use templates defined at the organization or region level, or define their own templates.
In short, policy templates can be defined at three different levels:
- Organization: Templates applicable across the entire organization.
- Region: Templates applicable to a particular region.
- Country: Templates applicable to a particular country.
This gives us a natural inheritance hierarchy to model using Entity Framework Core.
Let’s get into the technical implementation.
Entities
I use the term entities to refer to the .NET types that represent the data we want to persist in the database.
Instead of classes, I’ll use record types, a feature introduced in C# 9 and available with .NET 5. Record types provide a convenient way to define data-oriented types, with built-in value-based equality semantics.
For more details, refer to Microsoft’s documentation on record types.
PolicyTemplateHierarchy
This enum represents the level at which a policy template is defined.
public enum PolicyTemplateHierarchy
{
Unknown = 0,
Organization = 1,
Region = 2,
Country = 3
}
OrganizationPolicyTemplate
OrganizationPolicyTemplate is the base record type. It represents organization-level policy templates and contains the properties common to all policy templates.
public record OrganizationPolicyTemplate
{
protected OrganizationPolicyTemplate(
PolicyTemplateHierarchy policyTemplateHierarchy,
string displayName,
string location,
DateTime createdDate,
string createdUser)
{
this.PolicyTemplateHierarchy = policyTemplateHierarchy;
this.DisplayName = (string.IsNullOrWhiteSpace(displayName) ? null : displayName)
?? throw new ArgumentNullException(nameof(displayName));
this.Location = (string.IsNullOrWhiteSpace(location) ? null : location)
?? throw new ArgumentNullException(nameof(location));
this.CreatedDate = createdDate;
this.CreatedUser = (string.IsNullOrWhiteSpace(createdUser) ? null : createdUser)
?? throw new ArgumentNullException(nameof(createdUser));
}
public OrganizationPolicyTemplate(
string displayName,
string location,
DateTime createdDate,
string createdUser)
: this(
PolicyTemplateHierarchy.Organization,
displayName,
location,
createdDate,
createdUser)
{
}
public int Id { get; init; }
public PolicyTemplateHierarchy PolicyTemplateHierarchy { get; init; }
public string DisplayName { get; init; }
public string Location { get; init; }
public DateTime CreatedDate { get; init; }
public string CreatedUser { get; init; }
}
RegionPolicyTemplate
RegionPolicyTemplate represents region-level policy templates.
For simplicity, the only additional property introduced at this level is RegionId.
Notice that this record inherits from OrganizationPolicyTemplate.
public record RegionPolicyTemplate : OrganizationPolicyTemplate
{
protected RegionPolicyTemplate(
PolicyTemplateHierarchy policyTemplateHierarchy,
string displayName,
string location,
DateTime createdDate,
string createdUser,
int regionId)
: base(
policyTemplateHierarchy,
displayName,
location,
createdDate,
createdUser)
{
this.RegionId = regionId;
}
public RegionPolicyTemplate(
string displayName,
string location,
DateTime createdDate,
string createdUser,
int regionId)
: this(
PolicyTemplateHierarchy.Region,
displayName,
location,
createdDate,
createdUser,
regionId)
{
}
public int RegionId { get; init; }
}
CountryPolicyTemplate
CountryPolicyTemplate represents country-level policy templates.
Country templates introduce one additional property, CountryId.
This record inherits from RegionPolicyTemplate, which in turn inherits from OrganizationPolicyTemplate.
public record CountryPolicyTemplate : RegionPolicyTemplate
{
public CountryPolicyTemplate(
string displayName,
string location,
DateTime createdDate,
string createdUser,
int regionId,
int countryId)
: base(
PolicyTemplateHierarchy.Country,
displayName,
location,
createdDate,
createdUser,
regionId)
{
this.CountryId = countryId;
}
public int CountryId { get; init; }
}
We now have the following inheritance hierarchy:
OrganizationPolicyTemplate
|
v
RegionPolicyTemplate
|
v
CountryPolicyTemplate
Next, let’s see how we can configure Entity Framework Core to generate database migrations for both TPH and TPT.
Table-per-hierarchy (TPH)
TPH is the default inheritance mapping strategy in Entity Framework Core.
With TPH, the entire inheritance hierarchy is stored in a single database table. In our example, let’s call this table PolicyTemplate.
We’ll use a discriminator column to identify which .NET type each row represents.
Let’s create a DbContext and configure the TPH mapping.
public class PolicyTemplateDbContext : DbContext
{
public PolicyTemplateDbContext(
DbContextOptions<PolicyTemplateDbContext> dbContextOptions)
: base(dbContextOptions)
{
}
public DbSet<OrganizationPolicyTemplate> OrganizationPolicyTemplates { get; set; }
public DbSet<RegionPolicyTemplate> RegionPolicyTemplates { get; set; }
public DbSet<CountryPolicyTemplate> CountryPolicyTemplates { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<OrganizationPolicyTemplate>()
.ToTable("PolicyTemplate")
.HasDiscriminator(x => x.PolicyTemplateHierarchy)
.HasValue<OrganizationPolicyTemplate>(PolicyTemplateHierarchy.Organization)
.HasValue<RegionPolicyTemplate>(PolicyTemplateHierarchy.Region)
.HasValue<CountryPolicyTemplate>(PolicyTemplateHierarchy.Country);
}
}
Let’s understand the configuration:
- ToTable(“PolicyTemplate”): Configures the entire inheritance hierarchy to use a single table named
PolicyTemplate. - HasDiscriminator(): Configures the discriminator property. EF Core uses this property to identify the .NET type represented by each database row.
- HasValue(): Specifies the discriminator value associated with each .NET type in the inheritance hierarchy.
In our example, the PolicyTemplateHierarchy property acts as the discriminator column.
Now let’s generate the database migration.
Assuming the project containing this DbContext is configured as the EF Core migrations startup project, run:
dotnet ef migrations add CreatePolicyTemplateTPH
dotnet ef database update
The first command generates a migration based on the configured entity model. The second applies the migration to the database.
Let’s examine the resulting database table.

As you can see, all three entity types are mapped to a single table.
The properties inherited from OrganizationPolicyTemplate are non-nullable because they are defined as required properties in the base entity.
On the other hand, RegionId and CountryId are nullable database columns.
Why?
A row representing an organization-level template doesn’t have a RegionId or CountryId. Similarly, a region-level template has a RegionId, but no CountryId.
Therefore:
- Organization-level records have
NULLvalues for bothRegionIdandCountryId. - Region-level records have a
RegionId, butCountryIdisNULL. - Country-level records have values for both
RegionIdandCountryId.
This is one of the characteristics of TPH: properties introduced by derived types generally become nullable columns because those properties don’t apply to every row in the hierarchy.
Table-per-type (TPT)
TPT mapping was introduced in Entity Framework Core 5.
Unlike TPH, TPT maps each .NET type in the inheritance hierarchy to its own database table.
The base entity gets a table containing all its properties. Each derived entity gets a separate table containing only the properties introduced by that type.
In our example, we will have three tables:
OrganizationPolicyTemplateRegionPolicyTemplateCountryPolicyTemplate
Let’s configure the DbContext for TPT mapping.
public class PolicyTemplateDbContext : DbContext
{
public PolicyTemplateDbContext(
DbContextOptions<PolicyTemplateDbContext> dbContextOptions)
: base(dbContextOptions)
{
}
public DbSet<OrganizationPolicyTemplate> OrganizationPolicyTemplates { get; set; }
public DbSet<RegionPolicyTemplate> RegionPolicyTemplates { get; set; }
public DbSet<CountryPolicyTemplate> CountryPolicyTemplates { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<OrganizationPolicyTemplate>()
.ToTable("OrganizationPolicyTemplate");
modelBuilder.Entity<RegionPolicyTemplate>()
.ToTable("RegionPolicyTemplate");
modelBuilder.Entity<CountryPolicyTemplate>()
.ToTable("CountryPolicyTemplate");
}
}
Notice how simple the configuration is.
By calling ToTable() for each entity type, we tell EF Core that each type in the inheritance hierarchy should be mapped to its own table.
The mappings are:
OrganizationPolicyTemplatemaps toOrganizationPolicyTemplate.RegionPolicyTemplatemaps toRegionPolicyTemplate.CountryPolicyTemplatemaps toCountryPolicyTemplate.
I’ve intentionally kept the .NET type names and database table names the same. You can, of course, choose different table names.
Now let’s generate the migration for the TPT mapping.
dotnet ef migrations add CreatePolicyTemplateTPT
dotnet ef database update
These commands assume that the TPT configuration is the active model configuration in the DbContext.
The resulting database schema in SQL Server Management Studio (SSMS) looks like this:

One important thing to notice is how the tables are related.
The derived tables share their primary keys with their respective base tables. These primary keys also act as foreign keys, connecting the tables in the inheritance hierarchy.
For example:
RegionPolicyTemplate.Idis both its primary key and a foreign key referencingOrganizationPolicyTemplate.Id.CountryPolicyTemplate.Idis both its primary key and a foreign key referencingRegionPolicyTemplate.Id.
This allows EF Core to reconstruct the complete inheritance hierarchy when querying the database.
TPH vs. TPT: Comparing the data
I kept this section until the end so that it would be easier to understand the data after looking at both mapping strategies.

Both approaches represent the same inheritance hierarchy, but the way they store the data is quite different.
With TPH, all the data is stored in a single table. The discriminator column identifies the entity type, and columns belonging to derived types remain NULL when they aren’t applicable.
With TPT, data is distributed across multiple related tables. Each table stores the properties introduced at its respective inheritance level.
Here are a few observations:
1. Database normalization
TPH introduces nullable columns for properties that are specific to derived types. However, this doesn’t automatically mean that TPH violates database normalization rules. Both TPH and TPT are valid inheritance mapping strategies, and normalization depends on the overall data model and its functional dependencies.
2. Extending the inheritance hierarchy
Suppose we want to introduce another hierarchy level, say DepartmentPolicyTemplate, with a new property called DepartmentId.
With TPH, we need to introduce another column in the existing table.
With TPT, we can introduce another table containing the new property and a foreign-key relationship to the parent table.
3. Query performance
TPH generally has an advantage when querying entities across the inheritance hierarchy because all the data is available in one table.
TPT may require joins across multiple tables to reconstruct derived entities. Depending on the query and inheritance depth, this can affect performance.
4. Schema management
Personally, I prefer having separate tables for each entity type. I find it easier to reason about the schema when properties are stored alongside the type that introduces them.
I also like the idea of extending the hierarchy by adding a new table rather than introducing additional nullable columns into an existing table.
However, the right choice depends on your application, query patterns, and database design requirements.
Source code
A working implementation of both TPH and TPT mappings is available in my GitHub repository: