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 () or reactive (Project Reactor / RxJava) Neo4j access
When NOT to Use
- Cypher query authoring/optimization →
- Driver version upgrades →
- Spring Data Neo4j (, , ) →
Dependency
Maven
xml
<dependency>
<groupId>org.neo4j.driver</groupId>
<artifactId>neo4j-java-driver</artifactId>
<version>6.0.5</version>
</dependency>
Gradle
groovy
implementation 'org.neo4j.driver:neo4j-java-driver:6.0.5'
Environment Variables
Standard pattern for connection config — never hardcode credentials:
java
String 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
:
properties
spring.neo4j.uri=neo4j+s://xxx.databases.neo4j.io
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=secret
Driver Lifecycle
One
per application — thread-safe, expensive to create. Implement
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 |
|---|
| Unencrypted, cluster routing |
neo4j+s://xxx.databases.neo4j.io
| TLS + cluster routing (Aura) |
| Unencrypted, single instance |
| TLS, single instance |
Choosing the Right API
| API | When | Auto-retry | Streaming |
|---|
| Default for most queries | ✅ | ❌ eager |
session.executeRead/Write()
| Large results, callback control | ✅ | ✅ |
session.beginTransaction()
| Multi-method, external coordination | ❌ | ✅ |
| Self-managing queries () | ❌ | ✅ |
| Non-blocking | ✅ | ✅ |
| Reactor/RxJava backpressure | ✅ | ✅ |
CALL { … } IN TRANSACTIONS
and
self-manage their transaction — use
only.
and
will fail for these queries.
— 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 ( / )
Sessions are NOT thread-safe — one per request/thread, always close.
java
try (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
is a lazy cursor tied to the open transaction. Transaction closes when callback returns — any read after that throws
.
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 before next — multiple open cursors = undefined behaviour.
- No side effects (HTTP, email, metric increments) — callback may be retried on transient errors.
- Use (idempotent), not , for retry-safe writes.
- → replica; → leader.
TransactionConfig — timeouts & metadata
java
var 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.
java
try (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;
}
}
is a network call — wrap in its own try/catch and use
so the original exception is not lost.
Commit uncertainty: if
throws
ServiceUnavailableException
, the commit may or may not have succeeded. Design writes as idempotent (
+ unique constraints) so retrying is safe.
Choose explicit vs managed:
- Auto-retry needed → /
- Work spans multiple methods → explicit (pass as parameter)
- Coordinating with external I/O → explicit (commit only after I/O succeeds)
Error Handling
java
try {
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
— no catch needed.
Data Types & Value Extraction
| Cypher type | Java accessor |
|---|
| / |
| |
| |
| |
| |
| |
| |
| |
| |
| |
java
var 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 | | |
|---|
| Key present, value non-null | the value | returns string |
| Key present, value is graph null | where = true | throws |
| Key absent (typo / not projected) | sentinel | throws |
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
for lists:
java
public 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 —
in
or use
.
Batch writes with — pass
(plain maps only; custom objects fail):
java
List<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:
,
/
/
/
,
/
,
,
,
,
. Custom objects and
must be converted first.
Group writes in one transaction — one
with a loop, not one
per iteration.
Connection pool — default 100 connections. Tune if exhausted:
java
Config.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 / every time |
| New per request | Create once at startup; share everywhere |
| Share across threads | One session per request/thread |
| Return from tx callback | Collect to / inside callback |
| Leave open before next | Consume before next call |
| Side effects in managed tx callback | Move outside — callback may retry |
| Pass custom objects to UNWIND params | Convert to |
| on graph null | or check |
| on absent key | before optional access |
| Naked in catch | Wrap in try/catch; use |
| Assume failure = no commit | Commit uncertainty — design writes idempotent |
| Block inside async callback () | Chain with |
| Skip session close in async error path | to close then re-throw |
| One transaction per write in loop | Batch with or group in one callback |
| for a read | Use — routes to replica |
References
Load on demand:
- references/async-reactive.md — full async patterns, reactive with , deadlock avoidance
- references/advanced-config.md — full options, TLS, notification filtering, session-level auth, user impersonation, cross-session bookmarks, spatial types (Values.point/WGS-84/Cartesian)
Docs:
Checklist