Back openDesk Edu for a sovereign, open-source education β every vote counts.
Vote nowGraph databases excel at storing and querying relationships, but the real value emerges when you analyse the structure of those relationships. Who are the most influential people in a social network? Which transactions form a suspicious ring? How do communities form around topics in a knowledge graph?
These questions demand graph algorithms β and Neo4j's Graph Data Science (GDS) library brings them into production without exporting data to a separate analytics environment. GDS provides over 50 algorithms implemented for performance, with parallel execution, memory-efficient graph projections, and incremental loading.
This article walks through the three families of algorithms that deliver the most immediate value in production: centrality, community detection, and pathfinding β with real Cypher and GDS examples you can run today.
GDS ships as a Neo4j plugin. If you are running Neo4j in Docker, add the plugin volume:
volumes:
- ./plugins:/plugins
environment:
NEO4J_dbms_security_procedures_unrestricted: gds.*
Then install the jar matching your Neo4j version from the Neo4j GDS releases page. Once installed, verify:
CALL gds.version();
All GDS algorithms work on named graphs β in-memory projections of your stored graph. This decouples your operational schema from your analytics workload:
CALL gds.graph.project(
'myGraph',
['Person', 'Company'],
{KNOWS: {orientation: 'UNDIRECTED'}, INVESTED_IN: {orientation: 'NATURAL'}}
);
A projected graph lives in memory, is shared across algorithms, and can be streamed, written back, or dropped when done.
Every GDS algorithm exposes up to five execution modes, and choosing the wrong one is the most common production mistake. Understanding the difference between them is essential for building efficient pipelines.
| Mode | Behaviour | Writes to DB? | Memory | Use Case |
|---|---|---|---|---|
estimate | Dry-run to predict memory usage | β | Read-only | Capacity planning before running expensive algorithms |
stats | Compute and return summary statistics | β | Full run | Exploring community structure without storing results |
stream | Compute and stream results row-by-row | β | Full run | Development, ad-hoc analysis, feeding results into application logic |
mutate | Write result back to the in-memory projected graph | β (in-memory only) | Full run | Multi-step pipelines β writes intermediate results for the next algorithm |
write | Write result to the stored graph (Neo4j) | β | Full run | Final step that persists results for querying |
Before running an algorithm on a large graph, run estimate to check memory feasibility:
CALL gds.pageRank.write.estimate('myGraph', {
writeProperty: 'pagerank'
})
YIELD nodeCount, relationshipCount, bytesMin, bytesMax, requiredMemory;
If requiredMemory exceeds your available GDS heap, you need to either reduce the graph projection size or allocate more memory to Neo4j. The estimate is conservative β actual memory may be lower β but it prevents the out-of-memory crashes that plague under-provisioned GDS deployments.
stream mode returns results as Cypher rows, suitable for exploratory analysis and application logic:
CALL gds.pageRank.stream('myGraph')
YIELD nodeId, score
MATCH (n) WHERE id(n) = nodeId
RETURN n.name AS entity, score
ORDER BY score DESC;
Streaming uses Cypher's YIELD to process results incrementally, so you can LIMIT early and avoid materialising the full result set. This is the preferred mode during development.
mutate mode writes algorithm results as node or relationship properties on the in-memory projected graph. The stored Neo4j database is untouched. This is the critical mode for multi-algorithm pipelines:
// Step 1: Run PageRank and store in projected graph
CALL gds.pageRank.mutate('myGraph', {
mutateProperty: 'pagerank'
});
// Step 2: Run Louvain using PageRank as a node weight
CALL gds.louvain.mutate('myGraph', {
nodeWeightProperty: 'pagerank',
mutateProperty: 'communityId'
});
// Step 3: Write final results to the stored graph
CALL gds.graph.nodeProperties.write('myGraph', ['pagerank', 'communityId']);
Because mutate avoids round-tripping through the stored database, it is significantly faster than chaining stream β application code β write for each algorithm. For production pipelines that run multiple algorithms on the same projection, always use mutate.
stats mode computes the algorithm but returns only aggregated metrics β no per-node output. It is ideal for tuning algorithm parameters without paying the cost of materialising results:
CALL gds.louvain.stats('myGraph')
YIELD communityCount, modularity, modularityValues
RETURN communityCount, modularity;
Use stats during parameter tuning (trying different maxLevels or tolerance values) before committing to a full write run.
Centrality answers "which nodes matter most?" Different algorithms reveal different kinds of importance.
PageRank measures influence based on incoming connections β a node is important if other important nodes link to it. It is the foundation of recommendation engines, fraud detection, and knowledge graph summarisation.
CALL gds.pageRank.stream('myGraph')
YIELD nodeId, score
RETURN gds.util.asNode(nodeId).name AS entity, score
ORDER BY score DESC
LIMIT 10;
In a knowledge graph of research papers, PageRank surfaces the seminal works that the field builds on. In a transaction graph, it flags accounts that receive money from many other high-value accounts β a classic money-laundering signal.
Betweenness measures how often a node lies on the shortest paths between all other pairs of nodes. High-betweenness nodes are bridges β they connect otherwise disconnected parts of the graph.
CALL gds.betweenness.stream('myGraph')
YIELD nodeId, score
RETURN gds.util.asNode(nodeId).name AS entity, score
ORDER BY score DESC
LIMIT 10;
In a supply chain graph, the node with the highest betweenness is the single point of failure. In an organisational chart, it is the person who connects teams β lose them and collaboration breaks down.
The simplest measure: count of relationships. In directed graphs, you can separate in-degree (influence) from out-degree (reach).
CALL gds.degree.stream('myGraph', {orientation: 'REVERSE'})
YIELD nodeId, score
RETURN gds.util.asNode(nodeId).name AS entity, score AS indegree
ORDER BY indegree DESC
LIMIT 10;
Community detection finds groups of nodes that are more densely connected internally than to the rest of the graph. This is where graph algorithms outperform every other analytic technique.
Louvain iteratively moves nodes between communities to maximise modularity β a measure of how well the graph partitions into communities. It is fast, scalable to millions of nodes, and requires no prior knowledge of the number of communities.
CALL gds.louvain.stream('myGraph')
YIELD nodeId, communityId
RETURN communityId, collect(gds.util.asNode(nodeId).name) AS members
ORDER BY size(members) DESC;
Microsoft's GraphRAG uses a variant of this to organise retrieved entities into community summaries. In a customer graph, Louvain naturally segments users into interest groups without any manual labelling.
The simplest community algorithm: find islands of connectivity. Two nodes are in the same WCC if there is any undirected path between them.
CALL gds.wcc.stream('myGraph')
YIELD nodeId, componentId
RETURN componentId, count(*) AS size
ORDER BY size DESC;
WCC is indispensable for data quality. In a knowledge graph built from multiple sources, WCC reveals whether your entity resolution worked β if the same real-world entity appears in multiple components, your merge logic has gaps.
LPA propagates labels through the graph: each node adopts the most frequent label among its neighbours. It is semi-supervised β seed a few known labels and let the algorithm infer the rest.
CALL gds.labelPropagation.stream('myGraph')
YIELD nodeId, communityId
RETURN communityId, collect(gds.util.asNode(nodeId).name) AS members
ORDER BY size(members) DESC;
Use LPA when you have ground-truth labels for a subset of nodes and want to classify the rest β for example, flagging fraudulent merchants from a small seed set.
The existing Graph Theory for Software Engineers article covers BFS and Dijkstra conceptually. GDS brings these to production with Delta-Stepping (a parallel variant of Dijkstra) and Yen's k-shortest-paths:
MATCH (a:Location {name: 'Berlin'}), (b:Location {name: 'Munich'})
CALL gds.shortestPath.dijkstra.stream('roadGraph', {
sourceNode: a,
targetNode: b,
relationshipWeightProperty: 'distance_km'
})
YIELD nodeIds, totalCost
RETURN [nodeId IN nodeIds | gds.util.asNode(nodeId).name] AS route, totalCost;
| Problem | Algorithm | Family | When to Use |
|---|---|---|---|
| Find influencers | PageRank | Centrality | Recommendation, content ranking, expert finder |
| Find bridges | Betweenness | Centrality | Supply chain risk, org analysis, fault tolerance |
| Segment users | Louvain | Community | Customer segmentation, topic clustering |
| Detect fraud rings | WCC + PageRank | Hybrid | Transaction laundering, bot detection |
| Classify nodes | Label Propagation | Community | Semi-supervised ML with sparse labels |
| Shortest route | Delta-Stepping | Pathfinding | Logistics, network routing, dependency resolution |
A real fraud detection system combines multiple GDS algorithms:
// Step 1: Project the transaction graph
CALL gds.graph.project(
'fraudGraph',
'Account',
{TRANSFERRED_TO: {orientation: 'UNDIRECTED'}}
);
// Step 2: Community detection
CALL gds.louvain.stream('fraudGraph')
YIELD nodeId, communityId
WITH communityId, collect(gds.util.asNode(nodeId)) AS accounts
WHERE size(accounts) > 5 AND size(accounts) < 50
// Report mid-size communities β typical fraud ring size
RETURN communityId, [acc IN accounts | acc.id] AS accountIds,
size(accounts) AS memberCount;
Production pipelines rarely stop at a single algorithm. The real value comes from running algorithms in sequence β using the output of one as the input to the next. The mutate execution mode makes this efficient by keeping intermediate results in the in-memory projected graph, avoiding round-trips through the stored database.
Consider a customer segmentation pipeline that scores each user by influence within their community, then enriches the stored graph with both the community ID and the influence score:
// Step 1: Project the customer interaction graph
CALL gds.graph.project(
'customerGraph',
'Customer',
{TRANSFERRED_TO: {orientation: 'UNDIRECTED'},
SHARED_ACCOUNT: {orientation: 'UNDIRECTED'}}
);
// Step 2: Estimate memory before proceeding
CALL gds.louvain.write.estimate('customerGraph', {
writeProperty: 'communityId'
})
YIELD requiredMemory;
// Step 3: Run Louvain and store community IDs in the projected graph
CALL gds.louvain.mutate('customerGraph', {
mutateProperty: 'communityId',
maxLevels: 5
})
YIELD communityCount, modularity;
// Step 4: Use community membership as input to PageRank
// (eigenvector centrality with community-aware initialisation)
CALL gds.pageRank.mutate('customerGraph', {
mutateProperty: 'influenceScore',
tolerance: 0.001,
maxIterations: 100
})
YIELD ranIterations;
// Step 5: Write both properties to the stored Neo4j graph in one call
CALL gds.graph.nodeProperties.write('customerGraph', ['communityId', 'influenceScore']);
// Step 6: Drop the projection
CALL gds.graph.drop('customerGraph');
After this pipeline completes, every :Customer node has a communityId and influenceScore. You can now query with both:
-- Find the most influential customers in each community
MATCH (c:Customer)
RETURN c.communityId AS community,
c.name AS name,
c.influenceScore AS score
ORDER BY c.communityId, c.influenceScore DESC;
What if you already ran Louvain last week and wrote communityId to the database? You can load that property into a new projection and use it in the next algorithm:
CALL gds.graph.project(
'scoredCustomers',
'Customer',
{TRANSFERRED_TO: {orientation: 'UNDIRECTED'}},
{nodeProperties: ['communityId', 'influenceScore']} // Load stored properties
);
// Use communityId as seed for Label Propagation
CALL gds.labelPropagation.mutate('scoredCustomers', {
seedProperty: 'communityId', // Initialise from existing communities
mutateProperty: 'refinedCommunity'
});
// Node similarity weighted by existing influence scores
CALL gds.nodeSimilarity.stream('scoredCustomers', {
nodeWeightProperty: 'influenceScore',
topK: 5
})
YIELD node1, node2, similarity
RETURN gds.util.asNode(node1).name AS customer1,
gds.util.asNode(node2).name AS customer2,
similarity
ORDER BY similarity DESC;
This pattern β project existing properties as node weights, run a new algorithm, write back β is the foundation of incremental graph analytics, where the graph is enriched over time without reprocessing from scratch.
GDS algorithms run in-memory, so the size of your projected graph matters:
CALL gds.graph.project.cypher('g', 'MATCH (n:Person) RETURN id(n) AS id', 'MATCH (n:Person)-[:KNOWS]->(m:Person) RETURN id(n) AS source, id(m) AS target')NATURAL orientation unless you need undirected traversal β it halves memorystream mode during development and write mode only for the final pipelineThe most common production surprise with GDS is underestimating memory. A projected graph of 10M nodes with 50M relationships can consume several gigabytes of RAM β and different algorithms amplify that baseline differently. Use gds.graph.list to inspect your projection before running algorithms:
CALL gds.graph.list('myGraph')
YIELD nodeCount, relationshipCount, memoryUsage, sizeInBytes;
The table below gives rough memory estimates for a 10M-node, 50M-edge projection running on a machine with 32 GB available to GDS:
| Algorithm Family | Memory Multiplier | Estimated RAM | Typical Runtime | Bottleneck |
|---|---|---|---|---|
| WCC / Degree | 1Γ (baseline) | ~1.5 GB | Seconds | I/O (graph load) |
| PageRank (20 iterations) | 2β3Γ | ~3β5 GB | Minutes | CPU (iteration count) |
| Louvain | 3β4Γ | ~5β6 GB | Minutes | Memory (modularity tracking) |
| Betweenness Centrality | 4β8Γ | ~6β12 GB | Hours | CPU (all-pairs shortest paths) |
| Delta-Stepping (Dijkstra) | 1.5Γ per concurrent query | ~2.5 GB + per-query | Variable | Priority queue contention |
Memory Γ node count is roughly linear for sparse graphs β double the nodes at the same average degree, double the memory. Betweenness Centrality is the outlier: its memory scales with the number of shortest paths it tracks, which grows quadratically in dense subgraphs.
Concurrency tuning is the second lever. GDS runs algorithms on a thread pool; readConcurrency and writeConcurrency control parallelism during computation and result writing. The defaults (4) are conservative:
CALL gds.pageRank.stream('myGraph', {
readConcurrency: 8,
writeConcurrency: 4
})
A good starting heuristic: set readConcurrency to your available CPU cores and writeConcurrency to half that β writes contend on the Neo4j store lock, so over-parallelising them causes back-pressure.
Graph algorithms transform a graph database from a passive store of relationships into an active analytics engine. Neo4j GDS makes this practical in production β you run the same algorithms that power Google's search ranking and Facebook's community detection on your own data, without leaving Cypher.
Start with centrality to find what matters, community detection to discover hidden structure, and pathfinding to connect it all together. The algorithms are well-understood; the hard part is knowing which one to apply. The table above should give you a starting point, but the real learning comes from running them against your own data and observing what emerges.