SQL to Cypher - 10 Queries You Already Know

You already know how to write these queries. This article shows you what they look like when the joins go away.

The query every backend developer has needed and nobody enjoys writing

In March 2016, npm removed an 11-line package called left-pad. Within minutes, builds began failing across the JavaScript ecosystem. It broke thousands of projects, including tools like Babel. Many developers didn't choose left-pad directly; it was hidden in their dependencies and went unnoticed until it vanished.

That incident points at a question you have probably asked about your own stack: what is actually in my dependency tree? Not just the 30 packages in your package.json. Everything they pull in, and everything those pull in, all the way down.

In a relational database, dependencies live in a self-referencing join table. "Everything, all the way down" means a recursive CTE. Here is that query on a snapshot of the npm registry. It finds the full runtime dependency tree of express:

sql
WITH RECURSIVE closure(name, depth) AS (
  SELECT depends_on_name, 1
  FROM dependencies
  WHERE package_name = 'express' AND dep_type = 'runtime'
  UNION
  SELECT d.depends_on_name, c.depth + 1
  FROM closure c
  JOIN dependencies d
    ON d.package_name = c.name AND d.dep_type = 'runtime'
)
SELECT count(DISTINCT name) AS transitive_deps, max(depth) AS max_depth
FROM closure;

Here is the result:

"Query showing runtime dependencies"

It works. Here it shows 63 packages, 11 levels deep. But it is twelve lines, and every line matters. There is an anchor part, a recursive part, and a UNION doing quiet work to remove duplicates. A graph asks the same question in two lines:

cypher
MATCH (:Package {name: 'express'})-[:DEPENDS_ON*]->(dep)
RETURN count(DISTINCT dep) AS transitive_deps

That is not a shortened excerpt. That is the whole query. It returns the same 63 packages.

"Query showing runtime dependencies"

Cypher is Neo4j's query language. For a SQL developer, it is less a new language than a new notation for questions you already know how to ask. This article proves that claim with ten translations. They run from "this is just SQL with arrows" up to the query above, plus one final twist that SQL has no clean answer for. By the end, you should be able to read Cypher on sight and write the first seven queries yourself, today.

The dataset, so you can check my work

Every query runs against a fixed snapshot of npm. It starts with the top 5,000 most-used packages, then follows their runtime dependencies until the set is complete. The result is about 5,600 packages, their maintainers, and every dependency link among them. The exact same data exists twice.

Relational (SQLite): four tables. This is the shape the data naturally takes in a relational model.

Relational Diagram of our Model

  • packages(package_name, description, license, latest_version, weekly_downloads, deprecated)
  • dependencies(package_name, depends_on_name, dep_type, semver_range), the join table
  • maintainers(username) and package_maintainers(username, package_name)

Graph (Neo4j): the same facts as nodes and relationships.

Graph Diagram of our Model

  • (:Package) and (:Maintainer) nodes with the same properties
  • (:Package)-[:DEPENDS_ON {range}]->(:Package) for runtime dependencies
  • (:Package)-[:DEV_DEPENDS_ON {range}]->(:Package) for dev dependencies
  • (:Maintainer)-[:MAINTAINS]->(:Package)

From join table to relationships

Notice where the join table went. Each row of dependencies became a relationship. Its columns split in an interesting way. The semver_range is a fact about the dependency itself, so it became a property on the relationship. But dep_type became the relationship's type. Runtime and dev dependencies are different kinds of link, not different values in a column. Query 3 will show why that choice pays off.

Note: this snapshot collapses versions. There is one node per package, and the links come from each package's latest version. The semver ranges are saved as text but not resolved. So the graph answers "what does the current ecosystem depend on," not "what exact files does npm install produce." Version-level modeling is a different and much bigger graph.

Both databases, the CSVs, and the build pipeline are in the companion repository. You can load the graph into a free Aura instance by pasting this script. Or download npm.db and follow along in SQLite.

Queries 1 to 3: the automatic translations

For filtering, sorting, and picking columns, Cypher works just like SQL. The main change is how you point at your data. Instead of FROM packages p, you write MATCH (p:Package). The parentheses draw a node, the label picks which kind, and p is an alias, doing the same job it does in SQL.

1. SELECT with WHERE

Start with every MIT-licensed package:

sql
SELECT package_name, description
FROM packages
WHERE license = 'MIT';

"Results of Query from SQL"

The same query in Cypher:

cypher
MATCH (p:Package)              // (1)
WHERE p.license = 'MIT'        // (2)
RETURN p.name, p.description   // (3)
  1. 1

    MATCH (p:Package) plays the role of FROM packages. It binds every node with the Package label to the variable p.

  2. 2

    WHERE filters the matched rows, exactly as it does in SQL.

  3. 3

    RETURN is SELECT, moved to the end so the query reads in the order the work happens: find, filter, return.

"Results of Query from Cypher"

And as you can see the output is identical to the result of the SQL query.

2. ORDER BY and LIMIT

Now the ten most-downloaded packages:

sql
SELECT package_name, weekly_downloads
FROM packages
ORDER BY weekly_downloads DESC
LIMIT 10;

The same question in Cypher:

cypher
MATCH (p:Package)
RETURN p.name, p.weeklyDownloads
ORDER BY p.weeklyDownloads DESC
LIMIT 10

Same keywords, same meaning. There is nothing to learn here. That is the point.

3. Your first JOIN becomes a pattern

The direct runtime dependencies of express:

sql
SELECT d.depends_on_name, d.semver_range
FROM packages p
JOIN dependencies d ON d.package_name = p.package_name
WHERE p.package_name = 'express'
  AND d.dep_type = 'runtime';

In Cypher:

cypher
MATCH (:Package {name: 'express'})-[d:DEPENDS_ON]->(dep:Package)
RETURN dep.name, d.range

This is the first translation where the shape changes, not just the keywords. The JOIN ... ON clause is gone. The connection between two packages is stored as a relationship, so the arrow -[:DEPENDS_ON]-> is the join, already built.

Look at what happened to AND d.dep_type = 'runtime'. It disappeared. Runtime and dev dependencies are different relationship types, so asking for DEPENDS_ON filters by itself. Want dev dependencies instead? Write -[:DEV_DEPENDS_ON]->. Want both? Write -[:DEPENDS_ON|DEV_DEPENDS_ON]->.

In SQL, this is the filter you always have to remember, and the bug you ship when you forget it. In the graph, it became part of the question's grammar.

Both queries return 28 rows. Express pulls in 28 packages directly. Hold that number. Query 10 will tell you what it grows into.

Queries 4 to 6: aggregation without GROUP BY

4. The join you write twice

The dependencies of express's dependencies, two levels down, by hand:

sql
SELECT DISTINCT d2.depends_on_name
FROM dependencies d1
JOIN dependencies d2
  ON d2.package_name = d1.depends_on_name
 AND d2.dep_type = 'runtime'
WHERE d1.package_name = 'express'
  AND d1.dep_type = 'runtime';

The same question in Cypher:

cypher
MATCH (:Package {name: 'express'})-[:DEPENDS_ON*2]->(dep:Package)
RETURN DISTINCT dep.name

Each extra level in SQL costs another self-join of the dependencies table. You add d3, d4, d5, each with its own ON clause and its own dep_type filter to not forget.

In Cypher, depth is a number. Write *2 for exactly two hops, or *1..3 for one to three. This query is a preview. You can feel where hand-written depth stops working, and query 10 is waiting there.

5. GROUP BY is implicit

Which packages have the most direct dependencies?

sql
SELECT p.package_name, COUNT(*) AS dep_count
FROM packages p
JOIN dependencies d ON d.package_name = p.package_name
WHERE d.dep_type = 'runtime'
GROUP BY p.package_name
ORDER BY dep_count DESC
LIMIT 10;

In Cypher:

cypher
MATCH (p:Package)-[:DEPENDS_ON]->()
RETURN p.name, count(*) AS depCount
ORDER BY depCount DESC
LIMIT 10

There is no GROUP BY in Cypher. After a week, you will not miss it. The rule is simple. Any plain expression that appears next to an aggregate in RETURN (or WITH) becomes a grouping key. Since p.name sits next to count(*), Cypher groups by it. This also removes SQL's most tedious error: the "column must appear in the GROUP BY clause" dance, where you repeat every selected column a second time.

One more detail. The empty node () is anonymous. We are counting edges to anything, so the target needs no name. Say only what you need.

6. HAVING becomes WITH ... WHERE

The load-bearing packages: the ones that more than 100 other packages depend on directly:

sql
SELECT d.depends_on_name, COUNT(*) AS dependents
FROM dependencies d
WHERE d.dep_type = 'runtime'
GROUP BY d.depends_on_name
HAVING COUNT(*) > 100
ORDER BY dependents DESC;

The same question in Cypher:

cypher
MATCH (p:Package)<-[:DEPENDS_ON]-()
WITH p, count(*) AS dependents
WHERE dependents > 100
RETURN p.name, dependents
ORDER BY dependents DESC

Two things happened here. First, the arrow flipped. The pattern <-[:DEPENDS_ON]- counts incoming links: who depends on p, not what p depends on. Direction is visible in the pattern. SQL hides it in which column you group by. Quick, was it package_name or depends_on_name?

Second, meet WITH. It is the construct SQL never gave you: a pipe between query stages. Aggregate, then filter the result, then keep going. HAVING exists in SQL only because WHERE runs before aggregation. Cypher does not need a special keyword. You place the WHERE after the aggregation in the pipeline. One mental model replaces two keywords.

Reach for WITH any time you catch yourself wanting a subquery or a second CTE stage. It covers most of both.

Queries 7 to 9: the patterns that replace subqueries

7. LEFT JOIN ... IS NULL becomes OPTIONAL MATCH

Leaf packages: in the dataset, but nothing else depends on them:

sql
SELECT p.package_name
FROM packages p
LEFT JOIN dependencies d
  ON d.depends_on_name = p.package_name
 AND d.dep_type = 'runtime'
WHERE d.package_name IS NULL;

The anti-join translates straight across. OPTIONAL MATCH is exactly LEFT JOIN: match if you can, bind NULL if you cannot, then keep the rows where the match failed.

cypher
MATCH (p:Package)
OPTIONAL MATCH (p)<-[d:DEPENDS_ON]-()
WITH p, d
WHERE d IS NULL
RETURN p.name

But the graph does not need the join at all. The LEFT JOIN ... IS NULL pattern makes you assemble every non-match just to count that it is missing. The real question is simpler: does anything depend on p? An EXISTS predicate in WHERE asks exactly that:

cypher
MATCH (p:Package)
WHERE NOT EXISTS { (p)<-[:DEPENDS_ON]-() }
RETURN p.name

This is where the graph pulls ahead. EXISTS { } is an existence check: it stops the moment one incoming DEPENDS_ON is found and never enumerates the rest. Because Neo4j stores the relationship degree on every node, the question, does p have any incoming DEPENDS_ON, is answered by reading one number, without ever expanding a relationship or building a row to discard. It is a small query doing something the relational model has to work for, and a clean example of a graph-native advantage.

A fun detail about this dataset: the leaves are mostly the famous top-of-tree packages, like express, chalk, and lodash. They start dependency trees rather than sit inside them. The snapshot contains what they depend on, not the millions of apps that depend on them.

8. EXISTS subqueries become pattern predicates

Packages that depend on both react and vue. Rarer than you might think:

sql
SELECT p.package_name
FROM packages p
WHERE EXISTS (
  SELECT 1 FROM dependencies d
  WHERE d.package_name = p.package_name
    AND d.depends_on_name = 'react' AND d.dep_type = 'runtime')
AND EXISTS (
  SELECT 1 FROM dependencies d
  WHERE d.package_name = p.package_name
    AND d.depends_on_name = 'vue' AND d.dep_type = 'runtime');

The same query in Cypher:

cypher
MATCH (p:Package)
WHERE EXISTS { (p)-[:DEPENDS_ON]->(:Package {name: 'react'}) }
  AND EXISTS { (p)-[:DEPENDS_ON]->(:Package {name: 'vue'}) }
RETURN p.name

Same keyword as SQL, and you have already met it: query 7 used NOT EXISTS { } to find packages that nothing depends on. Here it does the positive job, with far less machinery. A correlated SQL subquery is a full nested query. It has its own FROM, its own filters, and its own correlation condition you wire up by hand. Cypher's EXISTS { } takes a pattern. The correlation is free, because the pattern reuses the variable p that is already bound. Ten lines become four, and the four read like a sentence.

By the way, if you're following along and running these queries yourself, the result is zero in this snapshot.

9. The self-join

Packages that share a maintainer with lodash:

sql
SELECT DISTINCT pm2.package_name
FROM package_maintainers pm1
JOIN package_maintainers pm2
  ON pm1.username = pm2.username
WHERE pm1.package_name = 'lodash'
  AND pm2.package_name <> 'lodash';

In Cypher:

cypher
MATCH (:Package {name: 'lodash'})<-[:MAINTAINS]-(:Maintainer)-[:MAINTAINS]->(p:Package)
RETURN DISTINCT p.name

The SQL joins the association table against itself. That takes two aliases, a join on the shared key, and an inequality to skip the starting row. The Cypher walks through the shared maintainer instead. It goes into lodash from its maintainer, then back out to everything else that maintainer touches. There are no aliases and no inequality, since a path cannot reuse the relationship it arrived on. The query reads like the question itself: who else does lodash's maintainer maintain?

This pattern (entity, through a shared link, to entity) is the skeleton of collaborative filtering, fraud-ring detection, and "people also bought." In SQL it is always a self-join on the association table. In a graph it is always this V-shaped walk. Learn it once here.

Query 10: where the languages stop being comparable

Back to the opening question, now with the numbers filled in. Express declares 28 direct dependencies. Its full runtime tree, the true answer to "what am I actually installing," is 63 packages, reaching 11 levels deep. The recursive CTE at the top of this article computes that correctly. I want to be precise about what is and is not hard about it.

The naive CTE finishes just fine on this dataset. Dependency graphs are nearly free of cycles, and the UNION step keeps the working set small by removing duplicates. This is a friendlier graph than, say, a social network. There, loops and highly connected nodes can make path-listing CTEs blow up.

What are the honest differences? First, the CTE is a program and the pattern is an expression. Twelve lines describe how to traverse: the anchor, the recursion, the dedup, the depth tracking. One line states what you want.

Second, and this is the one that matters in practice: now reverse the question. Not "what does express depend on," but the left-pad question. What breaks if this package disappears? In SQL, that is a second CTE, a mirror image of the first. The recursion now runs along package_name instead of depends_on_name. The anchor flips. You maintain both queries forever:

sql
WITH RECURSIVE blast_radius(name) AS (
  SELECT package_name FROM dependencies
  WHERE depends_on_name = 'debug' AND dep_type = 'runtime'
  UNION
  SELECT d.package_name
  FROM blast_radius b
  JOIN dependencies d ON d.depends_on_name = b.name
                     AND d.dep_type = 'runtime'
)
SELECT count(*) AS affected FROM blast_radius;

In Cypher, reversing the question means reversing the arrow:

cypher
MATCH (affected:Package)-[:DEPENDS_ON*]->(:Package {name: 'debug'})
RETURN count(DISTINCT affected) AS affected

Same query. Flipped arrow.

This is not Cypher being shorter for its own sake. The query language has a native concept, the path, that SQL lacks. Once paths are built in, "everything downstream," "everything upstream," "shortest chain between," and "is there any connection at all" become expressions instead of programs.

That is the difference between a notation gap and a capability gap. It is also the clearest signal for when a graph database is the right tool. If your hard queries are about how things connect, at unknown depth, then you are implementing algorithms in your query language that Cypher gives you as syntax.

The cheat sheet

The full mapping, for reference:

You know this in SQLYou write this in CypherTranslation difficulty
FROM table aliasMATCH (alias:Label)Automatic
WHEREWHERE (or inline {prop: value})Automatic
SELECTRETURN (at the end)Automatic
ORDER BY / LIMIT / DISTINCTSame keywordsAutomatic
JOIN ... ONA relationship in the pattern: -[:REL]->Shape change
A type/discriminator columnA relationship typeModeling shift
GROUP BYImplicit: plain terms next to aggregatesShape change
HAVINGWITH ... WHEREShape change
LEFT JOINOPTIONAL MATCHAutomatic
EXISTS (subquery)EXISTS { pattern }Shape change
Self-join on an association tableThe V-walk: (a)<-[:R]-(x)-[:R]->(b)Modeling shift
Recursive CTE-[:REL*]->, direction by arrowDifferent capability

Look at the rightmost column. Most rows are automatic or close to it. You can read those today. A few rows each require one new idea: implicit grouping, the WITH pipeline, or joins as stored relationships. Only the last row is truly new territory, and it is the row you came for.

When to stay in SQL

This comparison is only honest if it cuts both ways. Nothing here argues for replacing your relational database wholesale. In fact, several of these queries are evidence against moving certain workloads:

  • Flat filtering and aggregation (queries 1, 2, and 5): SQL is at parity. Your team already knows it, and the tooling around it (BI layers, ORMs, decades of operational habit) is mature. A GROUP BY over one table is not a graph problem.
  • Bulk reporting over wide tables with few relationship hops is exactly what relational engines are tuned for.
  • The modeling cost is real. The graph's elegance was paid for at load time. Someone decided that dep_type should be a relationship type, that maintainership should be an edge, and that versions should collapse. Change a decision, rebuild the graph. If your access patterns are stable, tabular, and shallow, that cost never pays off.

The signal to reach for a graph is specific. Your queries routinely chain three or more joins. The depth is variable or unknown ("all upstream dependencies," "how are these two things connected"). Or you find yourself writing recursive CTEs against self-referencing tables. That last one is the tell. If query 10 looks like something in your codebase, this article was written for you.

Try all ten yourself

Everything above is reproducible. The companion repository has both databases prebuilt: npm.db for SQLite, and one paste-and-run script that loads the graph into a free Aura instance. The CSVs and the pipeline that built them are there too, if you want to regenerate the snapshot yourself. No setup appears in this article because none is needed to follow it. The repo README is the whole install.

But the fastest route to writing Cypher rather than reading it is a course with a graph already loaded and live checking. Cypher Fundamentals is free, runs in the browser, and takes about an hour. It covers exactly the constructs in queries 1 through 8, and turns "I can read this" into "I wrote this" in one sitting:

From there, Intermediate Cypher Queries picks up the variable-length paths and the WITH pipeline behind queries 6 and 10:

One teaser before you go, because query 10 probably raised the question. The dataset in this article is generic npm. Your package-lock.json is the same graph shape, with your project as the root node. Converting it to this schema is a small script away. Running a blast-radius query against your own application's real dependency tree is a great next step.

Frequently asked questions

Is Cypher hard to learn if I already know SQL? No, and this article is the argument. Most of the ten translations are simple keyword swaps. The ideas that take real learning are implicit grouping (query 5), the WITH pipeline (query 6), and thinking of joins as stored relationships (query 3). This tends to click quickly for SQL developers.

Do I have to give up SQL to use Neo4j? No. The common production pattern is to use both. The relational database keeps the transactional, tabular workloads. The graph handles the relationship-heavy queries. The dataset in this article exists in both databases at once, built from the same CSVs.

Is Cypher actually faster, or just shorter? Shorter is what this article shows. Faster depends on the workload, and it deserves measurement. The structural argument goes like this: graph traversal follows stored pointers (called index-free adjacency) instead of recomputing joins at every hop. So cost scales with the part of the graph you touch, not the size of the tables you join. That difference grows with depth. For single-hop lookups, a well-indexed relational database is extremely hard to beat. And the naive recursive CTE handled this nearly cycle-free dataset without drama.

Why did dep_type become a relationship type instead of a property? Because runtime and dev dependencies are different kinds of connection, and queries almost always want one kind or the other. Making the difference structural means the common case needs no filter, and the forgotten-filter bug cannot happen. The semver range stayed a property because it is a fact about an edge you already selected, not a way of selecting edges. That is the general rule. Things you filter on want to be types. Things you read afterward want to be properties.

Does Cypher work outside Neo4j?

Increasingly, yes. Cypher was the main input to GQL, the ISO-standard graph query language published in 2024. Open implementations of Cypher exist for several other engines. The skills transfer.

Comments (0)

Loading comments...