SQL (PostgreSQL, MySQL, SQLite) Module 21 – SQL Server and Oracle side by side
Hierarchies: recursive CTEs and Oracle CONNECT BY
Write one recursive CTE that runs on SQL Server and Oracle, read Oracle CONNECT BY with LEVEL and paths, and stop cycles and depth limits in time.
What you will learn
- Write recursive CTEs that run on both SQL Server and Oracle
- Read and write Oracle CONNECT BY queries with LEVEL, paths and sibling order
- Detect cycles and depth limits before they stop a query
- Generate number and date series in both engines
Before you start
What you’ll learn · Before you start
What you will learn
- Write recursive CTEs that run on both SQL Server and Oracle
- Read and write Oracle CONNECT BY queries with LEVEL, paths and sibling order
- Detect cycles and depth limits before they stop a query
- Generate number and date series in both engines
Before you start
On this page
SQL Server and Oracle both walk a hierarchy with a recursive CTE, written without the RECURSIVE keyword that
PostgreSQL and SQLite accept: WITH org (id, name, lvl) AS (anchor UNION ALL recursive member). Oracle also has an
older syntax of its own for the same walk. In Oracle CONNECT BY queries, START WITH picks the roots,
CONNECT BY PRIOR id = manager_id links each row to its parent, and LEVEL, SYS_CONNECT_BY_PATH,
CONNECT_BY_ROOT and CONNECT_BY_ISLEAF describe each row’s place in the tree. Oracle CONNECT BY is common in older Oracle
code; recursive CTEs are the form to write when a query must run on both engines. The engines then differ in what
stops a query: SQL Server ends a recursion after 100 levels unless told otherwise, Oracle insists on the column list,
and only Oracle has clauses that order the walk and mark loops.
The tree used in this lesson
The company as a tree, with the LEVEL each person gets
Text description of the diagram
Each box is a row of the employees table, with its id, its name and what Oracle's CONNECT BY reports for it. Each line goes from a manager down to a person whose manager_id holds the manager's id.
- Asha (1) has no manager, so START WITH manager_id IS NULL makes her the root, at LEVEL 1.
- Ravi (2) and Fatima (3) report to Asha, at LEVEL 2.
- Kabir (4) and Meera (5) report to Ravi, and Imran (6) reports to Fatima, at LEVEL 3.
- Zoya (7) and Neha (8) report to Kabir, Tenzin (9) reports to Meera and Arjun (10) reports to Imran. They are at LEVEL 4 and have no reports, so CONNECT_BY_ISLEAF is 1 for them and 0 for everyone else.
Ten people, one row each, with manager_id pointing to the person above. Asha has no manager, so she is the root.
LEVEL counts from 1 at the root, and a leaf is a row with no reports.
One walk, written for both engines
-- One reporting tree, walked with the recursive CTE form that SQL Server and Oracle both document: a column list,
-- no RECURSIVE keyword, an anchor and a recursive member joined by UNION ALL. (T-SQL builds the path with + or
-- CONCAT instead of ||, before SQL Server 2025.) The columns stand in for Oracle's CONNECT BY features:
-- lvl LEVEL path SYS_CONNECT_BY_PATH(name, '/')
-- root CONNECT_BY_ROOT name is_leaf CONNECT_BY_ISLEAF
-- Oracle's CONNECT BY version of the same walk would read:
-- SELECT id, name, LEVEL, SYS_CONNECT_BY_PATH(name, '/'), CONNECT_BY_ROOT name, CONNECT_BY_ISLEAF
-- FROM employees START WITH manager_id IS NULL CONNECT BY PRIOR id = manager_id ORDER SIBLINGS BY name;
CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT NOT NULL, manager_id INTEGER REFERENCES employees (id));
INSERT INTO employees (id, name, manager_id) VALUES
(1, 'Asha', NULL),
(2, 'Ravi', 1), (3, 'Fatima', 1),
(4, 'Kabir', 2), (5, 'Meera', 2), (6, 'Imran', 3),
(7, 'Zoya', 4), (8, 'Neha', 4), (9, 'Tenzin', 5), (10, 'Arjun', 6);
WITH org (id, name, manager_id, lvl, path, root) AS (
SELECT id, name, manager_id, 1, '/' || name, name
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, o.lvl + 1, o.path || '/' || e.name, o.root
FROM employees AS e
JOIN org AS o ON e.manager_id = o.id
)
SELECT id,
substr(' ', 1, 2 * (lvl - 1)) || name AS chart,
lvl,
path,
root,
CASE WHEN EXISTS (SELECT 1 FROM employees AS c WHERE c.manager_id = org.id) THEN 0 ELSE 1 END AS is_leaf
FROM org
ORDER BY path; Output
┌────┬──────────────┬─────┬──────────────────────────┬──────┬─────────┐ │ id │ chart │ lvl │ path │ root │ is_leaf │ ├────┼──────────────┼─────┼──────────────────────────┼──────┼─────────┤ │ 1 │ Asha │ 1 │ /Asha │ Asha │ 0 │ │ 3 │ Fatima │ 2 │ /Asha/Fatima │ Asha │ 0 │ │ 6 │ Imran │ 3 │ /Asha/Fatima/Imran │ Asha │ 0 │ │ 10 │ Arjun │ 4 │ /Asha/Fatima/Imran/Arjun │ Asha │ 1 │ │ 2 │ Ravi │ 2 │ /Asha/Ravi │ Asha │ 0 │ │ 4 │ Kabir │ 3 │ /Asha/Ravi/Kabir │ Asha │ 0 │ │ 8 │ Neha │ 4 │ /Asha/Ravi/Kabir/Neha │ Asha │ 1 │ │ 7 │ Zoya │ 4 │ /Asha/Ravi/Kabir/Zoya │ Asha │ 1 │ │ 5 │ Meera │ 3 │ /Asha/Ravi/Meera │ Asha │ 0 │ │ 9 │ Tenzin │ 4 │ /Asha/Ravi/Meera/Tenzin │ Asha │ 1 │ └────┴──────────────┴─────┴──────────────────────────┴──────┴─────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < org_chart_portable.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The anchor selects the root, WHERE manager_id IS NULL, at level 1 with a path of /Asha. Each pass of the recursive
member joins employees to the rows found so far on e.manager_id = o.id, adds 1 to the level and appends the name
to the path. The walk stops when a pass finds no new rows. Sorting by the path puts every manager directly above
their team, with siblings in alphabetical order: the order ORDER SIBLINGS BY name gives in Oracle.
Run in Oracle, the CONNECT BY query in the example’s comment returns the same ten rows in the same order. The
difference is who does the work: CONNECT BY computes the level, the path and the leaf flag for you, while the CTE
carries them as columns you build yourself. The CTE is longer but explicit, and of the two forms it is the only one
SQL Server can run.
Three details make the same text acceptable to both engines. The column list after the CTE’s name is optional in SQL
Server but required in Oracle for a recursive CTE, so always write it. UNION ALL is the only operator either engine
allows between the anchor and the recursive member. And the path is built with ||, which Oracle has always had;
T-SQL uses + or CONCAT, and SQL Server 2025 adds || too.
CONNECT BY, clause by clause
| Criterion | CONNECT BY | Recursive CTE |
|---|---|---|
| Roots of the walk | START WITH manager_id IS NULL | The anchor member, WHERE manager_id IS NULL |
| Parent to child | CONNECT BY PRIOR id = manager_id | JOIN org AS o ON e.manager_id = o.id |
| Depth | LEVEL, 1 for a root | A column: 1 in the anchor, plus 1 per step |
| Path from the root | SYS_CONNECT_BY_PATH(name, '/') | A column: path || '/' || name |
| Value from the root | CONNECT_BY_ROOT name | A column copied from the anchor |
| Leaf or not | CONNECT_BY_ISLEAF | NOT EXISTS (a row whose manager_id is this id) |
| Siblings in order | ORDER SIBLINGS BY name | ORDER BY path, or the SEARCH clause in Oracle |
PRIOR marks the parent’s side of the condition, so PRIOR id = manager_id reads “the parent’s id equals this row’s
manager_id”. Without START WITH, every row starts a walk of its own, so each person appears once as the root of their
own walk and once more under every manager above them.
When to keep CONNECT BY
Rewriting working Oracle code is not always worth it. CONNECT BY is often shorter, and existing reports depend on its
exact order of rows. Rewrite when the query must also run on SQL Server or PostgreSQL, or when it needs what only the
recursive form offers: Oracle’s reference notes that recursive CTEs add depth-first and breadth-first search and
several recursive branches.
What stops a hierarchy query
- A loop in the data fails a recursive CTE without a guard, and fails
CONNECT BYwith ORA-01436. - The
CYCLEclause of Oracle’s recursive CTE marks the row that closes a loop instead of failing. NOCYCLEletsCONNECT BYreturn rows despite a loop, andCONNECT_BY_ISCYCLEshows where it is.MAXRECURSIONis SQL Server’s limit on recursion levels, 100 unless a query sets another.- A filter’s position decides whether it removes whole branches or single rows.
GENERATE_SERIESreturns a series of numbers in SQL Server 2022 and later, without a recursive CTE.
Your turn
Exercise · Medium · SQL
Rewrite a CONNECT BY query as a recursive CTE
An Oracle report lists every employee with their level in the tree and the path of names from the top. It is written with Oracle's hierarchical query syntax:
SELECT id, name, LEVEL AS lvl, SYS_CONNECT_BY_PATH(name, '/') AS path FROM employees START WITH manager_id IS NULL CONNECT BY PRIOR id = manager_id
The report must now also run on SQL Server, so rewrite it as a recursive CTE, the form both engines accept. The table employees has the columns id, name and manager_id (NULL for the person at the top).
Return the columns id, name, lvl and path, with the same values the Oracle query gives: lvl is 1 for a person without a manager, 2 for their direct reports and so on, and path starts with a slash, as in /Asha/Ravi. The order of the rows does not matter here.
Starter code · org_paths.sql
-- Rewrite the CONNECT BY report as a recursive CTE: id, name, lvl and path for everyone.
-- This starter finds only the people at the top.
WITH org (id, name, lvl, path) AS (
SELECT id, name, 1, '/' || name
FROM employees
WHERE manager_id IS NULL
)
SELECT id, name, lvl, path
FROM org; The sample tests · tests.yaml
# Sample tests: each one builds a small employees table, runs your query on it and compares the rows it returns (in
# any order).
tests:
- name: a three-level tree
setup: |
CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT NOT NULL, manager_id INTEGER);
INSERT INTO employees VALUES
(1, 'Asha', NULL), (2, 'Ravi', 1), (3, 'Fatima', 1), (4, 'Kabir', 2);
expected:
columns: [id, name, lvl, path]
rows:
- [1, Asha, 1, /Asha]
- [2, Ravi, 2, /Asha/Ravi]
- [3, Fatima, 2, /Asha/Fatima]
- [4, Kabir, 3, /Asha/Ravi/Kabir]
- name: two people at the top start two trees
setup: |
CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT NOT NULL, manager_id INTEGER);
INSERT INTO employees VALUES
(10, 'Meera', NULL), (11, 'Imran', NULL), (12, 'Zoya', 10), (13, 'Neha', 11), (14, 'Tenzin', 13);
expected:
columns: [id, name, lvl, path]
rows:
- [10, Meera, 1, /Meera]
- [11, Imran, 1, /Imran]
- [12, Zoya, 2, /Meera/Zoya]
- [13, Neha, 2, /Imran/Neha]
- [14, Tenzin, 3, /Imran/Neha/Tenzin]
- name: a chain five levels deep
setup: |
CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT NOT NULL, manager_id INTEGER);
INSERT INTO employees VALUES
(1, 'Arjun', NULL), (2, 'Kavya', 1), (3, 'Rohan', 2), (4, 'Sneha', 3), (5, 'Vikram', 4);
expected:
columns: [id, name, lvl, path]
rows:
- [1, Arjun, 1, /Arjun]
- [2, Kavya, 2, /Arjun/Kavya]
- [3, Rohan, 3, /Arjun/Kavya/Rohan]
- [4, Sneha, 4, /Arjun/Kavya/Rohan/Sneha]
- [5, Vikram, 5, /Arjun/Kavya/Rohan/Sneha/Vikram] A hint
START WITH becomes the anchor member: the rows WHERE manager_id IS NULL, with 1 as lvl and '/' || name as the path. CONNECT BY PRIOR id = manager_id becomes the join of the recursive member: an employee whose manager_id equals the id of a row already found. Add 1 to lvl and append '/' || name to the path in that member.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Cycles and depth limits in both engines, with the failures reproduced Pro: opens with a Pro, Premium or Ultimate pass
Bad data and deep trees stop hierarchy queries in production long after they passed every test. This part reproduces one loop under each engine’s rule, measures a thread deeper than SQL Server’s default limit, and shows the type rule that makes a working Oracle query fail in T-SQL.
In this part:
- One loop, four ways of reporting it, reproduced
- SQL Server’s limit of 100 levels, measured on a 400-message thread
- A type rule that only SQL Server enforces
2 runnable examples (SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Filters, sibling order and series translated between the syntaxes, worked through Pro: opens with a Pro, Premium or Ultimate pass
A CONNECT BY query hides three decisions inside its clauses: where a filter applies, how siblings are ordered and how
rows are generated. This part translates each one to a recursive CTE, with a run that shows how far apart the answers
can be.
In this part:
- Prune or filter: the same condition in two places, reproduced
- Sibling order without ORDER SIBLINGS BY
- Series and calendars without a table
2 runnable examples (SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
In an Oracle query with START WITH manager_id IS NULL CONNECT BY PRIOR id = manager_id, what do START WITH, PRIOR and LEVEL mean?
START WITH picks the root rows of the walk, here the people without a manager. CONNECT BY gives the link between a parent row and a child row, and PRIOR marks the parent's side: PRIOR id = manager_id reads "the parent's id equals this row's manager_id", so each step finds the direct reports of the rows found before. LEVEL is a pseudocolumn that numbers the depth: 1 for a root, 2 for its children and so on. The same walk as a recursive CTE puts the START WITH condition in the anchor member, the CONNECT BY condition in the join of the recursive member, and LEVEL in a column that starts at 1 and grows by 1 per step.
-
Mid to senior
An Oracle report walks a 30,000-row category tree with START WITH, CONNECT BY PRIOR, SYS_CONNECT_BY_PATH and ORDER SIBLINGS BY. Rewrite it for SQL Server, and say where the results could differ from Oracle's.
-
Mid to senior
After a data load, a SQL Server org-chart query that worked for months stops with an error that the maximum recursion of 100 was exhausted. The company has 6,000 employees. What do you check first, and how do you fix it without hiding the real problem?
-
Mid to senior
An Oracle CONNECT BY report has WHERE status = 'ACTIVE'. Rewritten as a recursive CTE with that condition in the recursive member, it returns 1,200 fewer people. Which version is right, and why do they differ?
Key takeaways
- Write recursive CTEs with a column list, without
RECURSIVE, and withUNION ALL: both engines accept that form. CONNECT BY PRIOR id = manager_idlinks parent to child;LEVELis 1 at the root.- SQL Server stops a recursion after 100 levels by default; set
MAXRECURSIONand keep a cycle guard. - Oracle reports loops with ORA-01436, or marks them with
NOCYCLEor theCYCLEclause.
References
- SQL Server documentation: WITH common_table_expression (Transact-SQL) (Microsoft)
- SQL Server documentation: Query hints (MAXRECURSION) (Microsoft)
- SQL Server documentation: GENERATE_SERIES (Transact-SQL) (Microsoft)
- SQL Server documentation: || (String concatenation) (Transact-SQL) (Microsoft)
- Oracle AI Database SQL Language Reference 26: Hierarchical Queries (Oracle Corporation)
- Oracle AI Database SQL Language Reference 26: Hierarchical Query Pseudocolumns (Oracle Corporation)
- Oracle AI Database SQL Language Reference 26: SYS_CONNECT_BY_PATH (Oracle Corporation)
- Oracle AI Database SQL Language Reference 26: SELECT (subquery factoring, SEARCH and CYCLE clauses) (Oracle Corporation)
- Oracle error help: ORA-01436 (Oracle Corporation)
- SQLite: The WITH Clause (recursive common table expressions) (SQLite)
- SQLite: Date and Time Functions (SQLite)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress