The Core Problem
Hierarchical data is inherently non-relational. A tree has parent-child relationships, but SQL is built for flat tables. You must choose a representation strategy, and that choice affects every query you write.
Common hierarchies in business applications:
- Organizational structures (CEO → VPs → Managers → Employees)
- Product taxonomies (Category → Subcategory → SKU)
- File systems (Drive → Folders → Subfolders → Files)
- Threaded comments (Post → Comment → Reply → Reply-to-Reply)
- Bill of Materials (Product → Assemblies → Parts → Subparts)
Strategy 1: Adjacency List (Parent-Child Pointers)
The simplest: each row stores a reference to its parent.
CREATE TABLE Organization (
EmployeeID INT PRIMARY KEY,
Name NVARCHAR(100),
ParentEmployeeID INT NULL, -- Foreign key to self
FOREIGN KEY (ParentEmployeeID) REFERENCES Organization(EmployeeID)
);
-- Example data:
-- EmployeeID | Name | ParentEmployeeID
-- 1 | CEO | NULL
-- 2 | VP Sales | 1
-- 3 | VP Eng | 1
-- 4 | Sales Rep | 2
-- 5 | Engineer | 3
Query: Get All Descendants
-- Who reports to VP Sales (EmployeeID 2)?
WITH RECURSIVE OrgChart AS (
-- Base case: start with VP Sales
SELECT EmployeeID, Name, ParentEmployeeID, 0 AS Level
FROM Organization
WHERE EmployeeID = 2
UNION ALL
-- Recursive case: find all employees reporting to current level
SELECT o.EmployeeID, o.Name, o.ParentEmployeeID, oc.Level + 1
FROM Organization o
INNER JOIN OrgChart oc
ON o.ParentEmployeeID = oc.EmployeeID
)
SELECT EmployeeID, Name, Level
FROM OrgChart
ORDER BY Level, Name;
Output: VP Sales (Level 0), Sales Rep (Level 1).
Strengths
- ✓ Simple schema: one self-referencing foreign key
- ✓ Easy to insert/update: just set parent ID
- ✓ Intuitive for small hierarchies
Weaknesses
- ✗ Queries require recursive CTEs (complex, hard to optimize)
- ✗ "Get all ancestors" or "get entire subtree" becomes expensive
- ✗ No built-in depth limit: infinite recursion risk
- ✗ Performance degrades with deep hierarchies (many joins)
Strategy 2: HierarchyID
SQL Server's native hierarchical data type. It encodes the full path from root to node as a binary value.
CREATE TABLE Organization_HID (
EmployeeID INT PRIMARY KEY,
Name NVARCHAR(100),
HierarchyID HIERARCHYID NOT NULL UNIQUE
);
-- Insert data using GetRoot() and GetDescendant()
INSERT INTO Organization_HID
VALUES
(1, 'CEO', HIERARCHYID::GetRoot()),
(2, 'VP Sales', HIERARCHYID::GetRoot().GetDescendant(NULL, NULL)),
(3, 'VP Eng', HIERARCHYID::GetRoot().GetDescendant(
(SELECT HierarchyID FROM Organization_HID WHERE EmployeeID = 2),
NULL
)),
(4, 'Sales Rep', (SELECT HierarchyID FROM Organization_HID WHERE EmployeeID = 2)
.GetDescendant(NULL, NULL));
-- Simpler insert pattern:
INSERT INTO Organization_HID (EmployeeID, Name, HierarchyID)
VALUES (5, 'Engineer', '/1/2/'); -- Encoded path: 1st child of root (CEO), 2nd child of VP Eng
Query: Get All Descendants (Much Simpler)
-- Who reports to VP Sales?
DECLARE @VPSalesHID HIERARCHYID =
(SELECT HierarchyID FROM Organization_HID WHERE EmployeeID = 2);
SELECT EmployeeID, Name, HierarchyID
FROM Organization_HID
WHERE HierarchyID.IsDescendantOf(@VPSalesHID) = 1;
No recursion needed! HierarchyID's IsDescendantOf() method checks if one node is within another's subtree in a single indexed lookup.
Query: Get All Ancestors
-- Who are all the managers above Sales Rep (EmployeeID 4)?
DECLARE @SalesRepHID HIERARCHYID =
(SELECT HierarchyID FROM Organization_HID WHERE EmployeeID = 4);
SELECT EmployeeID, Name, HierarchyID
FROM Organization_HID
WHERE @SalesRepHID.IsDescendantOf(HierarchyID) = 1;
Strengths
- ✓ Blazingly fast ancestor/descendant queries (indexed, no recursion)
- ✓ No recursive CTE overhead
- ✓ Built-in path support (navigate by encoded path)
- ✓ Depth calculation trivial (
GetLevel())
Weaknesses
- ✗ Opaque binary format: hard to debug or inspect
- ✗ Inserting rows requires careful management of path encoding
- ✗ Moving subtrees is complex (must recalculate all paths)
- ✗ SQL Server–specific (not portable)
- ✗ Reordering siblings requires full path recalculation
Strategy 3: Nested Set (Preorder Traversal)
Assign each node a LeftValue and RightValue representing entry/exit points in a depth-first traversal.
CREATE TABLE Organization_NestedSet (
EmployeeID INT PRIMARY KEY,
Name NVARCHAR(100),
LeftValue INT NOT NULL,
RightValue INT NOT NULL,
CHECK (LeftValue < RightValue)
);
-- Example data (visualized as tree traversal):
-- EmployeeID | Name | LeftValue | RightValue
-- 1 | CEO | 1 | 10
-- 2 | VP Sales | 2 | 5
-- 4 | Sales Rep | 3 | 4
-- 3 | VP Eng | 6 | 9
-- 5 | Engineer | 7 | 8
Visualization:
CEO (1---10)
/ \
VP Sales VP Eng
(2---5) (6---9)
/ /
Sales Rep Engineer
(3---4) (7---8)
Query: Get All Descendants
-- Who works under VP Sales?
DECLARE @VPSalesLeft INT = (SELECT LeftValue FROM Organization_NestedSet WHERE Name = 'VP Sales');
DECLARE @VPSalesRight INT = (SELECT RightValue FROM Organization_NestedSet WHERE Name = 'VP Sales');
SELECT EmployeeID, Name
FROM Organization_NestedSet
WHERE LeftValue > @VPSalesLeft AND RightValue < @VPSalesRight;
Strengths
- ✓ Excellent for "get all descendants" (single range query, no recursion)
- ✓ Fast queries for large hierarchies (index on LeftValue/RightValue)
- ✓ Depth calculation possible (count ancestors within range)
Weaknesses
- ✗ Inserting/moving nodes is expensive: must recalculate all LeftValue/RightValue pairs to the right
- ✗ Concurrent inserts cause contention (locking/deadlock)
- ✗ Complex to implement correctly
- ✗ "Get all ancestors" is harder than descendants
Avoid if: You're doing frequent inserts/reorganizations.
Strategy 4: Materialized Path
Store the full path from root to node as a string.
CREATE TABLE Organization_Path (
EmployeeID INT PRIMARY KEY,
Name NVARCHAR(100),
Path NVARCHAR(MAX) NOT NULL -- e.g., '/1/2/4/' for Sales Rep under VP Sales under CEO
);
-- Example data:
-- EmployeeID | Name | Path
-- 1 | CEO | /1/
-- 2 | VP Sales | /1/2/
-- 4 | Sales Rep | /1/2/4/
-- 3 | VP Eng | /1/3/
-- 5 | Engineer | /1/3/5/
Query: Get All Descendants
-- Who reports to VP Sales?
SELECT EmployeeID, Name
FROM Organization_Path
WHERE Path LIKE '/1/2/%';
Strengths
- ✓ Readable: path is human-interpretable
- ✓ Fast descendant queries (LIKE or string prefix match)
- ✓ Easy to debug ("what's the full path of this node?")
- ✓ Can be indexed (filtered index on path prefix)
Weaknesses
- ✗ Path grows with depth (deep trees = long strings)
- ✗ LIKE queries can be slow if path is long or many nodes match
- ✗ Moving subtrees requires updating all descendant paths
- ✗ No depth limit built-in (but path length provides practical limit)
Comparison Table
| Strategy | Get Descendants | Get Ancestors | Insert Cost | Move Node | Readability |
|---|---|---|---|---|---|
| Adjacency List | Slow (recursive) | Slow (recursive) | Fast (add parent ID) | Fast (update parent) | Excellent |
| HierarchyID | Fast (indexed) | Fast (indexed) | Medium (encode path) | Slow (recalc all) | Poor (binary) |
| Nested Set | Fast (range) | Medium (range + count) | Slow (recalc all right) | Slow (recalc all) | Medium |
| Materialized Path | Fast (LIKE) | Medium (parse path) | Fast (concat string) | Medium (update path) | Excellent |
Real-World Decision Tree
Organizational Chart (Frequent Moves, Queries Mostly Depth-2)
→ Use Adjacency List. Rarely more than 3 levels deep. Moves are common. Recursion is acceptable.
Product Taxonomy (Read-Heavy, Deep, Rarely Changes)
→ Use HierarchyID or Nested Set. Thousands of nodes. Queries must be fast. Changes are quarterly.
File System or Comments (Mixed Read/Write, Moderate Depth)
→ Use Materialized Path. Readable, fast queries, acceptable insert cost. String paths are intuitive for debugging.
Bill of Materials or Manufacturing (Deep, Complex Assembly, Frequent Changes)
→ Use Adjacency List + Indexed Views. Cache computed subtree sizes using materialized views for fast "count items" queries.
Performance Pitfalls
Pitfall 1: Unbounded Recursion Depth
-- DANGEROUS: No limit on recursion depth
WITH RECURSIVE OrgChart AS (
SELECT EmployeeID, Name, ParentEmployeeID, 0 AS Level
FROM Organization
WHERE EmployeeID = 1
UNION ALL
SELECT o.EmployeeID, o.Name, o.ParentEmployeeID, oc.Level + 1
FROM Organization o
INNER JOIN OrgChart oc
ON o.ParentEmployeeID = oc.EmployeeID
-- Missing: WHERE oc.Level < 100 ← Infinite recursion if cycle exists!
)
SELECT * FROM OrgChart;
OPTION (MAXRECURSION 100)
Pitfall 2: Not Indexing HierarchyID
-- Create index for descendant queries
CREATE NONCLUSTERED INDEX idx_HierarchyID_IsDescendantOf
ON Organization_HID (HierarchyID);
Pitfall 3: Moving Nodes in Nested Sets Without Recalculation
If you change LeftValue/RightValue manually without recalculating the entire tree, your queries return wrong results silently. Use stored procedures for all modifications.
Hybrid Approach: The Best of Both Worlds
For large, mixed-access hierarchies, combine strategies:
CREATE TABLE Organization_Hybrid (
EmployeeID INT PRIMARY KEY,
Name NVARCHAR(100),
ParentEmployeeID INT NULL, -- Adjacency list (for inserts)
HierarchyID HIERARCHYID NOT NULL, -- HierarchyID (for queries)
Path NVARCHAR(MAX) NOT NULL, -- Materialized path (for debugging)
FOREIGN KEY (ParentEmployeeID) REFERENCES Organization_Hybrid(EmployeeID)
);
-- Create indexes on both HierarchyID and Path
CREATE NONCLUSTERED INDEX idx_HierarchyID ON Organization_Hybrid (HierarchyID);
CREATE NONCLUSTERED INDEX idx_Path ON Organization_Hybrid (Path);
-- On insert, calculate all three columns programmatically
Trade-off: Extra storage (three columns instead of one), but queries are blazingly fast and the schema is debuggable.
The Bottom Line
There's no universally "best" hierarchy strategy: only the right choice for your access patterns.
- Start with adjacency list for simplicity (unless you know it's read-heavy)
- Move to HierarchyID if queries become bottlenecks
- Use materialized path if you need readability and moderate speed
- Reserve nested sets for read-heavy, rarely-changing hierarchies
- Consider hybrid approaches for large, complex structures with mixed access patterns
Profile your queries. Benchmark with realistic data. Don't over-engineer until you have proof the hierarchy is a bottleneck.