When developers run into graph problems, such as working with hierarchies, finding routes between cities, or mapping social network connections, the first reaction is often to grab a specialised tool. We tend to think we need a graph database like Neo4j or a Python library like NetworkX to handle these challenges.
For massive, billion-node graphs, those tools are necessary. But for a substantial percentage of operational data, e.g. supply chains with a few thousand nodes, organisational charts, or navigation paths, introducing a new database engine can be architectural overkill.
Your current relational database can likely already handle most graph problems just fine. The key is a feature that has been in the SQL standard since 1999 but still isn’t widely used: Recursive Common Table Expressions (CTEs).
This article shows how to do graph traversal, pathfinding, and cycle detection using only standard SQL.
Prerequisites
If you want to try these examples, you’ll need access to a modern relational database like Postgres, Oracle, or MySQL.
I’ll use SQLite on my local computer for the examples. Keep in mind that the exact SQL syntax may vary slightly depending on your database.
A Quick Refresher on Standard CTEs
Before diving into recursive common table expressions, let’s review what a regular, non-recursive CTE does.
A Common Table Expression is a temporary result set you define within a single SELECT, INSERT, UPDATE, or DELETE statement. You can think of it as a named subquery or a temporary view that only exists while the query runs.
The main reason to use CTEs is to make queries more straightforward to read. They help you break up complex logic into clear, step-by-step parts. Instead of writing messy queries full of subqueries, you can organise your logic in named sections at the top. For complex SQL, you can also reuse the same CTE more than once in a statement.
Consider a simple sales table.
We want to find out which region(s) sales are greater than or equal to the average sales of all regions. First, we use a CTE to calculate total sales for each region. Then, we filter these results by comparing each region’s total to the average, which we get from a subquery over the CTE.
Let’s check. Regional totals are:
North = 250
South = 250
East = 300
The average of these values is 266.67, and yes, only the East region has sales greater than this average.
The WITH region_totals AS (…) block defines the CTE. The subsequent query treats region_totals like it’s a real table. Once the query finishes, the CTE vanishes.
A Recursive CTE takes this concept one step further. Instead of just passing data down to the main query, it can reference itself to generate new rows based on previous rows.
The Anatomy of a Recursive CTE
A Recursive CTE works like a loop inside a query. Unlike a regular SELECT statement that runs once, a recursive CTE keeps running until there’s no more data. It builds the result set step by step.
The syntax is standardised, but the logic requires a mental shift from “set-based” thinking to “iterative” thinking.
Every recursive CTE has the same basic structure: an initial (or anchor) query to start the result set, a recursive query that joins back to itself to add more rows, and a stopping point when there are no more rows to add.
For example,
When you run this, the database engine does a Breadth-First Search (BFS). It runs the anchor query, adds the results to a working table, then uses those results for the recursive step. This repeats until the recursive step returns no more rows.
Example 1. The Organisational Hierarchy (Trees)
The most common graph problem in business software is the tree structure. File systems, comment threads, and organisational charts all model hierarchical relationships, even though the meaning of those relationships differs in each case.
Let’s define a simple employees table.
The problem
We want to create a report that shows every employee, their management path (e.g., “Alice -> Bob -> Dave”), and their depth in the hierarchy.
The SQL Solution
And here is the output.
How this works
The process starts with the anchor query, which selects the top-level employee where manager_id is NULL. This gives us Alice and starts the result set at depth 1, with a path containing just her name.
Next, the recursive query keeps joining the current results back to the employees table using manager_id = id. The first round finds Alice’s direct reports, Bob and Charlie, giving them a depth of 2 and extending their paths. Later rounds keep expanding from the new rows. Bob and Charlie lead to Dave and Eve, and Dave leads to Frank. Each level increases the depth and adds to the path.
Eventually, the recursive step finds no new rows because there are no more employees whose manager_id matches the current set. At that point, the recursion stops. The final result is a flat view of the organisational hierarchy, built step by step by the database engine without any loops in your code.
The end result is a flat view of the organisational hierarchy, built in SQL with a breadth-first recursive query. There’s no need for loops or extra control flow in your application.
Example 2. Pathfinding in a Network (Graphs)
Trees are simple because they only go in one direction, from the top down. Graphs are more complex because they can have cycles and multiple paths.
Let’s look at a transportation network. Unlike an org chart, you can travel from Point A to Point B in several ways, and each connection (like a road or flight) has a weight, such as cost or distance.
The problem
Find all possible routes from New York to Tokyo, and calculate the total cost for each route.
The SQL solution
Here, we need to keep track of state as we recurse. We have to follow the running total cost and the order of cities visited.
How this works
The query uses a recursive CTE to find all possible flight routes from New York to Tokyo, building each route one step at a time.
Anchor phase
The anchor query picks all direct flights leaving New York. Each row is a one-hop route, setting the total cost, route string, and hop count.
Recursive expansion
For each route discovered so far, the recursive member looks for flights whose origin matches the current route’s destination. When a match is found, the query:
-
adds the new flight’s cost to the running total,
-
appends the destination to the route string,
-
increments the hop count.
This process repeats, extending routes one flight at a time, until no further connections can be made.
Result selection
After all possible routes are found, the final SELECT filters for routes ending in Tokyo and sorts them by total cost, so the cheapest routes come first.
The output
We’ve basically written a pathfinding algorithm using only SQL. The database engine explores the graph step by step – first New York’s neighbours, then their neighbours – until it finds the destination.
Example 3. Cycle Detection (The Infinite Loop Trap)
The previous flight example assumes the graph is a Directed Acyclic Graph (DAG), so you always move forward. But real-world graphs can have loops. For example, if London connects to Dubai and Dubai connects back to London, the query could get stuck in an infinite loop and run until it hits a memory limit or times out.
To handle general graphs, we need to add cycle detection. This means checking if the node we’re about to visit has already been visited in the current path.
Let us add a loop to our data.
If we run the previous query now, it will crash (or run forever). Let’s patch it with cycle detection.
A More Robust SQL Solution
And our output
How this works
To make the traversal safe, the query keeps track of the route so far as a text string. Each time a new connection is added, the destination city is added to this path, creating a record of every city visited.
Before extending a route, the recursive step checks if the next destination is already in the path string. If it is, that branch is marked as a cycle and won’t be expanded further. Other branches that aren’t cyclic keep exploring the graph.
In addition to cycle detection, the query also sets a hard depth limit. This acts as a safety brake, ensuring recursion can’t run forever, even if the data contains unexpected loops or very deep chains.
With these checks, the CTE can walk the graph by joining on origin = destination, building longer routes until it reaches Tokyo, finds a cycle, or hits the depth limit. This gives you a graph traversal in SQL with per-path “visited” tracking, using simple strings in SQLite instead of arrays.
In short, the CTE explores the graph by joining connections on c.origin = ts.destination, building longer routes until it reaches Tokyo, finds a cycle, or hits the depth limit. This is a graph traversal with a “visited” check for each path, done in SQLite using strings. For other databases, you get away with using arrays instead.
Example 4. Six Degrees of Separation (Shortest Path)
Sometimes, we don’t care about the exact route, just the distance. In social networks, this is the classic “Six Degrees of Separation” idea, which says everyone is connected to everyone else by at most six people. This idea became famous in the game “Six Degrees of Kevin Bacon,” where the goal is to link the actor Kevin Bacon to another actor through the shortest path of connections.
So, given two people, can we figure out what the shortest chain of friends is connecting them?
Because Recursive CTEs operate in a Breadth-First manner (processing all friends, then all friends-of-friends), the first time the recursion finds the target user, it is guaranteed to be the shortest path (in an unweighted graph).
First, we need some data.
Now we find out how many degrees of separation there are between Kevin and everyone else.
How this works
The query expands Kevin’s paths using a recursive CTE. The first query begins with Kevin, sets his degree to zero, and starts tracking the route. Each recursive step extends the search by one hop, joining the current results back to the relations table. As new people are found, the degree increases by 1, and their names are added to the path.
To prevent loops, each path keeps a record of visited names. Before extending a path, the query checks if the next person is already in the path and skips them if so. A hard depth limit adds another safety brake, making sure recursion doesn’t go on forever. This process creates many possible paths, including different ways to reach the same person at different depths.
After all paths are found, a second CTE ranks them by length for each person. A window function selects the shortest path for each person and retains it, discarding longer paths. The final SELECT formats these paths and orders the results by degree, so the closest connections come first.
The resulting output is a “six degrees” view of the network: the minimum number of steps from Kevin to every other reachable person, computed entirely inside SQLite using recursive SQL and a small amount of post-processing.
Performance Optimisation and Limits
Recursive CTEs are powerful, but they aren’t a replacement for a dedicated graph engine. They work well within certain limits, but performance drops quickly if you go beyond those limits.
One key thing to watch is indexing. During recursion, the database keeps joining the working set back to the base table. If the join columns aren’t indexed, each step turns into a full table scan. What should be a quick operation can become much slower, so you should always think about indexing columns used in the recursive join.
Another limit is the size of the working table. Recursive CTEs keep intermediate results as they go. In very wide graphs, such as social networks where a single node may have thousands of neighbours, these sets can grow very large, and if they spill to disk, performance can suffer. Because of this, recursive CTEs shouldn’t be used for wide, highly connected graphs.
Finally, always use recursion carefully. In the real-world data is often messy, and cycles can appear without you even realising it. Without safeguards, a recursive query can loop forever or use up all system resources. Adding a simple depth limit to the recursive WHERE clause is a reliable safety brake, ensuring the query stops even in the presence of unexpected loops or bad data.
Summary
SQL is often seen as just a language for simple reports and CRUD operations. But it’s actually a declarative logic programming language. This article shows how standard SQL, using recursive Common Table Expressions (CTEs), can solve many real-world graph problems without a dedicated graph database.
It shows how relational databases can do hierarchy traversal, pathfinding, cycle detection, and shortest-path calculations using just SQL recursion. With real-world examples such as org charts, route planning, cycle-safe traversal, and six degrees of separation, the article explains both how recursive CTEs work and where their limits lie.
The main point is that while recursive CTEs aren’t a replacement for large graph engines, they are a powerful and underused tool for working with small to medium graphs inside your existing relational database—as long as you know their performance limits and use safety checks.
You don’t always need to set up a Neo4j instance or write a Python script to traverse a tree or find a path. Sometimes, all you need is SQL and a new way of thinking.

