Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 30 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free core, Pro depth: 11 of 30 minutes free
Free: The tree used in this lesson · One walk, written for both engines · CONNECT BY, clause by clause · 3 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 19 minutes.

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

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

A tree of 10 people: Asha at level 1, Ravi and Fatima at level 2, three people at level 3 and four leaves at level 4.1 AshaLEVEL 1, the root2 RaviLEVEL 23 FatimaLEVEL 24 KabirLEVEL 35 MeeraLEVEL 36 ImranLEVEL 37 ZoyaLEVEL 4, leaf8 NehaLEVEL 4, leaf9 TenzinLEVEL 4, leaf10 ArjunLEVEL 4, leaf

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

The whole tree with level, path, root and leaf flags SQL · org_chart_portable.sql
-- 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

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

Oracle CONNECT BY and its recursive CTE equivalent
Criterion CONNECT BYRecursive CTE
Roots of the walk START WITH manager_id IS NULLThe anchor member, WHERE manager_id IS NULL
Parent to child CONNECT BY PRIOR id = manager_idJOIN org AS o ON e.manager_id = o.id
Depth LEVEL, 1 for a rootA 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 nameA column copied from the anchor
Leaf or not CONNECT_BY_ISLEAFNOT EXISTS (a row whose manager_id is this id)
Siblings in order ORDER SIBLINGS BY nameORDER 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 BY with ORA-01436.
  • The CYCLE clause of Oracle’s recursive CTE marks the row that closes a loop instead of failing.
  • NOCYCLE lets CONNECT BY return rows despite a loop, and CONNECT_BY_ISCYCLE shows where it is.
  • MAXRECURSION is 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_SERIES returns 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.

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.

  1. Question 1 of 4 A query that starts WITH RECURSIVE org (id, lvl) AS (...) runs in PostgreSQL. What happens in SQL Server and in Oracle?

    Choose one answer.

    Show the answer to question 1

    Answer: Both reject it: write WITH org (id, lvl) AS (...) without RECURSIVE

    Neither engine's WITH syntax has a RECURSIVE keyword: a CTE is recursive because it refers to itself. Oracle also requires the column list after the name for a recursive CTE, so keep the list for both engines.

  2. Question 2 of 4 In the lesson's tree, Tenzin reports to Meera, who reports to Ravi, who reports to Asha at the top. What LEVEL does CONNECT BY report for Tenzin?

    Type a number.

    Show the answer to question 2

    Answer: 4

    LEVEL is 1 for a root row, 2 for its children and so on: Asha 1, Ravi 2, Meera 3, Tenzin 4.

  3. Question 3 of 4 How many recursion levels does SQL Server allow a recursive CTE by default, before it stops the statement with an error?

    Type a number.

    Show the answer to question 3

    Answer: 100

    The server-wide default is 100. OPTION (MAXRECURSION n) sets another limit from 0 to 32,767, and 0 removes the limit, which makes your own cycle guard essential.

  4. Question 4 of 4 A CONNECT BY query has WHERE department = 'Sales'. Ravi is not in Sales, but his reports are. Which of them does the query return?

    Choose one answer.

    Show the answer to question 4

    Answer: Ravi's reports who are in Sales: WHERE removes each row on its own after the tree is built

    Oracle builds the hierarchy first, then a WHERE without a join removes rows one by one, keeping their children. To cut whole branches, put the condition into CONNECT BY instead.

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.

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.

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

  1. 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.

  2. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Write recursive CTEs with a column list, without RECURSIVE, and with UNION ALL: both engines accept that form.
  • CONNECT BY PRIOR id = manager_id links parent to child; LEVEL is 1 at the root.
  • SQL Server stops a recursion after 100 levels by default; set MAXRECURSION and keep a cycle guard.
  • Oracle reports loops with ORA-01436, or marks them with NOCYCLE or the CYCLE clause.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.