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:
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:

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:
MATCH (:Package {name: 'express'})-[:DEPENDS_ON*]->(dep)
RETURN count(DISTINCT dep) AS transitive_depsThat is not a shortened excerpt. That is the whole query. It returns the same 63 packages.

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.

packages(package_name, description, license, latest_version, weekly_downloads, deprecated)dependencies(package_name, depends_on_name, dep_type, semver_range), the join tablemaintainers(username)andpackage_maintainers(username, package_name)
Graph (Neo4j): the same facts as nodes and relationships.

(: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:
SELECT package_name, description
FROM packages
WHERE license = 'MIT';
The same query in Cypher:
MATCH (p:Package) // (1)
WHERE p.license = 'MIT' // (2)
RETURN p.name, p.description // (3)- 1
MATCH (p:Package)plays the role ofFROM packages. It binds every node with thePackagelabel to the variablep. - 2
WHEREfilters the matched rows, exactly as it does in SQL. - 3
RETURNisSELECT, moved to the end so the query reads in the order the work happens: find, filter, return.

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:
SELECT package_name, weekly_downloads
FROM packages
ORDER BY weekly_downloads DESC
LIMIT 10;The same question in Cypher:
MATCH (p:Package)
RETURN p.name, p.weeklyDownloads
ORDER BY p.weeklyDownloads DESC
LIMIT 10Same 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:
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:
MATCH (:Package {name: 'express'})-[d:DEPENDS_ON]->(dep:Package)
RETURN dep.name, d.rangeThis 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:
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:
MATCH (:Package {name: 'express'})-[:DEPENDS_ON*2]->(dep:Package)
RETURN DISTINCT dep.nameEach 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?
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:
MATCH (p:Package)-[:DEPENDS_ON]->()
RETURN p.name, count(*) AS depCount
ORDER BY depCount DESC
LIMIT 10There 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:
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:
MATCH (p:Package)<-[:DEPENDS_ON]-()
WITH p, count(*) AS dependents
WHERE dependents > 100
RETURN p.name, dependents
ORDER BY dependents DESCTwo 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:
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.
MATCH (p:Package)
OPTIONAL MATCH (p)<-[d:DEPENDS_ON]-()
WITH p, d
WHERE d IS NULL
RETURN p.nameBut 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:
MATCH (p:Package)
WHERE NOT EXISTS { (p)<-[:DEPENDS_ON]-() }
RETURN p.nameThis 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, andlodash. 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:
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:
MATCH (p:Package)
WHERE EXISTS { (p)-[:DEPENDS_ON]->(:Package {name: 'react'}) }
AND EXISTS { (p)-[:DEPENDS_ON]->(:Package {name: 'vue'}) }
RETURN p.nameSame 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:
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:
MATCH (:Package {name: 'lodash'})<-[:MAINTAINS]-(:Maintainer)-[:MAINTAINS]->(p:Package)
RETURN DISTINCT p.nameThe 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:
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:
MATCH (affected:Package)-[:DEPENDS_ON*]->(:Package {name: 'debug'})
RETURN count(DISTINCT affected) AS affectedSame 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 SQL | You write this in Cypher | Translation difficulty |
|---|---|---|
FROM table alias | MATCH (alias:Label) | Automatic |
WHERE | WHERE (or inline {prop: value}) | Automatic |
SELECT | RETURN (at the end) | Automatic |
ORDER BY / LIMIT / DISTINCT | Same keywords | Automatic |
JOIN ... ON | A relationship in the pattern: -[:REL]-> | Shape change |
| A type/discriminator column | A relationship type | Modeling shift |
GROUP BY | Implicit: plain terms next to aggregates | Shape change |
HAVING | WITH ... WHERE | Shape change |
LEFT JOIN | OPTIONAL MATCH | Automatic |
EXISTS (subquery) | EXISTS { pattern } | Shape change |
| Self-join on an association table | The V-walk: (a)<-[:R]-(x)-[:R]->(b) | Modeling shift |
| Recursive CTE | -[:REL*]->, direction by arrow | Different 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 BYover 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_typeshould 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...