Hierarchical Queries
A hierarchical query walks parent-child relationships within a single table — the classic example is an organisation chart, where each employee row references a manager_id that points to another row in the same table.
Oracle's CONNECT BY syntax is purpose-built for this and predates standard SQL's recursive CTEs by decades. Even today, on Oracle, CONNECT BY is usually more concise and faster than the equivalent recursive WITH clause.
The Tree You're Querying
Consider this slice of the HR employees table:
| employee_id | first_name | manager_id |
|---|---|---|
| 100 | Steven | NULL |
| 101 | Neena | 100 |
| 102 | Lex | 100 |
| 108 | Nancy | 101 |
| 109 | Daniel | 108 |
This forms a tree:
Steven (100)
├── Neena (101)
│ └── Nancy (108)
│ └── Daniel (109)
└── Lex (102)
A flat SELECT * FROM employees shows rows in no particular order. A hierarchical query lets you walk the tree from a starting point down (or up) through every level.
The CONNECT BY Anatomy
SELECT employee_id, first_name, manager_id, LEVEL
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id;
Result:
| employee_id | first_name | manager_id | LEVEL |
|---|---|---|---|
| 100 | Steven | NULL | 1 |
| 101 | Neena | 100 | 2 |
| 108 | Nancy | 101 | 3 |
| 109 | Daniel | 108 | 4 |
| 102 | Lex | 100 | 2 |
Three clauses are doing the work:
START WITH— the anchor. Identifies the root row(s) where the walk begins.CONNECT BY— the recursive step. Tells Oracle how a child row relates to its parent.PRIOR— points to the parent row when evaluating the next iteration.
Reading PRIOR
PRIOR column means "the value of column from the previous (parent) row in the walk".
CONNECT BY PRIOR employee_id = manager_id
-- └─ parent's emp_id └─ child's manager_id
Read it as: "a row is a child of the prior row when the child's manager_id equals the parent's employee_id."
Reverse the equation to walk upward (from a leaf to the root):
START WITH employee_id = 109 -- start at Daniel
CONNECT BY employee_id = PRIOR manager_id; -- walk up to manager
LEVEL Pseudocolumn
LEVEL is automatically available in every hierarchical query. It tells you the depth of the current row, starting at 1 for the START WITH rows.
SELECT LPAD(' ', 2 * (LEVEL - 1)) || first_name AS org_chart,
LEVEL
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id;
Result:
| org_chart | LEVEL |
|---|---|
| Steven | 1 |
| Neena | 2 |
| Nancy | 3 |
| Daniel | 4 |
| Lex | 2 |
LPAD(' ', 2*(LEVEL-1)) adds 2 spaces of indentation per level — a quick way to visualise a hierarchy in a flat result.
ORDER SIBLINGS BY
Plain ORDER BY would sort the whole result alphabetically and destroy the tree structure. ORDER SIBLINGS BY orders children within their parent group while keeping the hierarchical ordering intact.
SELECT LPAD(' ', 2 * (LEVEL - 1)) || first_name AS org_chart
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id
ORDER SIBLINGS BY first_name;
Now siblings appear alphabetically (Lex before Neena under Steven), but the parent-before-child structure is preserved.
CONNECT_BY_ROOT
The CONNECT_BY_ROOT operator returns a column value from the root of the current branch. Useful when you want each row to carry the name of the top-level manager.
SELECT first_name,
LEVEL,
CONNECT_BY_ROOT first_name AS top_manager
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id;
Result:
| first_name | LEVEL | top_manager |
|---|---|---|
| Steven | 1 | Steven |
| Neena | 2 | Steven |
| Nancy | 3 | Steven |
| Daniel | 4 | Steven |
| Lex | 2 | Steven |
In a multi-tree dataset (multiple root rows), each row gets the name of its own tree's root.
SYS_CONNECT_BY_PATH
SYS_CONNECT_BY_PATH(column, separator) returns the full path from the root to the current row, with values joined by the separator.
SELECT first_name,
LEVEL,
SYS_CONNECT_BY_PATH(first_name, ' / ') AS path
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id;
Result:
| first_name | LEVEL | path |
|---|---|---|
| Steven | 1 | / Steven |
| Neena | 2 | / Steven / Neena |
| Nancy | 3 | / Steven / Neena / Nancy |
| Daniel | 4 | / Steven / Neena / Nancy / Daniel |
| Lex | 2 | / Steven / Lex |
The separator must not appear in the column data, or the path is ambiguous.
CONNECT_BY_ISLEAF
CONNECT_BY_ISLEAF is 1 if the current row has no children in the walk and 0 otherwise.
SELECT first_name,
CONNECT_BY_ISLEAF AS is_leaf
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id;
Result:
| first_name | is_leaf |
|---|---|
| Steven | 0 |
| Neena | 0 |
| Nancy | 0 |
| Daniel | 1 |
| Lex | 1 |
Combined with a filter, you can find all "leaf" employees (the ones with no direct reports):
SELECT first_name
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id
AND CONNECT_BY_ISLEAF = 1;
Wait — putting CONNECT_BY_ISLEAF = 1 in CONNECT BY is wrong; it would stop the walk. Put it in WHERE:
SELECT first_name
FROM employees
WHERE CONNECT_BY_ISLEAF = 1
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id;
Cycles and NOCYCLE
If the data accidentally contains a loop — A's manager is B, B's manager is C, C's manager is A — Oracle detects the cycle and aborts:
ORA-01436: CONNECT BY loop in user data
The NOCYCLE keyword tells Oracle to stop walking when a cycle is detected but not raise an error. CONNECT_BY_ISCYCLE then flags which rows participate in a cycle:
SELECT first_name,
CONNECT_BY_ISCYCLE AS is_cycle_root
FROM employees
START WITH manager_id IS NULL
CONNECT BY NOCYCLE PRIOR employee_id = manager_id;
CONNECT_BY_ISCYCLE = 1 marks the row where the walk hit a cycle and stopped.
NOCYCLE silences the error but doesn't fix the underlying data. Identify the cycle, decide whether the cycle row is wrong, and fix it.
Walking Up vs Walking Down
The direction of the walk depends on which side of the equation has PRIOR.
-- Walk DOWN from the CEO to everyone they manage:
START WITH employee_id = 100
CONNECT BY PRIOR employee_id = manager_id;
-- Walk UP from Daniel to the CEO:
START WITH employee_id = 109
CONNECT BY employee_id = PRIOR manager_id;
Switching PRIOR from the left to the right of the equation flips the walk direction. Always double-check which side it's on when reading or debugging hierarchical SQL.
Filtering With WHERE
WHERE is applied after the hierarchy is built; it filters rows out of the final result without breaking the walk.
-- All employees under Steven, with salary > 8000:
SELECT first_name, salary, LEVEL
FROM employees
WHERE salary > 8000
START WITH employee_id = 100
CONNECT BY PRIOR employee_id = manager_id;
Compare with adding the condition to CONNECT BY, which prunes the walk:
-- Walk stops at any branch where salary <= 8000:
SELECT first_name, salary, LEVEL
FROM employees
START WITH employee_id = 100
CONNECT BY PRIOR employee_id = manager_id
AND salary > 8000;
The two queries give different results — the second skips entire subtrees rooted at a low-salary manager. Use the first form for "filter the displayed list", the second form for "don't walk past these nodes".
CONNECT BY vs Recursive CTE
The same tree walk can be written with a recursive WITH clause:
WITH org (employee_id, first_name, manager_id, lvl) AS (
SELECT employee_id, first_name, manager_id, 1
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.employee_id, e.first_name, e.manager_id, o.lvl + 1
FROM employees e
JOIN org o ON e.manager_id = o.employee_id
)
SELECT * FROM org;
| Feature | CONNECT BY |
Recursive CTE |
|---|---|---|
| Conciseness | Very compact | More verbose |
| Standard SQL | Oracle-specific | ANSI standard |
LEVEL |
Built-in pseudocolumn | Must compute manually |
CONNECT_BY_ROOT |
Built-in | Must propagate through the recursion |
SYS_CONNECT_BY_PATH |
Built-in | Build with string concat manually |
ORDER SIBLINGS BY |
Built-in | Difficult to replicate |
| Cycle detection | NOCYCLE |
Manual |
| Cross-database portability | Oracle only | Works on PostgreSQL, SQL Server, etc. |
| Multiple recursion paths | Cumbersome | Cleaner |
On Oracle: prefer CONNECT BY for tree walks. Cross-database code: use recursive CTEs.
Real-world Examples
Generate a Number Series
A surprisingly common use of CONNECT BY is to fabricate a series of numbers — useful for densifying a sparse dataset or building a calendar:
SELECT LEVEL AS n
FROM dual
CONNECT BY LEVEL <= 10;
Result:
| n |
|---|
| 1 |
| 2 |
| 3 |
| 4 |
| 5 |
| 6 |
| 7 |
| 8 |
| 9 |
| 10 |
No START WITH needed here — dual has exactly one row, so the walk runs LEVEL <= 10 times.
Generate a Date Series
SELECT DATE '2024-01-01' + (LEVEL - 1) AS day
FROM dual
CONNECT BY LEVEL <= 7;
Result: seven consecutive days starting 2024-01-01.
Find All Subordinates Beneath a Manager
SELECT employee_id, first_name, last_name, LEVEL AS depth
FROM employees
START WITH manager_id = 100
CONNECT BY PRIOR employee_id = manager_id;
Everyone reporting to employee 100, transitively.
Find the Reporting Chain Up From an Employee
SELECT first_name, last_name, LEVEL
FROM employees
START WITH employee_id = 200
CONNECT BY employee_id = PRIOR manager_id;
Employee 200's manager, their manager's manager, and so on up to the root.
Count Indirect Reports
SELECT manager_id, COUNT(*) - 1 AS subordinates
FROM (
SELECT employee_id, manager_id, CONNECT_BY_ROOT manager_id AS root_mgr
FROM employees
CONNECT BY PRIOR employee_id = manager_id
)
GROUP BY manager_id;
For each manager, the number of people in the org tree underneath them (subtract 1 to exclude themselves).
Common Errors
| Error | Cause | Fix |
|---|---|---|
| ORA-01436: CONNECT BY loop in user data | Data has a cycle (A → B → A) | Fix the data; or add NOCYCLE to skip cycled rows |
| ORA-01437: cannot have join with CONNECT BY (older versions) | Some Oracle versions disallow joins with CONNECT BY in the same query | Wrap the hierarchical query in a subquery, then join the outer query |
| Query returns only the START WITH rows | PRIOR is on the wrong side or column names mismatch |
Verify PRIOR parent_col = child_col is correctly written |
| Query returns too many rows | Two siblings have the same parent_id and PRIOR matches the wrong column | Make sure your PRIOR clause uniquely identifies parent-child |
| ORA-00904: invalid identifier | Used LEVEL outside of a CONNECT BY query |
LEVEL is only available in CONNECT BY queries |
| Filtering condition doesn't seem to work | Put the filter in CONNECT BY (which prunes the walk) when it belonged in WHERE |
Use WHERE for "filter displayed rows", CONNECT BY for "stop walking past these nodes" |
| Result loses tree order with ORDER BY | Used regular ORDER BY |
Use ORDER SIBLINGS BY to preserve hierarchy |
Interview Corner
▶ Show answer
START WITH— the anchor predicate that selects root rows. The walk begins from rows satisfying this condition. If omitted, every row is treated as a potential root, which produces a large cross-product result.CONNECT BY— the recursive step. Tells Oracle how to identify a child row given the parent row.PRIOR— operator inside the CONNECT BY predicate that refers to the parent row's value. Always appears on one side of an=.
A clean way to remember:
START WITH <which rows are at the top?>
CONNECT BY PRIOR <parent_col> = <child_col>
^── parent's value ^── child's value
If the equation is reversed (PRIOR on the child side), the walk goes upward instead of downward.
▶ Show answer
Three reasons:
- Built-in operators —
LEVEL,CONNECT_BY_ROOT,CONNECT_BY_ISLEAF,SYS_CONNECT_BY_PATH,ORDER SIBLINGS BYall come for free with CONNECT BY. The recursive CTE equivalent requires you to propagate these manually through the recursion, which is verbose and error-prone. - Performance — Oracle's CONNECT BY has dedicated optimisations not always available to recursive CTEs.
- Conciseness — A 4-line CONNECT BY can replace a 10-line recursive WITH clause.
When to prefer recursive CTE:
- Cross-database code (Oracle, PostgreSQL, SQL Server all support recursive WITH)
- Multiple recursive paths in the same query (CTEs compose better)
- You're already using CTEs heavily and want one consistent style
▶ Show answer
Use SYS_CONNECT_BY_PATH to build the chain, then strip the leading separator:
SELECT employee_id,
first_name,
LTRIM(SYS_CONNECT_BY_PATH(first_name, ' → '), ' → ') AS chain
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id;
Result excerpt:
| employee_id | first_name | chain |
|---|---|---|
| 100 | Steven | Steven |
| 101 | Neena | Steven → Neena |
| 108 | Nancy | Steven → Neena → Nancy |
| 109 | Daniel | Steven → Neena → Nancy → Daniel |
| 102 | Lex | Steven → Lex |
Each row carries the full chain from the root down to itself. The LTRIM removes the leading " → " that SYS_CONNECT_BY_PATH adds before the first value.
If your separator contains characters that might appear in the data (like / in a name), pick a separator that can't collide — || or a CHR(1) character work well.
Related Topics
- CTEs & Procedures — recursive CTEs are the cross-database alternative to CONNECT BY
- Subqueries — wrap a CONNECT BY result in an inline view for further processing
- Window Functions — compute totals per branch using
OVER (PARTITION BY CONNECT_BY_ROOT ...) - Aggregate Functions — count subordinates per manager using CONNECT BY + GROUP BY
- Performance — large CONNECT BY queries benefit from indexes on both parent and child columns