powershelldba.de

Hierarchical Data in SQL Server: Recursive CTEs, HierarchyID, and Beyond

Every application has hierarchies: organizational charts, product categories, file systems, comment threads. SQL Server gives you multiple ways to model them—each with different trade-offs in query speed, insert complexity, and code readability.

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:

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

Weaknesses

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

Weaknesses

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

Weaknesses

Nested Sets are best for: Read-heavy hierarchies that rarely change (taxonomies, catalogs, org structures that only shift quarterly).
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

Weaknesses

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;
⚠ Fix: Always add a MAXRECURSION limit or depth check:
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.

Profile your queries. Benchmark with realistic data. Don't over-engineer until you have proof the hierarchy is a bottleneck.

← Back to Blog