one tree, two paths: my SQL hierarchy dilemma

A few weeks ago, I was working on a project that needed to manage a pretty deep hierarchy of users, something like:

Admin → Operation Lead → DPR Head → DPR → LPR Head → LPR → Volunteers

At first, it seemed like your classic parent_id setup. Nothing fancy. Just link each user to their superior. Done.

But then came the real requirements.

"Can we show all volunteers under a specific DPR head?" "Can we fetch the full tree under any user for access control?" "Can we give the frontend a nested structure to render?" "Can we do all of that in one API call?"

And suddenly, the parent_id column felt… insufficient. I was faced with the classic challenge: how do you represent and query hierarchical data in a relational database, in a way that doesn’t make your queries (or your brain) explode?

That’s when I came across two major patterns that kept showing up in blog posts, open source repos, and discussions with more seasoned backend engineers:

  • 🧬 Materialized Path (commonly shortened to Mpath)

  • 🧗 Recursive SQL using CTEs (Common Table Expressions)

Both approaches try to solve the same problem: letting you traverse up or down a tree of data efficiently. But they go about it very differently and the difference matters a lot depending on what kind of system you're building.

In this blog, I want to walk you through both.

Not just what they are, but how they feel when you're using them. What it's like to query with them, maintain them, migrate with them. The trade-offs I hit, the edge cases I didn’t expect, and what I’d choose now depending on the use case.

So whether you're working on user roles, categories, org trees, or nested locations, this post is for you.

Let’s start by breaking down what each approach looks like, and where they shine (or stumble).

I'll start with explaining Materialized Path, then move to Recursive SQL, and finally compare them.

🧬 Materialized Path (The Shortcut Trail)

Materialized Path (or just “Mpath”) is like writing the entire route to your node on a sticky note and attaching it to every row.

Imagine a user hierarchy like this:

Admin (1)
└── Operation Lead (2)
    └── DPR Head (3)
        └── DPR (4)
            └── LPR Head (5)
                └── LPR (6)
                    └── Volunteer (7)

In Mpath, every row stores its full lineage as a string: Volunteer → '1/2/3/4/5/6/7'

📦 Schema-wise, this means:

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  parent_id INT,
  path TEXT NOT NULL
);

Whenever you insert a new user, you compute their path based on their parent:

-- Insert LPR (id = 6) under LPR Head (id = 5)
INSERT INTO users (name, parent_id, path)
SELECT 'LPR', 5, path || '/' || nextval('users_id_seq')
FROM users WHERE id = 5;

Or you can generate the path in your app logic if your DB setup is a little less flexible.

🔍 Querying with Mpath

This is where it shines.

Need all volunteers under a specific DPR Head?

SELECT * FROM users
WHERE path LIKE '1/2/3/%';

Need to build a full org tree under a specific user? Just filter by path and reconstruct the tree on the frontend.

Want to show breadcrumbs (like “Admin > Operation Lead > DPR Head”)? Just split the path.

It’s simple. It’s fast. It’s indexable. And it works beautifully for read-heavy systems.

The Good, the Bad, and the Path

the good

  • Read queries are blazing fast. Once you throw an index on the path column, it’s like your tree structure runs on nitro.

  • Mentally lightweight. You don’t need recursion or nested joins. If the path starts with 1/2/3/, it’s part of the subtree. That’s it. That’s the logic.

  • Friendly to frontend needs. Whether it’s nested menus, org trees, or breadcrumbs, Mpath hands you everything in one clean pull.

the bad

  • Moving a node is a pain. You can’t just update one row, you have to rewrite the path of that node and every single descendant under it.

  • Referential integrity? Not a thing here. As far as the database is concerned, your path is just a string. There’s no FK magic backing it.

  • Structure enforcement is on you. Want to avoid circular hierarchies? Want to validate path depth? Cool. That’s your problem now.

  • Write-heavy systems will make you regret this. If your tree shifts often, you’re looking at cascading updates every time something moves. It adds up fast.


🧗 Recursive SQL - Climbing the Tree One Join at a Time

Recursive SQL doesn’t store any extra path. It relies on standard parent-child relationships and climbs the tree on the fly using recursive Common Table Expressions (CTEs).

Think of it as asking SQL to "keep joining" until it runs out of branches.

🧱 Schema stays clean:

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  parent_id INT
);

No path column. No string manipulation.

Just plain, normalized data.

🔁 Querying with Recursive CTE

Want to fetch all users under a specific DPR Head (say, id = 3)?

WITH RECURSIVE user_tree AS (
  SELECT * FROM users WHERE id = 3
  UNION ALL
  SELECT u.* FROM users u
  JOIN user_tree ut ON u.parent_id = ut.id
)
SELECT * FROM user_tree;

Boom. That’s all your descendants in one query. SQL climbs the hierarchy level by level, just like your brain does.

What You Gain, What You Give Up

things you gain

  • No duplication ad no need to store extra data like path.

  • Moving nodes is clean, just change the parent_id.

  • Great for write-heavy systems, very less cascade headache.

  • Referential integrity is intact as the classic foreign key setups still work.

things you give up

  • Performance can drop on deep or wide trees.

  • Harder to index as recursive CTEs don’t benefit much from traditional indexes.

  • Debugging can get very tricky as recursive queries can get gnarly fast.

  • Not supported everywhere, works great in PostgreSQL, decent in MySQL 8+, but not all ORMs or databases handle it well.

🤜🤛 Mpath vs Recursive SQL: When to Use What

Now that we’ve walked through both, let’s talk about trade-offs in the real world.

ScenarioUse MpathUse Recursive SQL
Read-heavy tree queries✅ Yes⚠️ Maybe
Write-heavy system⚠️ Painful✅ Yes
Moving nodes frequently❌ Avoid✅ Clean
Need easy breadcrumb or nesting for UI✅ Perfect⚠️ Needs extra processing
Maintaining referential integrity❌ Nope✅ Yes
Simple to implement✅ Yes⚠️ Slightly trickier

🧠 What I’d Do Differently Now

Back then, I went with Mpath. It made sense, the frontend needed to render a tree instantly, we weren’t moving users much, and the data volume wasn’t insane. The path column + a single indexed query handled most of our pain.

But now, if I were working on a system with frequent role changes, dynamic tree manipulation, or deeper business rules, I’d lean toward Recursive SQL.

In fact, in some systems, I’d do both: Use Recursive SQL as the truth, and maintain a materialized path as a read-only field for fast queries and caching.

It’s extra work, but worth it if you need the best of both worlds.

Hierarchical data in SQL is one of those deceptively simple problems. You start off thinking “just add a parent_id”, and then you’re two weeks deep writing custom recursion logic and wondering if NoSQL was the answer all along.

Mpath and Recursive SQL are both battle-tested strategies, each with strengths and drawbacks.

👉 Use Mpath when reads are king, the tree structure is mostly stable, and you want fast frontend delivery.

👉 Use Recursive SQL when your data structure changes often, or when integrity and normalization matter more than raw query speed.

Also, if you’re currently working on a tree-based structure and unsure which path to choose (pun intended), hit me up. I’ll tell you what’ll break first. 😄

0
Subscribe to my newsletter

Read articles from Vineet Likhitkar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Vineet Likhitkar
Vineet Likhitkar