Choosing Between GDS and Cypher by Query Shape
“Which is faster, GDS or Cypher?” has no answer, and chasing one produces benchmarks that are individually correct and collectively useless — GDS wins the test that keeps a projection resident, Cypher wins the one that does not, and both results are quoted at each other indefinitely. The question that does have an answer is narrower: for this query shape, at this update rate, at this request volume, which one is cheaper? Three properties settle it, none of them requires a measurement to decide, and the measurement is then worth running to confirm rather than to discover. This page gives the procedure and the cases where it points somewhere unexpected.
Prerequisites & Versions
The decision needs no code; the confirmation reuses the existing benchmark harness.
| Requirement | Minimum version | Install |
|---|---|---|
| Python | 3.11 | — |
| neo4j (async driver) | 5.20 | pip install "neo4j>=5.20" |
| Neo4j Server | 5.15 | — |
| Graph Data Science | 2.6 | server plugin |
Implementation
from dataclasses import dataclass
from enum import Enum
class Shape(Enum):
POINT_TO_POINT = "one source, one target"
ONE_TO_MANY = "one source, many targets"
MANY_TO_MANY = "many sources, many targets"
WHOLE_GRAPH = "every node — centrality, components, communities"
@dataclass(frozen=True)
class Workload:
shape: Shape
requests_per_hour: float
graph_changes_per_hour: float
custom_cost_per_request: bool # does the weight depend on the caller?
@property
def projection_reuses(self) -> float:
"""How many requests one projection can serve before it goes stale.
This is the number the whole decision turns on. A projection is a
snapshot, so its useful life ends at the next topology or weight change;
everything it can serve in that window shares its build cost.
"""
if self.graph_changes_per_hour <= 0:
return float("inf")
return self.requests_per_hour / self.graph_changes_per_hour
def recommend(w: Workload) -> tuple[str, str]:
# A per-request cost function cannot be projected: the weight is not a
# property of the graph, it is a property of the request. This overrides
# everything else, including volume.
if w.custom_cost_per_request:
return ("Cypher", "the weight depends on the caller, so no projection "
"can be shared between requests")
# Whole-graph algorithms have no Cypher equivalent worth writing. The
# projection is not an optimisation here, it is the only implementation.
if w.shape is Shape.WHOLE_GRAPH:
return ("GDS", "no practical Cypher formulation; the projection is the "
"implementation rather than an accelerator")
# Many-to-many is where the asymmetry is largest: one projection serves the
# entire matrix, and Cypher would pay a traversal per cell.
if w.shape is Shape.MANY_TO_MANY:
return ("GDS", "one projection amortises across every cell of the matrix")
# One-to-many settles every reachable node in a single pass either way, but
# GDS does it against a packed array rather than the store.
if w.shape is Shape.ONE_TO_MANY and w.projection_reuses >= 10:
return ("GDS", f"~{w.projection_reuses:.0f} requests per projection build")
if w.projection_reuses < 5:
return ("Cypher", f"only ~{w.projection_reuses:.1f} requests per build — "
"the projection never pays for itself")
return ("either — measure", f"~{w.projection_reuses:.0f} reuses puts this in "
"the band where the constants decide")
if __name__ == "__main__":
cases = [
Workload(Shape.POINT_TO_POINT, 40_000, 1, False), # busy, stable
Workload(Shape.POINT_TO_POINT, 200, 60, False), # live traffic feed
Workload(Shape.MANY_TO_MANY, 24, 1, False), # nightly fleet plan
Workload(Shape.POINT_TO_POINT, 5_000, 1, True), # per-user preferences
Workload(Shape.WHOLE_GRAPH, 1, 1, False), # centrality report
]
for w in cases:
choice, why = recommend(w)
print(f"{w.shape.value:<34}{choice:<16}{why}")
How It Works
Three properties decide it, and they are checked in order of how decisively they settle the question.
A per-request cost function rules out projection entirely. If the weight depends on the caller — a vehicle profile, a set of avoided roads, a time-of-day preference — then no two requests can share a projection, and building one per request is the worst of both approaches. This overrides request volume completely: a million requests an hour with a million distinct cost functions still cannot amortise anything.
Shape decides how much one projection is worth. A point-to-point query uses a tiny fraction of the graph it projected; a many-to-many matrix uses all of it, repeatedly. That is why the same projection cost is extravagant in one case and negligible in the other, and it is why “is GDS faster” is unanswerable without naming the shape.
Reuse count is the arithmetic that settles the remaining cases. Requests divided by graph changes gives how many queries one projection can serve before it goes stale. Below about five the build dominates and Cypher wins; above about ten the build is noise and GDS wins; between them the constants matter and a measurement is genuinely required — which is the only band where the benchmark harness is answering a question rather than confirming one.
Common Failure Patterns
1. Benchmarking a warm projection against cold Cypher. The commonest way to produce a misleading number. Timing gds.shortestPath.stream against a resident projection and comparing it with a Cypher traversal that includes its own first-execution compile measures two different things. The honest comparison brackets the full projection lifecycle on one side and a warmed plan on the other.
2. Treating “the graph changes” as binary. What matters is whether the change affects the projected properties. A graph whose POI attributes update constantly but whose topology and travel times are weekly has a projection reuse count based on the weekly figure, not the constant one. Projecting only the properties the algorithm reads is what makes that distinction real rather than notional.
3. Choosing once and never revisiting. The reuse count is a ratio of two numbers that both move — request volume grows, and update frequency changes when a live traffic feed is added. A choice that was clearly right at launch can be clearly wrong two quarters later, with no code change to prompt a review. It is worth recording the ratio as a metric so the crossing is visible.
Performance Notes
The break-even is a straightforward comparison of totals over the projection’s lifetime:
$$n \cdot t_{\text{cypher}} \quad\text{vs}\quad t_{\text{project}} + n \cdot t_{\text{gds}}, \qquad n^{*} = \frac{t_{\text{project}}}{t_{\text{cypher}} - t_{\text{gds}}}$$
The useful thing about this form is that it makes the sensitivity obvious. $t_{\text{project}}$ scales with graph size, so a larger graph raises the break-even and pushes marginal cases toward Cypher. The denominator is the per-query saving, which is largest exactly where the search touches a lot of the graph — so one-to-many shapes reach break-even in a handful of requests while point-to-point shapes need hundreds.
Two architectural options change the arithmetic rather than sitting inside it. A subgraph projection — projecting one region rather than the country — cuts $t_{\text{project}}$ by an order of magnitude and can move a marginal case decisively, at the cost of a request that leaves the region failing to find a path. And a scheduled rebuild with an atomic swap decouples the projection’s life from the update rate: rebuild nightly into a new named graph and swap, and the reuse count becomes requests-per-night regardless of how often the underlying data moves, at the cost of serving costs that are up to a day stale.
Where neither helps and the shape is point-to-point at high volume against a static graph, the answer is usually neither engine but a contraction hierarchy, which pays a much larger preprocessing cost once and makes every subsequent query cheaper than either option here.
A final note on how this decision tends to be made in practice, which is worth naming because it is the most common failure of all. Teams pick an engine early, build around it, and the choice then defends itself: the code is written for that engine, the operational knowledge is about that engine, and revisiting means a rewrite. That is a reasonable reason to be slow to change, and a poor reason to never look — particularly since the ratio that decides it moves on its own.
The cheap hedge is to keep the routing call behind a narrow interface from the start. Both engines answer the same question and return the same shape of answer, so a single method taking a source, a target and a cost property covers the overwhelming majority of usage. With that boundary in place, switching is a configuration change and a benchmark rather than a project, which means the decision can be revisited when the numbers say so rather than when someone has budget for a migration. It also makes the comparison honest, because the same caller exercises both paths against the same graph — which is exactly the measurement the benchmark harness struggles to construct after the fact.
Related
- Neo4j GDS vs Cypher Routing — the comparison this procedure formalises.
- Benchmarking GDS shortestPath Against Hand-Written Cypher — how to measure the band where the decision is genuinely open.
- Many-to-Many Cost Matrices with Neo4j GDS — the shape where the answer is never in doubt.
- Tuning JVM Heap for GDS Projections — the cost the reuse count is amortising.
This guide is part of Neo4j GDS vs Cypher Routing, within Network Routing Algorithms in Python.