Graph Algorithms in Production with Neo4j GDS
Why Graph Algorithms in Production?
Graph 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.
Setting Up GDS
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.
GDS Execution Modes — Five Paths to a Result
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 |
Estimate — Always Run First
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 — Development and Ad-Hoc Analysis
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 — The Pipeline Backbone
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 — Exploring Without Overhead
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 Algorithms
Centrality answers "which nodes matter most?" Different algorithms reveal different kinds of importance.
PageRank
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 Centrality
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.
Degree Centrality
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
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 (Modularity Optimisation)
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.
Weakly Connected Components (WCC)
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.
Label Propagation (LPA)
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.
Pathfinding
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;
Algorithm Selection Guide
| 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 |
Putting It Together: Fraud Detection Pipeline
A real fraud detection system combines multiple GDS algorithms:
- WCC on the transaction graph to find connected components
- PageRank within each component to score account influence
- Louvain to find tightly-knit subgroups (potential fraud rings)
- Betweenness to identify mule accounts that bridge legitimate and fraudulent clusters
// 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;
Chaining Algorithms with Mutate Mode
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.
End-to-End Example: Customer 360 with Community-Aware Scoring
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;
Using Node Properties from Previous Runs
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.
Performance Considerations
GDS algorithms run in-memory, so the size of your projected graph matters:
- Project only what you need: filter nodes and relationships with Cypher projections:
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') - Use
NATURALorientation unless you need undirected traversal — it halves memory - Tier your algorithms: PageRank and Betweenness are O(n²) or worse on dense graphs. Run WCC as a cheap filter first, then run expensive algorithms on the largest component only
- Stream, don't write: use
streammode during development andwritemode only for the final pipeline
Memory Estimation and Concurrency Tuning
The 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.
Conclusion
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.
Further Reading
- Community Detection Algorithms — A deeper dive into Louvain, Leiden, Label Propagation, and K-Clique with Python and Cypher examples.
- Ontology in Graph Databases — How ontology design affects which graph patterns and algorithms are effective in production.
- Graph Theory for Software Engineers — The fundamentals these algorithms build on, from BFS to Dijkstra.
- Knowledge Graphs as a Context Layer for AI Agents — Using graph algorithms to power retrieval-augmented generation pipelines.
- Graph Data Modeling with Neo4j — Patterns and anti-patterns for designing graphs that can be queried efficiently with GDS.
- Neo4j GDS Documentation — Official reference for all 50+ algorithms, their parameters, and execution modes.