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.
Scenario | Use Mpath | Use 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. 😄
Subscribe to my newsletter
Read articles from Vineet Likhitkar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
