Home // Cloud Infrastructure // Odoo Development // AI & Automation // Odoo + AI Agents Case Studies About Blog Free Assessment
// CLOUD MIGRATION · 13 MIN READ

Oracle CONNECT BY vs. Postgres Recursive CTEs: Translation Patterns and Performance

Oracle's CONNECT BY clause has been the standard for hierarchical queries since Oracle 8. Postgres uses recursive CTEs conforming to the SQL standard. The conceptual model is the same — traverse a parent-child relationship — but the syntax, pseudo-columns, ordering semantics, and cycle detection mechanisms differ enough that query-by-query translation is required.

// PUBLISHED 2025-05-20 · LANIAKEA TEAM

The Oracle CONNECT BY Model

Oracle's hierarchical query syntax uses three clauses: START WITH (defines the root nodes), CONNECT BY (defines the parent-child relationship), and optionally ORDER SIBLINGS BY (orders children within each level). Oracle provides several pseudo-columns that expose traversal metadata: LEVEL, CONNECT_BY_ROOT, CONNECT_BY_ISLEAF, CONNECT_BY_ISCYCLE, and SYS_CONNECT_BY_PATH.

-- Oracle: traverse an org chart hierarchy
SELECT
  LEVEL,
  employee_id,
  LPAD(' ', (LEVEL-1)*2) || first_name || ' ' || last_name AS name,
  manager_id,
  SYS_CONNECT_BY_PATH(last_name, '/') AS path,
  CONNECT_BY_ISLEAF AS is_leaf,
  CONNECT_BY_ROOT employee_id AS root_employee
FROM employees
START WITH manager_id IS NULL        -- root: employees with no manager
CONNECT BY PRIOR employee_id = manager_id  -- parent: prior row's employee_id = this row's manager_id
ORDER SIBLINGS BY last_name;

The PRIOR keyword identifies the parent side of the join. CONNECT BY PRIOR employee_id = manager_id means "connect this row to the row where employee_id (from the prior/parent row) equals this row's manager_id." Reversing PRIOR direction — CONNECT BY employee_id = PRIOR manager_id — traverses upward from a leaf to its ancestors.

The Postgres Recursive CTE Model

Postgres implements hierarchical traversal using WITH RECURSIVE. A recursive CTE has two parts separated by UNION ALL: the anchor (non-recursive base case) and the recursive member (the self-join that extends the result set one level at a time). Execution continues until the recursive member produces no new rows.

-- Postgres: equivalent org chart traversal
WITH RECURSIVE org_hierarchy AS (
  -- Anchor: root employees (no manager)
  SELECT
    employee_id,
    first_name,
    last_name,
    manager_id,
    1 AS level,
    ARRAY[employee_id] AS path_array,
    last_name::TEXT AS path
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- Recursive member: join children to their parent row
  SELECT
    e.employee_id,
    e.first_name,
    e.last_name,
    e.manager_id,
    h.level + 1,
    h.path_array || e.employee_id,
    h.path || '/' || e.last_name
  FROM employees e
  JOIN org_hierarchy h ON h.employee_id = e.manager_id
)
SELECT
  level,
  employee_id,
  repeat('  ', level - 1) || first_name || ' ' || last_name AS name,
  manager_id,
  path,
  -- CONNECT_BY_ISLEAF equivalent: leaf if no children in the hierarchy result
  NOT EXISTS (
    SELECT 1 FROM employees c WHERE c.manager_id = org_hierarchy.employee_id
  ) AS is_leaf
FROM org_hierarchy
ORDER BY path_array;  -- approximates ORDER SIBLINGS BY

Translating Oracle Pseudo-Columns

LEVEL

Straightforward: carry a level counter in the CTE, incrementing by 1 in the recursive member.

-- Oracle
SELECT LEVEL, employee_id FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id;

-- Postgres
WITH RECURSIVE h AS (
  SELECT employee_id, 1 AS level FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.employee_id, h.level + 1
  FROM employees e JOIN h ON h.employee_id = e.manager_id
)
SELECT level, employee_id FROM h;

SYS_CONNECT_BY_PATH

Build a path string by concatenating the path from the parent with the current node's value. Using an array and then converting to text provides better flexibility:

-- Oracle
SELECT SYS_CONNECT_BY_PATH(last_name, '/') AS path
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id;

-- Postgres: build path as concatenated string
WITH RECURSIVE h AS (
  SELECT employee_id, last_name, '/' || last_name AS path
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.employee_id, e.last_name, h.path || '/' || e.last_name
  FROM employees e JOIN h ON h.employee_id = e.manager_id
)
SELECT path FROM h;

CONNECT_BY_ISLEAF

Oracle computes this as part of the traversal. In Postgres you need a post-traversal join or subquery to determine whether a node has children:

-- Postgres: CONNECT_BY_ISLEAF equivalent
WITH RECURSIVE h AS (
  SELECT employee_id, manager_id, 1 AS level
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.employee_id, e.manager_id, h.level + 1
  FROM employees e JOIN h ON h.employee_id = e.manager_id
)
SELECT
  h.employee_id,
  h.level,
  CASE WHEN child.employee_id IS NULL THEN 1 ELSE 0 END AS is_leaf
FROM h
LEFT JOIN employees child ON child.manager_id = h.employee_id
GROUP BY h.employee_id, h.level, child.employee_id IS NULL;

CONNECT_BY_ROOT

Carry the root node's value through the recursion from the anchor:

-- Postgres: CONNECT_BY_ROOT equivalent
WITH RECURSIVE h AS (
  SELECT employee_id, manager_id, employee_id AS root_employee_id
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.employee_id, e.manager_id, h.root_employee_id
  FROM employees e JOIN h ON h.employee_id = e.manager_id
)
SELECT employee_id, root_employee_id FROM h;

Cycle Detection

Oracle detects cycles using NOCYCLE and exposes CONNECT_BY_ISCYCLE. Without NOCYCLE, a cyclic graph causes an infinite loop. Postgres 14+ added native cycle detection with the CYCLE clause:

-- Oracle: cycle-safe traversal
SELECT employee_id, CONNECT_BY_ISCYCLE AS is_cycle
FROM employees
START WITH manager_id IS NULL
CONNECT BY NOCYCLE PRIOR employee_id = manager_id;

-- Postgres 14+: native CYCLE clause
WITH RECURSIVE h AS (
  SELECT employee_id, manager_id FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.employee_id, e.manager_id
  FROM employees e JOIN h ON h.employee_id = e.manager_id
)
CYCLE employee_id SET is_cycle TO TRUE DEFAULT FALSE
USING cycle_path
SELECT employee_id, is_cycle FROM h;

For Postgres 13 and earlier, cycle detection requires carrying an array of visited node IDs and checking membership before each recursive step:

-- Postgres 13 and earlier: manual cycle detection
WITH RECURSIVE h AS (
  SELECT employee_id, manager_id,
         ARRAY[employee_id] AS visited,
         FALSE AS is_cycle
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.employee_id, e.manager_id,
         h.visited || e.employee_id,
         e.employee_id = ANY(h.visited)
  FROM employees e
  JOIN h ON h.employee_id = e.manager_id
  WHERE NOT h.is_cycle
    AND e.employee_id != ALL(h.visited)
)
SELECT employee_id, is_cycle FROM h;

ORDER SIBLINGS BY: The Hard Part

Oracle's ORDER SIBLINGS BY orders children within each parent while preserving depth-first traversal order. Postgres has no equivalent. The standard workaround is to carry an ordering array through the recursion and sort by it at the end:

-- Postgres: approximating ORDER SIBLINGS BY last_name
WITH RECURSIVE h AS (
  SELECT
    employee_id, last_name, manager_id,
    ARRAY[lpad(last_name, 50)] AS sort_path
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT
    e.employee_id, e.last_name, e.manager_id,
    h.sort_path || lpad(e.last_name, 50)
  FROM employees e JOIN h ON h.employee_id = e.manager_id
)
SELECT employee_id, last_name
FROM h
ORDER BY sort_path;

The lpad ensures alphabetic comparison works correctly across levels. The padding width must be consistent and large enough to hold the longest sibling name. This is a reasonable workaround but fragile if values contain special characters or vary widely in length. For production code, use integer sort keys (sequence numbers) rather than name strings.

Performance Comparison

Oracle's CONNECT BY is a native operator with decades of optimizer work behind it. Postgres recursive CTEs are a general-purpose mechanism not specifically optimized for tree traversal. For deep hierarchies (hundreds of levels) or very wide trees (millions of nodes), Postgres recursive CTEs can be significantly slower than Oracle CONNECT BY on equivalent hardware.

The practical mitigation strategies:

-- Postgres 14+: explicit depth-first search ordering
WITH RECURSIVE h AS (
  SELECT employee_id, manager_id FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.employee_id, e.manager_id
  FROM employees e JOIN h ON h.employee_id = e.manager_id
)
SEARCH DEPTH FIRST BY employee_id SET traversal_order
SELECT employee_id FROM h ORDER BY traversal_order;

Oracle CONNECT BY queries in your migration scope and not sure how many need manual work?

We catalog and translate Oracle hierarchical query patterns as part of Oracle-to-Postgres migrations — including CONNECT_BY_ROOT, SYS_CONNECT_BY_PATH, and cycle detection. Free assessment, no obligation.