Calling Graph Data Science from Spring Boot

This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases.

This section’s bibliography lists the reference material consulted while preparing these pages.

Every use case in this section ends up calling Graph Data Science (GDS) from Java: project a subgraph into memory, run an algorithm on it, read or persist the result, and release the memory. This page fixes that recipe once — the helper classes, the transaction rules, the memory guard and the scheduling pattern — so that the use-case pages only show the Cypher and the domain code that is specific to them. The algorithms themselves are explained in Graph Data Science Fundamentals, and the mapping and Neo4jClient basics in Spring Data Neo4j; neither is repeated here.

GDS is Cypher: there is no Java client

The GDS manual describes its API as Cypher procedures and functions, and Neo4j publishes no official Java client library for it. A Spring Boot application therefore calls GDS exactly like any other Cypher: through Neo4jClient (or a @Query method on a repository), with string statements, bound parameters and result rows mapped by hand. The consequence is that the application owns three things a client library would otherwise hide: the lifecycle of the in-memory graph, the mapping of YIELD columns to Java types, and the guards (memory, concurrency, duplicate runs) around long jobs. The rest of this page is those three things.

A GDS procedure only runs where the plugin is installed and allowed. With a manual install, the plugin jar goes into the plugins folder and the configuration needs dbms.security.procedures.unrestricted=gds.* (plus dbms.security.procedures.allowlist=gds.* when an allow list is active); the Docker images install it for you through the NEO4J_PLUGINS variable shown in the setup notes. See GDS — Neo4j Server installation.

Setup notes and version baseline

The pages of this section are written against the following baseline, checked against the official documentation:

Component Baseline

Spring Boot

4.1.2 (Spring Framework 7.0.x)

Spring Data Neo4j

8.1.2, managed by the Spring Boot 4.1.2 dependency versions

Neo4j Java Driver

6.1.0, managed by the Spring Boot 4.1.2 dependency versions

Testcontainers Neo4j module

org.testcontainers:testcontainers-neo4j 2.0.5, managed by Spring Boot

Neo4j and GDS

Neo4j 2026.10.x with GDS 2026.10 (the Neo4j and GDS year.month must match)

APOC Core

2026.10.x for Neo4j 2026.10.x (the year and month must match the server)

The versions of Spring Data Neo4j and the driver come from the Spring Boot — Dependency Versions appendix, so a project that inherits the Boot dependency management declares neither version. The Neo4j/GDS pairing comes from the GDS — Supported Neo4j versions table and the APOC pairing from the APOC — Installation page.

The connection is configured with the standard Spring Boot properties (Spring Boot — NoSQL, Neo4j):

spring:
  neo4j:
    uri: bolt://localhost:7687
    authentication:
      username: neo4j
      password: ${NEO4J_PASSWORD}
  data:
    neo4j:
      database: neo4j        # the database SDN and Neo4jClient use; omit it for the default database

Integration tests start a real server with the GDS and APOC plugins through Neo4jContainer, and @ServiceConnection replaces the connection properties above with the container’s address (Spring Boot — Testcontainers):

import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.neo4j.Neo4jContainer;

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;

@Testcontainers
@SpringBootTest
class GdsSmokeTest {

    @Container
    @ServiceConnection
    static final Neo4jContainer NEO4J = new Neo4jContainer("neo4j:2026.10.1")
        .withEnv("NEO4J_PLUGINS", "[\"graph-data-science\", \"apoc\"]");

    // tests call the application beans, which talk to the container through Neo4jConnectionDetails
}

The NEO4J_PLUGINS value is the same one the plugin documentation uses for Docker (GDS — Docker installation, APOC — Installation); both pages describe it as a convenience for development, and a production image mounts the matching jars instead. The fixtures, container reuse and deterministic assertions are the subject of the testing page of this section.

Procedure names used in this section

The use-case pages call the following procedures and functions. The names are the ones the current manual documents for GDS 2026.10; the tier column records what the manual says about each.

Algorithm Procedures and functions Tier as documented

Weakly Connected Components

gds.wcc.stream, .stats, .mutate, .write

No tier label on the page

Louvain

gds.louvain.stream, .stats, .mutate, .write

No tier label on the page

Betweenness Centrality

gds.betweenness.stream, .stats, .mutate, .write (samplingSize, samplingSeed)

No tier label on the page

Node Similarity

gds.nodeSimilarity.stream, .stats, .mutate, .write

No tier label on the page

Filtered Node Similarity

gds.nodeSimilarity.filtered.stream, .stats, .mutate, .write (sourceNodeFilter, targetNodeFilter)

No tier label on the page

K-Nearest Neighbors

gds.knn.stream, .stats, .mutate, .write (nodeProperties, topK, randomSeed with concurrency: 1)

No tier label on the page

Dijkstra Source-Target

gds.shortestPath.dijkstra.stream, .mutate, .write (sourceNode, targetNodes; targetNode is deprecated)

No tier label on the page

A*

gds.shortestPath.astar.stream, .mutate, .write (sourceNode, targetNode, latitudeProperty, longitudeProperty)

No tier label on the page

Yen’s k-shortest paths

gds.shortestPath.yens.stream, .mutate, .write (sourceNode, targetNode, k)

No tier label on the page

Strongly Connected Components

gds.scc.stream, .stats, .mutate, .write

No tier label on the page

Topological link prediction

functions gds.linkprediction.adamicAdar and gds.linkprediction.commonNeighbors taking two nodes and a configuration map with relationshipQuery and direction

The function pages carry no tier label; the algorithm overview lists the topological link prediction family as Alpha

Link prediction pipelines

gds.beta.pipeline.linkPrediction.create, .addNodeProperty, .addFeature, .configureSplit, .addLogisticRegression, .addRandomForest, gds.beta.pipeline.linkPrediction.train, gds.beta.pipeline.linkPrediction.predict.stream and .predict.mutate

Beta

Every algorithm procedure also has a .estimate variant that works with any execution mode, for example gds.louvain.write.estimate. See the GDS manual pages Algorithms, Topological link prediction — Adamic Adar and Link prediction pipelines. Treat Alpha and Beta procedures as subject to change between releases, and pin the Neo4j image tag in tests so that an upgrade is a deliberate change.

The projection lifecycle

Whatever the algorithm, a GDS job has the same five steps, and the catalog entry it creates lives in the memory of the Neo4j server until somebody drops it:

sequenceDiagram participant J as Job (scheduler or endpoint) participant S as AnalyticsService participant G as GdsGraphs helper participant N as Neo4j with GDS J->>S: run(parameters) S->>G: estimate and project(name, parameters) G->>N: gds.graph.project.estimate N-->>G: bytesMax G->>N: gds.graph.project(name, ...) N-->>G: nodeCount, relationshipCount G-->>S: ProjectedGraph (AutoCloseable) S->>N: gds.algorithm.stream, stats, mutate or write(name, config) N-->>S: result rows S->>S: map rows to records S->>G: close() in finally G->>N: gds.graph.drop(name, false) S-->>J: result

The steps of the loop are project (copy the selected nodes and relationships into the catalog), run in one of the four execution modes (stream returns rows, stats returns a summary, mutate stores the result on the in-memory graph for the next algorithm, write persists it to the database) and drop. The execution modes and the memory model are explained in Graph Data Science Fundamentals; the part that matters for Java is that a projection is not transactional. A rolled-back Spring transaction does not remove it, an exception between project and drop leaks it, and two jobs that choose the same name collide. Three rules follow:

  • give every run a unique graph name (a prefix that identifies the job plus a random suffix),

  • tie the lifetime of the catalog entry to a Java scope with AutoCloseable and try/finally,

  • drop with failIfMissing set to false, so that cleanup after a failed projection does not throw a second exception that hides the first one.

A small AutoCloseable helper

The helper below is the only GDS-specific infrastructure the use-case pages need. It projects with either a native or a Cypher projection, remembers the generated name, and drops the graph on close():

import java.util.List;
import java.util.Map;
import java.util.UUID;

import org.springframework.data.neo4j.core.Neo4jClient;
import org.springframework.stereotype.Component;

@Component
public class GdsGraphs {

    private final Neo4jClient client;

    public GdsGraphs(Neo4jClient client) {
        this.client = client;
    }

    /** Native projection: node labels and relationship types only. */
    public ProjectedGraph projectNative(String prefix, List<String> nodeLabels,
                                        Map<String, Object> relationshipProjection) {
        var name = uniqueName(prefix);
        var counts = client
            .query("""
                CALL gds.graph.project($name, $nodeLabels, $relationships)
                YIELD nodeCount, relationshipCount
                RETURN nodeCount, relationshipCount
                """)
            .bind(name).to("name")
            .bind(nodeLabels).to("nodeLabels")
            .bind(relationshipProjection).to("relationships")
            .fetchAs(GraphSize.class)
            .mappedBy((typeSystem, record) -> new GraphSize(
                record.get("nodeCount").asLong(), record.get("relationshipCount").asLong()))
            .one()
            .orElseThrow();
        return new ProjectedGraph(client, name, counts);
    }

    /** Cypher projection: the caller supplies a MATCH that ends in the gds.graph.project aggregation. */
    public ProjectedGraph projectCypher(String prefix, String projectionQuery, Map<String, Object> parameters) {
        var name = uniqueName(prefix);
        var counts = client
            .query(projectionQuery)
            .bind(name).to("graphName")
            .bindAll(parameters)
            .fetchAs(GraphSize.class)
            .mappedBy((typeSystem, record) -> new GraphSize(
                record.get("nodeCount").asLong(), record.get("relationshipCount").asLong()))
            .one()
            .orElseThrow();
        return new ProjectedGraph(client, name, counts);
    }

    private static String uniqueName(String prefix) {
        return prefix + "-" + UUID.randomUUID();
    }
}

public record GraphSize(long nodeCount, long relationshipCount) {
}

public final class ProjectedGraph implements AutoCloseable {

    private final Neo4jClient client;
    private final String name;
    private final GraphSize size;

    ProjectedGraph(Neo4jClient client, String name, GraphSize size) {
        this.client = client;
        this.name = name;
        this.size = size;
    }

    public String name() {
        return name;
    }

    public GraphSize size() {
        return size;
    }

    @Override
    public void close() {
        client.query("CALL gds.graph.drop($name, false) YIELD graphName RETURN graphName")
            .bind(name).to("name")
            .run();
    }
}

The native form maps to gds.graph.project(graphName, nodeProjection, relationshipProjection, configuration); the Cypher form is the aggregation-function call shown in the projection section below. The drop call is gds.graph.drop(graphName, failIfMissing, ...), whose failIfMissing argument defaults to true. See GDS — Native projection, GDS — Cypher projection and GDS — Dropping graphs.

Using it reads like any resource scope:

public List<CommunityScore> louvainCommunities(Instant since) {
    try (var graph = graphs.projectCypher("transfers", TRANSFERS_SINCE,
            Map.of("since", since.toString(), "minimumAmount", 0))) {
        return client
            .query("""
                CALL gds.louvain.stream($graph)
                YIELD nodeId, communityId
                RETURN elementId(gds.util.asNode(nodeId)) AS accountId, communityId
                """)
            .bind(graph.name()).to("graph")
            .fetchAs(CommunityScore.class)
            .mappedBy((typeSystem, record) -> new CommunityScore(
                record.get("accountId").asString(), record.get("communityId").asLong()))
            .all()
            .stream()
            .toList();
    }
}

try-with-resources already is try/finally: close() runs whether the algorithm returns, throws, or the thread is interrupted. If the process itself dies between project and drop, the entry stays in the catalog until the server restarts, which is why the helper uses a recognisable prefix: a startup or scheduled cleanup can list the catalog with gds.graph.list and drop the leftovers whose names start with the application’s prefixes. See GDS — Dropping graphs.

Mapping YIELD rows to records

Neo4jClient binds named parameters with .bind(value).to("name") or .bindAll(map), and converts each result row with fetchAs(Type.class).mappedBy(...), a function from the driver’s TypeSystem and Record to the target type; .all() returns every row, .one() an Optional. This is the same API as in the Spring Data Neo4j page, and Java records are the natural result type because a GDS row is a fixed tuple of named columns:

public record RankedAccount(String accountId, double score) {
}

public List<RankedAccount> topBetweenness(ProjectedGraph graph, int limit) {
    return client
        .query("""
            CALL gds.betweenness.stream($graph)
            YIELD nodeId, score
            RETURN elementId(gds.util.asNode(nodeId)) AS accountId, score
            ORDER BY score DESC
            LIMIT $limit
            """)
        .bind(graph.name()).to("graph")
        .bind(limit).to("limit")
        .fetchAs(RankedAccount.class)
        .mappedBy((typeSystem, record) -> new RankedAccount(
            record.get("accountId").asString(), record.get("score").asDouble()))
        .all()
        .stream()
        .toList();
}

Three details recur on every page of this section:

  • A GDS row carries an internal node id (nodeId), which is only meaningful inside the projection. Convert it with gds.util.asNode(nodeId) and return a stable business key or elementId(...); never id(...), which is deprecated and not stable across stores.

  • YIELD lists the columns the Java code reads. Naming them explicitly (and aliasing with AS) keeps the mapper independent of columns that later GDS versions add.

  • The graph name, the algorithm configuration values and every filter value are parameters, never concatenated into the statement. Procedure names and the execution mode cannot be parameters, so they stay in the statement text, chosen from a fixed set in code.

A write or stats call returns one summary row, mapped the same way:

public record WriteSummary(long nodePropertiesWritten, long communityCount) {
}

WriteSummary summary = client
    .query("""
        CALL gds.louvain.write($graph, {writeProperty: $property, concurrency: 1})
        YIELD nodePropertiesWritten, communityCount
        RETURN nodePropertiesWritten, communityCount
        """)
    .bind(graph.name()).to("graph")
    .bind("communityId").to("property")
    .fetchAs(WriteSummary.class)
    .mappedBy((typeSystem, record) -> new WriteSummary(
        record.get("nodePropertiesWritten").asLong(), record.get("communityCount").asLong()))
    .one()
    .orElseThrow();

See Spring Data Neo4j — reference guide for Neo4jClient, and GDS — Louvain for the columns each execution mode yields.

Transaction semantics

Three facts decide where a GDS call can live:

  • Neo4jClient takes part in the Spring transaction. Inside @Transactional it uses the transaction opened by the Neo4jTransactionManager; outside one, each statement runs on its own and commits when it completes. Do not rely on the exact kind of transaction the client opens in that case: statements that only work in an implicit (auto-commit) transaction, such as CALL { ... } IN TRANSACTIONS, are better replaced by a batching loop, as the similarity job of the opportunity page does. See Transactions in the Spring Data Neo4j page.

  • The graph catalog is not transactional. project and drop take effect immediately and do not roll back, whatever surrounds them. Put them in the try/finally of the helper above, not in a transaction you expect to undo them.

  • @Transactional(readOnly = true) selects read access. Spring Data Neo4j opens the session in read mode, which a cluster uses to route the work to a follower. stream and stats calls and ordinary read queries belong in such a method; a write call changes the database, so it must run in a writable transaction. Routing and consistency in a cluster are covered in Clustering and Multi-Database.

@Service
public class RingReadService {

    private final Neo4jClient client;

    public RingReadService(Neo4jClient client) {
        this.client = client;
    }

    @Transactional(readOnly = true)
    public List<RankedAccount> persistedScores(int limit) {
        return client
            .query("""
                MATCH (a:Account)
                WHERE a.fraudScore IS NOT NULL
                RETURN a.accountNumber AS accountId, a.fraudScore AS score
                ORDER BY score DESC
                LIMIT $limit
                """)
            .bind(limit).to("limit")
            .fetchAs(RankedAccount.class)
            .mappedBy((typeSystem, record) -> new RankedAccount(
                record.get("accountId").asString(), record.get("score").asDouble()))
            .all()
            .stream()
            .toList();
    }
}

See Spring Data Neo4j — reference guide and Neo4j Driver manual for the transaction functions underneath.

Long jobs do not belong in a request transaction. A projection followed by an algorithm run can take minutes. Held inside a web request’s @Transactional method it would pin a driver connection and a database transaction for the whole run, hit the request timeout, and, because the catalog is not transactional, still leave the projection behind when the request dies. The pattern used in this section is the opposite: the HTTP endpoint only reads precomputed results, and the heavy projection runs in a background job, in its own short transactions, that writes its results back (for example a communityId or a fraudScore property) for the endpoint to read. For the ordinary OLTP-versus-analytics split and what repositories can express, see Spring Data Neo4j.

Estimating before running

A projection that does not fit in memory fails halfway, and an algorithm that does not fit can starve the rest of the server. GDS estimates memory before it runs and aborts a job it can already tell will not fit, but that check only blocks runs that are certain not to fit, so the application adds a stricter guard of its own: estimate first, compare with a configured budget, and refuse to project when the estimate is above it.

@ConfigurationProperties("analytics.gds")
public record GdsLimits(@DefaultValue("2147483648") long maxEstimatedBytes) {   // 2 GiB unless configured
}

@Component
public class GdsMemoryGuard {

    private final Neo4jClient client;
    private final GdsLimits limits;

    public GdsMemoryGuard(Neo4jClient client, GdsLimits limits) {
        this.client = client;
        this.limits = limits;
    }

    public void requireFits(List<String> nodeLabels, Map<String, Object> relationshipProjection) {
        long bytesMax = client
            .query("""
                CALL gds.graph.project.estimate($nodeLabels, $relationships)
                YIELD bytesMax
                RETURN bytesMax
                """)
            .bind(nodeLabels).to("nodeLabels")
            .bind(relationshipProjection).to("relationships")
            .fetchAs(Long.class)
            .mappedBy((typeSystem, record) -> record.get("bytesMax").asLong())
            .one()
            .orElseThrow();
        if (bytesMax > limits.maxEstimatedBytes()) {
            throw new AnalyticsRejectedException(
                "Projection needs up to %d bytes, budget is %d".formatted(bytesMax, limits.maxEstimatedBytes()));
        }
    }
}

The budget needs a default because an unset long binds to 0, which would reject every job, including the tiny graphs of the integration tests. Size it for the server and set it per environment, for example analytics.gds.max-estimated-bytes=8589934592 on an analytics instance with a large heap; the record is registered by @ConfigurationPropertiesScan or @EnableConfigurationProperties(GdsLimits.class). See Spring Boot — Externalized Configuration.

gds.graph.project.estimate takes the same node and relationship projection as a native projection and yields requiredMemory, bytesMin, bytesMax and the counts it assumed; the algorithm procedures expose the same idea as gds.<algorithm>.<mode>.estimate, which accepts the name of a projected graph (or a projection configuration) and the algorithm configuration. Because a Cypher projection cannot be estimated directly, estimate the native shape that over-approximates the subgraph (the labels and relationship types the MATCH reads) and use the result as an upper bound. A job that is rejected should surface as a clear error to the operator (a 409 or 422 from an endpoint, a logged warning from a scheduled job), not as an out-of-memory failure in the middle of the run. The sudo parameter exists to bypass the server-side check, and this section does not use it. See GDS — Memory estimation.

Async and scheduled execution

Because the projection is expensive, GDS work runs outside the request thread, in one of two shapes: triggered by an endpoint and run asynchronously, or run on a schedule.

For the scheduled shape, @Scheduled triggers the job and ShedLock makes sure that only one instance of a scaled-out service starts it per tick; both are explained in Scheduling and ShedLock, including the choice of lockAtMostFor and lockAtLeastFor. For a GDS job, lockAtMostFor must be longer than the longest realistic run, otherwise a second instance starts a second projection while the first still holds the memory:

@Component
public class FraudAnalyticsJob {

    private static final Logger log = LoggerFactory.getLogger(FraudAnalyticsJob.class);

    private final FraudAnalyticsService analytics;

    public FraudAnalyticsJob(FraudAnalyticsService analytics) {
        this.analytics = analytics;
    }

    @Scheduled(cron = "${analytics.fraud.cron:0 0 2 * * *}")
    @SchedulerLock(name = "fraud-analytics", lockAtMostFor = "PT2H", lockAtLeastFor = "PT5M")
    public void run() {
        try {
            var summary = analytics.recomputeCommunities();
            log.info("Fraud analytics finished: {}", summary);
        } catch (AnalyticsRejectedException e) {
            log.warn("Fraud analytics skipped: {}", e.getMessage());
        }
    }
}

For the on-demand shape, @Async (with @EnableAsync and a bounded executor) moves the run to another thread and returns immediately, so an endpoint can answer 202 Accepted. The same distributed-lock concern applies, because @Async is per JVM; take the ShedLock lock programmatically inside the method, or reject a second trigger with a simple "already running" check:

@Service
public class AnalyticsTrigger {

    private final FraudAnalyticsService analytics;
    private final AtomicBoolean running = new AtomicBoolean();

    public AnalyticsTrigger(FraudAnalyticsService analytics) {
        this.analytics = analytics;
    }

    @Async
    public CompletableFuture<Void> recomputeAsync() {
        if (!running.compareAndSet(false, true)) {
            return CompletableFuture.completedFuture(null);
        }
        try {
            analytics.recomputeCommunities();
            return CompletableFuture.completedFuture(null);
        } finally {
            running.set(false);
        }
    }
}

The results of such jobs are read by the cheap OLTP queries of the application; when those reads are hot, cache them as described in Caching and evict the cache when the job finishes. See Spring Framework — Task Execution and Scheduling and ShedLock.

Native versus Cypher projections

A native projection selects whole labels and relationship types and is the fastest to build; a Cypher projection builds the in-memory graph from a query, which makes it the tool for a filtered or reshaped subgraph. The trade-offs are explained in Graph Data Science Fundamentals. In current GDS a Cypher projection is not a separate procedure: it is a MATCH that ends in the gds.graph.project aggregation function, taking the graph name, the source node, the target node and optional data and general configuration maps, and yielding a graphName, nodeCount and relationshipCount.

Older articles, and some pages in the Neo4j Reference of this site, use the gds.graph.project.cypher procedure with two query strings. The Cypher projection page of the GDS manual documents only the aggregation form shown below, and the examples of this section use it.

Because the MATCH is an ordinary parameterised Cypher query, the subgraph filter can be driven by request or job parameters: a time window, a minimum amount, a tenant, a label set. The helper passes the graph name as the $graphName parameter, so the statement itself is a constant:

static final String TRANSFERS_SINCE = """
    MATCH (source:Account)-[transfer:TRANSFER]->(target:Account)
    WHERE transfer.timestamp >= datetime($since) AND transfer.amount >= $minimumAmount
    WITH gds.graph.project(
        $graphName,
        source,
        target,
        {relationshipProperties: transfer {.amount}}
    ) AS graph
    RETURN graph.nodeCount AS nodeCount, graph.relationshipCount AS relationshipCount
    """;

try (var graph = graphs.projectCypher("transfers", TRANSFERS_SINCE,
        Map.of("since", since.toString(), "minimumAmount", 1_000))) {
    // run any algorithm against graph.name()
}

The dataConfig map of the aggregation accepts sourceNodeLabels, targetNodeLabels, sourceNodeProperties, targetNodeProperties, relationshipProperties and relationshipType; the optional last map takes settings such as readConcurrency and undirectedRelationshipTypes. Only numeric properties can be projected, which is why the example projects the amount and not a name. The native counterpart, when the whole label and relationship type is what is wanted, is the helper’s projectNative:

try (var graph = graphs.projectNative("accounts",
        List.of("Account"),
        Map.of("TRANSFER", Map.of("orientation", "NATURAL", "properties", List.of("amount"))))) {
    // run any algorithm against graph.name()
}

Use the native form as the default and the Cypher form only when the filter cannot be expressed as labels and relationship types. See GDS — Cypher projection and GDS — Native projection.

Managed analytics on Aura

Neo4j Aura offers Aura Graph Analytics, an on-demand environment in which graph analytics runs in sessions that are created and deleted as needed, each as an isolated instance that does not share memory or compute with the database it reads. It is the managed alternative to running GDS inside the database server, and it is documented separately from the plugin used in this section; see Neo4j Aura — Graph Analytics.

Where to go next