When to Use
- Java/Kotlin code connecting to Neo4j (Aura or self-managed)
- Setting up driver, sessions, transactions in Maven/Gradle projects
- Debugging result handling, error recovery, connection pool issues
- Async (
CompletableFuture) or reactive (Project Reactor / RxJava) Neo4j access
When NOT to Use
- Cypher query authoring/optimization →
neo4j-cypher-skill - Driver version upgrades →
neo4j-migration-skill - Spring Data Neo4j (
@Node,@Relationship,Neo4jRepository) →neo4j-spring-data-skill
Dependency
Maven
xml<dependency> <groupId>org.neo4j.driver</groupId> <artifactId>neo4j-java-driver</artifactId> <version>6.2.1</version> </dependency>
Gradle
groovyimplementation 'org.neo4j.driver:neo4j-java-driver:6.2.1'
Check latest: https://central.sonatype.com/artifact/org.neo4j.driver/neo4j-java-driver
6.2.0 [2026-06]: Neo4j UUID type + Bolt 6.1 support; QueryProfile in result summary.
6.2.1 [2026-08]: fixes Value#asObject() on UUID values — required if reading UUID properties generically. Neo4j 2026.08 server bundles 6.2.1; 2026.07 bundles 6.2.0.
Server-side UUID type and uuid() constructors ship in Neo4j 2026.08 — use driver >= 6.2.1 against those servers.
Environment Variables
Standard pattern for connection config — never hardcode credentials:
javaString uri = System.getenv().getOrDefault("NEO4J_URI", "neo4j://localhost:7687"); String user = System.getenv().getOrDefault("NEO4J_USERNAME", "neo4j"); String password = System.getenv().getOrDefault("NEO4J_PASSWORD", ""); String database = System.getenv().getOrDefault("NEO4J_DATABASE", "neo4j");
Spring Boot: inject via @Value("${spring.neo4j.uri}") or application.properties:
propertiesspring.neo4j.uri=neo4j+s://xxx.databases.neo4j.io spring.neo4j.authentication.username=neo4j spring.neo4j.authentication.password=secret
Driver Lifecycle
One Driver per application — thread-safe, expensive to create. Implement AutoCloseable or use try-with-resources.
java// Long-lived singleton var driver = GraphDatabase.driver( "neo4j+s://xxx.databases.neo4j.io", // Aura TLS+routing AuthTokens.basic(user, password)); driver.verifyConnectivity(); // fail fast // Short-lived (tests / CLI) try (var driver = GraphDatabase.driver(uri, AuthTokens.basic(user, password))) { driver.verifyConnectivity(); // ... }
URI schemes:
| URI | Use |
|---|---|
neo4j://localhost | Unencrypted, cluster routing |
neo4j+s://xxx.databases.neo4j.io | TLS + cluster routing (Aura) |
bolt://localhost:7687 | Unencrypted, single instance |
bolt+s://localhost:7687 | TLS, single instance |
Auth options: AuthTokens.basic(u,p) · AuthTokens.bearer(token) · AuthTokens.kerberos(b64) · AuthTokens.none()
Choosing the Right API
| API | When | Auto-retry | Streaming |
|---|---|---|---|
driver.executableQuery() | Default for most queries | ✅ | ❌ eager |
session.executeRead/Write() | Large results, callback control | ✅ | ✅ |
session.beginTransaction() | Multi-method, external coordination | ❌ | ✅ |
session.run() | Self-managing queries (CALL IN TRANSACTIONS) | ⚠️ one-shot [6.1+] | ✅ |
driver.asyncSession() | Non-blocking CompletableFuture | ✅ | ✅ |
driver.rxSession() | Reactor/RxJava backpressure | ✅ | ✅ |
CALL { … } IN TRANSACTIONS and USING PERIODIC COMMIT self-manage their transaction — use session.run() only. executableQuery and executeRead/Write will fail for these queries.
session.run() retry [6.1+]: single immediate retry on idempotent errors only (enabled by default). Disable per driver or per session:
java// Driver-level — disable for all sessions var config = Config.builder().withAutoCommitRetriesDisabled(true).build(); // Session-level — overrides driver var sessionConfig = SessionConfig.builder() .withAutoCommitRetriesMode(AutoCommitRetriesMode.DISABLED) // DEFAULT = follow driver .build();
executableQuery — Default
java// Read — route to replicas var result = driver.executableQuery(""" MATCH (p:Person {name: $name})-[:KNOWS]->(friend) RETURN friend.name AS name """) .withParameters(Map.of("name", "Alice")) .withConfig(QueryConfig.builder() .withDatabase("neo4j") // always specify — avoids home-db round-trip .withRouting(RoutingControl.READ) .build()) .execute(); result.records().forEach(r -> System.out.println(r.get("name").asString())); long ms = result.summary().resultAvailableAfter(TimeUnit.MILLISECONDS); // Write driver.executableQuery("CREATE (p:Person {name: $name, age: $age})") .withParameters(Map.of("name", "Bob", "age", 30)) .withConfig(QueryConfig.builder().withDatabase("neo4j").build()) .execute();
Never string-interpolate Cypher. Always .withParameters(Map.of(...)).
Managed Transactions (executeRead / executeWrite)
Sessions are NOT thread-safe — one per request/thread, always close.
javatry (var session = driver.session(SessionConfig.builder() .withDatabase("neo4j").build())) { // Read → replica routing var names = session.executeRead(tx -> { var result = tx.run( "MATCH (p:Person) WHERE p.name STARTS WITH $prefix RETURN p.name AS name", Map.of("prefix", "Al")); return result.stream().map(r -> r.get("name").asString()).toList(); // collect INSIDE }); // Write → leader routing session.executeWriteWithoutResult(tx -> tx.run("CREATE (p:Person {name: $name})", Map.of("name", "Carol")) ); }
Result must be consumed INSIDE the callback
Result is a lazy cursor tied to the open transaction. Transaction closes when callback returns — any read after that throws ResultConsumedException.
java// ❌ Returns Result — already closed by the time caller uses it var result = session.executeRead(tx -> tx.run("MATCH (p:Person) RETURN p.name AS name")); result.stream().forEach(...); // throws ResultConsumedException // ✅ Collect to List inside callback var names = session.executeRead(tx -> tx.run("MATCH (p:Person) RETURN p.name AS name") .stream().map(r -> r.get("name").asString()).toList());
Callback rules
- Consume each
Resultbefore nexttx.run()— multiple open cursors = undefined behaviour. - No side effects (HTTP, email, metric increments) — callback may be retried on transient errors.
- Use
MERGE(idempotent), notCREATE, for retry-safe writes. executeRead→ replica;executeWrite→ leader.
TransactionConfig — timeouts & metadata
javavar config = TransactionConfig.builder() .withTimeout(Duration.ofSeconds(5)) .withMetadata(Map.of("app", "myService", "user", userId)) // visible in SHOW TRANSACTIONS .build(); session.executeRead(tx -> { /* ... */ }, config);
Explicit Transactions
Use when work spans multiple methods or requires external coordination. Not auto-retried.
javatry (var session = driver.session(SessionConfig.builder().withDatabase("neo4j").build())) { var tx = session.beginTransaction(); try { doPartA(tx); doPartB(tx); tx.commit(); } catch (Exception e) { try { tx.rollback(); } catch (Exception rb) { e.addSuppressed(rb); } throw e; } }
tx.rollback() is a network call — wrap in its own try/catch and use addSuppressed so the original exception is not lost.
Commit uncertainty: if tx.commit() throws ServiceUnavailableException, the commit may or may not have succeeded. Design writes as idempotent (MERGE + unique constraints) so retrying is safe.
Choose explicit vs managed:
- Auto-retry needed →
executeRead/executeWrite - Work spans multiple methods → explicit (pass
txas parameter) - Coordinating with external I/O → explicit (commit only after I/O succeeds)
Error Handling
javatry { driver.executableQuery("...").execute(); } catch (ServiceUnavailableException e) { // No servers — check connection } catch (SessionExpiredException e) { // Server closed session — open new one } catch (TransientException e) { // Managed txns retry automatically; explicit txns need manual retry } catch (Neo4jException e) { // Cypher/constraint error — e.code() gives GQL status code }
Managed transactions auto-retry TransientException — no catch needed.
Data Types & Value Extraction
| Cypher type | Java accessor |
|---|---|
Integer | value.asLong() / value.asInt() |
Float | value.asDouble() |
String | value.asString() |
Boolean | value.asBoolean() |
List | value.asList() |
Map | value.asMap() |
Node | value.asNode() |
Relationship | value.asRelationship() |
Date | value.asLocalDate() |
DateTime | value.asZonedDateTime() |
UUID | value.asUUID() → java.util.UUID [6.2+, Neo4j 2026.08+] |
javavar record = result.records().get(0); String name = record.get("name").asString(); long age = record.get("age").asLong(); var node = record.get("p").asNode(); String label = node.labels().iterator().next(); Map<String,Object> props = node.asMap();
Null safety — two distinct cases
| Situation | record.get(key) | .asString() |
|---|---|---|
| Key present, value non-null | the value | returns string |
| Key present, value is graph null | Value where .isNull() = true | throws Uncoercible |
| Key absent (typo / not projected) | Value.NULL sentinel | throws NoSuchElementException |
java// Graph null — use default overload (safe only if key is always projected): String city = record.get("city").asString("Unknown"); // Absent key — check containsKey first: if (record.containsKey("city") && !record.get("city").isNull()) { String city = record.get("city").asString(); }
Object Mapping
Map query results to Java records/classes directly — eliminates manual accessor calls.
java// Domain record — field names match RETURN aliases (case-sensitive) public record Person(String name, long age) {} // Map single record var person = driver.executableQuery("MATCH (p:Person {name: $name}) RETURN p.name AS name, p.age AS age") .withParameters(Map.of("name", "Alice")) .withConfig(QueryConfig.builder().withDatabase("neo4j").build()) .execute() .records() .stream() .map(r -> r.get("name").asString()) // or: r.as(Person.class) — see note .findFirst() .orElseThrow(); // Using .as(Person.class) — maps RETURN keys to record fields by name var person2 = driver.executableQuery(""" MATCH (p:Person {name: $name}) RETURN p.name AS name, p.age AS age """) .withParameters(Map.of("name", "Tom Hanks")) .withConfig(QueryConfig.builder().withDatabase("neo4j").build()) .execute() .records() .stream() .map(record -> record.get("p").as(Person.class)) .findFirst() .orElseThrow(() -> new RuntimeException("Person not found"));
Nested mapping — return a map projection and include COLLECT {} for lists:
javapublic record Movie(String title, List<Person> actors) {} var movieCypher = """ MATCH (movie:Movie) LIMIT 1 RETURN movie { .title, actors: COLLECT { MATCH (actor:Person)-[:ACTED_IN]->(movie) RETURN actor } } """; var movie = driver.executableQuery(movieCypher) .withConfig(QueryConfig.builder().withDatabase("neo4j").build()) .execute() .records() .stream() .map(r -> r.get("movie").as(Movie.class)) .findFirst() .orElseThrow();
Only mapped properties defined in the record are populated — extra properties returned by Cypher are ignored.
Performance Patterns
Always specify database — omitting triggers home-db round-trip on every call.
Route reads to replicas — RoutingControl.READ in QueryConfig or use executeRead.
Batch writes with UNWIND — pass List<Map<String,Object>> (plain maps only; custom objects fail):
javaList<Map<String, Object>> rows = people.stream() .map(p -> Map.<String, Object>of("name", p.name(), "age", p.age())) .toList(); driver.executableQuery("UNWIND $items AS item MERGE (p:Person {name: item.name}) SET p.age = item.age") .withParameters(Map.of("items", rows)) .withConfig(QueryConfig.builder().withDatabase("neo4j").build()) .execute();
Allowed leaf types in parameter maps: String, Long/Integer/Short/Byte, Double/Float, Boolean, List<?>, Map<String,?>, null. Custom objects and LocalDate must be converted first.
Group writes in one transaction — one executeWrite with a loop, not one executeWrite per iteration.
Connection pool — default 100 connections. Tune if exhausted:
javaConfig.builder() .withMaxConnectionPoolSize(50) .withConnectionAcquisitionTimeout(30, TimeUnit.SECONDS) .build()
Common Errors
| Mistake | Fix |
|---|---|
| String-interpolate Cypher params | .withParameters(Map.of(...)) always |
| Omit database name | Set in QueryConfig / SessionConfig every time |
New Driver per request | Create once at startup; share everywhere |
Share Session across threads | One session per request/thread |
Return Result from tx callback | Collect to List/Map inside callback |
Leave Result open before next tx.run() | Consume before next call |
| Side effects in managed tx callback | Move outside — callback may retry |
| Pass custom objects to UNWIND params | Convert to List<Map<String,Object>> |
asString() on graph null | .asString("default") or check .isNull() |
asString() on absent key | containsKey() before optional access |
Naked tx.rollback() in catch | Wrap in try/catch; use addSuppressed |
Assume commit() failure = no commit | Commit uncertainty — design writes idempotent |
Block inside async callback (.join()) | Chain with thenCompose |
| Skip session close in async error path | exceptionallyCompose to close then re-throw |
| One transaction per write in loop | Batch with UNWIND or group in one callback |
executeWrite for a read | Use executeRead — routes to replica |
References
Load on demand:
- references/async-reactive.md — full async
CompletableFuturepatterns, reactiveRxSessionwithFlux.usingWhen, deadlock avoidance - references/advanced-config.md — full
Config.builder()options, TLS, notification filtering, session-level auth, user impersonation, cross-session bookmarks, spatial types (Values.point/WGS-84/Cartesian)
Docs:
- Java Driver manual: https://neo4j.com/docs/java-manual/current/
- API reference: https://neo4j.com/docs/api/java-driver/current/
Checklist
- One
Driverinstance created at startup; closed on shutdown -
verifyConnectivity()called after driver creation - Database name specified in every
QueryConfig/SessionConfig - Parameters used (never string-interpolated Cypher)
-
Resultconsumed inside managed transaction callback - No side effects inside
executeRead/Writecallbacks - Sessions closed via try-with-resources
- Async sessions closed in both success and error paths (
exceptionallyCompose) -
ServiceUnavailableExceptionon commit handled as commit-uncertain -
UNWINDparams areList<Map<String,Object>>(no custom objects) -
containsKey()checked before accessing optional result columns

