Modeling Hierarchical Data in EF Core

Modeling Hierarchical Data in EF Core

6 min read··

databasedotnetef-core

Model hierarchical data in EF Core with an adjacency list: a nullable ParentId foreign key, a Parent navigation, and a Children collection on the same entity. Load the rows of the tree in one query and relationship fixup wires the object graph for you. When the hierarchy outgrows that, push the recursion into the database with a recursive CTE, or store materialized paths with PostgreSQL ltree.

Product categories nest. Org charts nest. Comment threads, folder structures, chart-of-accounts, all of them are trees, and sooner or later you have to persist one in a relational database.

The natural model in EF Core, a self-referencing entity with a ParentId, takes five minutes to write. Then you try to load a subtree, or delete a node, or find every descendant of "Electronics", and discover that the write model and the read patterns are at war.

The adjacency list works well when its self-referencing relationship and read strategy are explicit. The two escalation paths, recursive CTEs and PostgreSQL ltree, become useful when the tree gets large.

The Adjacency List

An adjacency list stores a tree in one table by giving every row a foreign key that points at its parent. Roots have a null parent:

Adjacency list tree where Electronics is a root with a null ParentId, branching into Computers and Phones, and Computers branching further into Laptops and Desktops
public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;

    public int? ParentId { get; set; }
    public Category? Parent { get; set; }
    public List<Category> Children { get; set; } = [];
}

The configuration deserves care, especially the delete behavior:

public class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
    public void Configure(EntityTypeBuilder<Category> builder)
    {
        builder.HasOne(c => c.Parent)
            .WithMany(c => c.Children)
            .HasForeignKey(c => c.ParentId)
            .OnDelete(DeleteBehavior.Restrict);

        builder.HasIndex(c => c.ParentId);
    }
}

Two deliberate choices:

  • DeleteBehavior.Restrict. SQL Server rejects cascade on self-referencing foreign keys outright (cycle detection), and even on PostgreSQL, where it works, cascading a node delete into silently vaporizing a subtree is rarely what the business meant. Make subtree deletion an explicit operation. The broader reasoning is in cascade delete in EF Core.
  • An index on ParentId. Every children lookup filters on it. Skipping this index is the most common hierarchy performance bug I see.

How Do You Load a Whole Tree Without N+1 Queries?

One level is easy:

var roots = await context.Categories
    .Where(c => c.ParentId == null)
    .Include(c => c.Children)
    .ToListAsync();

The trap is trying to go deeper with chained includes (.Include(c => c.Children).ThenInclude(c => c.Children) and so on). That hardcodes a maximum depth, and each level multiplies the join. The recursive-lazy-loading variant is worse: walking Children with lazy loading fires one query per node, the classic shape from the N+1 query problem.

For hierarchies of reasonable size (navigation menus, category trees, org units, anything up to a few thousand rows), the right move is almost embarrassingly simple: load the whole set in one query and let relationship fixup build the tree.

var all = await context.Categories.ToListAsync();

var roots = all.Where(c => c.ParentId is null).ToList();

When EF Core materializes the rows, identity resolution connects every Parent and Children navigation automatically. One SELECT, and roots is a fully wired object graph you can recurse in memory. This works with tracking queries, and with AsNoTrackingWithIdentityResolution if you do not need tracking. Plain AsNoTracking skips identity resolution, so the rows materialize as disconnected objects and the navigations stay unwired.

Scope the query if the table holds many independent trees:

var all = await context.Categories
    .Where(c => c.TreeId == treeId)
    .ToListAsync();

Recursive CTEs: Push the Recursion into the Database

When the hierarchy has hundreds of thousands of rows and you need one subtree, loading everything stops being cute. Relational databases walk hierarchies natively with recursive CTEs, and EF Core consumes them cleanly through SqlQuery or FromSqlRaw:

var subtree = await context.Database
    .SqlQuery<CategoryRow>($"""
        WITH RECURSIVE subtree AS (
            SELECT "Id", "Name", "ParentId"
            FROM "Categories"
            WHERE "Id" = {rootId}

            UNION ALL

            SELECT c."Id", c."Name", c."ParentId"
            FROM "Categories" c
            JOIN subtree s ON c."ParentId" = s."Id"
        )
        SELECT "Id", "Name", "ParentId" FROM subtree
        """)
    .ToListAsync();

(That is PostgreSQL syntax; SQL Server drops the RECURSIVE keyword and brackets the identifiers. One SQL Server catch: SqlQuery composes over your SQL as a subquery, and T-SQL does not allow a CTE inside one, so use FromSql on a mapped entity there instead.)

The same pattern answers the other classic questions: the ancestor chain of a node (flip the join direction), the depth of each node (carry a level + 1 column through the recursion), and "does moving node X under node Y create a cycle" (check whether Y appears in X's descendants).

This is one of the places where dropping to SQL is not a failure of the ORM, it is using each tool for what it is good at. I covered the mechanics and parameterization safety in EF Core raw SQL queries.

PostgreSQL ltree: Materialized Paths with an Index

If your workload is read-heavy on subtrees, PostgreSQL's ltree extension changes the data structure itself. Each node stores its full path as a chain of labels (electronics.computers.laptops), and a GiST index makes subtree and ancestor queries indexed operations instead of recursion.

Npgsql maps the LTree type directly:

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public LTree Path { get; set; }
}

// in OnModelCreating
modelBuilder.HasPostgresExtension("ltree");

modelBuilder.Entity<Category>()
    .HasIndex(c => c.Path)
    .HasMethod("gist");

Subtree and ancestor queries become one-liners that translate to indexed operators:

// all descendants of electronics.computers
var descendants = await context.Categories
    .Where(c => c.Path.IsDescendantOf(new LTree("electronics.computers")))
    .ToListAsync();

// ancestors of a node
var ancestors = await context.Categories
    .Where(c => new LTree("electronics.computers.laptops").IsDescendantOf(c.Path))
    .ToListAsync();

The cost shows up on writes: moving a subtree means rewriting the Path of every descendant, and the application owns path maintenance (nothing enforces that electronics.computers exists just because a path mentions it). Many teams run ltree alongside a ParentId, treating the path as a derived, indexed acceleration structure. If you are Postgres-curious about features like this, PostgreSQL vs SQL Server for .NET developers covers more of what you gain.

SQL Server's counterpart is hierarchyid, supported in EF Core 8+ via Microsoft.EntityFrameworkCore.SqlServer.HierarchyId, with similar tradeoffs.

Choosing a Hierarchy Strategy

  • Small tree, read whole or nearly whole (menus, categories, org units under ~5k rows): adjacency list, load-all plus fixup. Simplest code, one query, done.
  • Large tree, occasional subtree or ancestor queries: adjacency list plus recursive CTEs for the heavy questions.
  • Large tree, subtree queries on the hot path, rare moves: ltree (or hierarchyid) with a GiST index, likely alongside the adjacency columns.
  • Comment threads and event-like data that only append: adjacency list; threads are shallow and append-only, recursion rarely hurts.

Summary

The adjacency list is the right default: it is normalized, enforces referential integrity, and EF Core's relationship fixup gives you an underrated superpower, load the rows in one query and get a fully connected tree for free. Configure Restrict on the self-reference, index ParentId, and never chain ThenInclude to fake recursion.

When the tree outgrows load-everything, do not fight LINQ into recursion it cannot express. Recursive CTEs answer subtree, ancestor, and depth questions inside the database, and Postgres ltree turns subtree reads into indexed lookups when they dominate your workload.

Pick the structure by the queries you run, not by the shape of the data. Trees are all shaped the same; workloads are not.

Frequently Asked Questions

How do I model a self-referencing entity in EF Core?

Give the entity a nullable ParentId foreign key, a Parent navigation, and a Children collection, then configure them with HasOne(Parent).WithMany(Children).HasForeignKey(ParentId). Roots have a null ParentId.

How do I load an entire tree in EF Core without N+1 queries?

Load all rows of the hierarchy (or the relevant subtree scope) in a single query with tracking or identity resolution enabled. EF Core relationship fixup automatically connects parents and children in memory, so you can walk the tree without extra queries.

Why should I avoid cascade delete on self-referencing relationships?

SQL Server refuses cascade delete on self-referencing foreign keys because of cycle detection, so migrations fail or you must use restrict. Even where allowed, deleting a node silently deleting an entire subtree is rarely the behavior you want.

What is a recursive CTE and when do I need it?

A recursive common table expression is SQL that walks a hierarchy inside the database, following parent-child links until no more rows match. Use it when you need a subtree, ancestor chain, or depth calculation on a large hierarchy without loading the whole table.

When is PostgreSQL ltree better than an adjacency list?

ltree stores the full path of each node as a label chain and indexes it with GiST, so subtree and ancestor queries become simple indexed comparisons instead of recursion. It shines for deep hierarchies with frequent subtree reads and infrequent moves.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.