Back to Blog

Certified, Not Claimed: ArcadeDB's Bolt Compatibility With Every Official Neo4j Driver

Bolt driver compatibility matrix: conformance areas passing across the Java, JavaScript, Python, C#, and Go Neo4j drivers

Plenty of databases say they “support the Bolt protocol.” Almost none of them tell you what that sentence leaves out.

ArcadeDB has spoken Bolt for a while, and the honest version of our claim was narrow: the official Neo4j drivers connect, and simple queries work. Useful, but not the same thing as compatibility. The gap between the two is where users get hurt. The driver connects on Monday; on Thursday someone reads a datetime back out of a query and gets a string.

ArcadeDB 26.7.2 closes that gap. It ships a shared conformance spec, all five official drivers under test, fixes for the protocol bugs that testing exposed, and a compatibility matrix that regenerates itself every night and is published where anyone can read it.

The audit

We started by auditing what we actually had (epic #4882). Some of it was reassuring. The full Bolt 3.0/4.0/4.4 message set was implemented, ROUTE included, along with a hand-written PackStream encoder, correct structure tags for Node, Relationship, and Path, TLS via bolt+s, and Neo4j-style structured error codes.

The rest was not:

  • Bolt 5.x was never advertised. The server negotiated 3.0, 4.0, and 4.4. Modern 5.x drivers worked only by silently downgrading, which nobody had documented or tested as a deliberate stance.
  • Temporal values went out as strings. Date, Time, LocalDateTime, DateTime and friends were serialized as ISO-8601 text instead of native Bolt structures. Duration and spatial Point had no handling anywhere. The driver dutifully handed your application a String.
  • Neo.TransientError.* did not exist. Only a handful of ClientError and DatabaseError codes were defined, so the drivers’ managed-transaction retry logic could never fire the way it does against Neo4j.
  • ROUTE was single-node only. It returned this node’s own address as writer, reader, and router, so neo4j:// routing against a real HA cluster was unproven.
  • Three of the five official drivers had zero Bolt coverage. The Python and C# e2e suites tested the Postgres wire protocol and HTTP. There was no Go module at all.
  • Java and JavaScript coverage was shallow. Connect, run a query, one regression test. No transactions, no error paths, no type round-trips.

A compatibility claim you cannot fail is not a compatibility claim. That list is why the epic existed.

The rules we set

Certify depth, not presence. “The driver connects” is not certification. Every driver runs the full feature matrix, and every unsupported cell becomes a documented limitation instead of a silent omission.

Only the real drivers. neo4j-java-driver, neo4j-driver, neo4j, Neo4j.Driver, neo4j-go-driver. No mocks, no bespoke socket clients. If the driver your application imports cannot do it, we do not get to claim it.

One spec, five idiomatic suites. We deliberately did not build a YAML-driven test runner in five languages. The conformance spec is a reference document. Each scenario is hand-written into that language’s native framework (JUnit, jest, pytest, xUnit, go test) and tagged with the scenario ID. The spec owns what gets tested; the code stays polyglot and readable.

“Not supported” is an acceptable answer, as long as it is written down. Byte-for-byte parity with Neo4j server behavior was never the goal. Knowing exactly where we differ was worth more.

The spec: 39 scenarios, 9 areas

The matrix is defined once, in bolt/conformance/spec.yaml, across nine areas taken verbatim from the epic’s feature table:

Area What it pins down
connection bolt://, bolt+s:// with TLS required and optional, neo4j:// routing discovery
auth Basic auth success and failure; the none scheme being rejected (intentional, now certified as such)
transactions Autocommit, explicit BEGIN/COMMIT/ROLLBACK, managed transaction functions, retry on transient errors
causal-consistency Bookmarks enforcing read-after-write across sessions
multi-database Session database selection and isolation between databases on one driver
result-handling Streaming PULL, PULL n resumption, DISCARD, ResultSummary counters
type-roundtrip Node, Relationship, Path, ByteArray, nested collections, nulls, all five temporal types, Duration, Point
errors Neo.ClientError.* and Neo.TransientError.* so driver retry behavior matches Neo4j
protocol 3.0/4.0/4.4 negotiation, 5.x negotiation, RESET mid-stream

Each scenario carries a stable ID (TYPE-011, PROTO-002), a fixture, given/when/then steps, and a status. Every test in every language embeds its scenario ID in the test name, so checking coverage is a grep.

The drivers: 14 pinned versions

Testing against latest tells you about today and nothing about tomorrow. Every language is pinned to a band set, resolved to concrete versions in driver-versions.md:

Driver Versions under test
Java (neo4j-java-driver) 4.4.20, 5.28.5, 6.2.0
JavaScript (neo4j-driver) 5.28.3, 6.2.0
Python (neo4j) 5.28.4, 6.1.0, 6.2.0
.NET (Neo4j.Driver) 5.26.2, 5.28.4, 6.2.1
Go (neo4j-go-driver) 5.27.0, 5.28.0, 5.28.4

The latest band deliberately tracks the newest release, so a driver-side release that breaks compatibility trips our nightly run within a day of shipping.

Two of these bands carry compromises. The C# floor is 5.26.2 instead of a 4.x line, because Neo4j.Driver made breaking API changes in 5.0 that the shared suite cannot compile against. JavaScript has no 4.x band for the same reason. The 4.4 wire protocol is still covered through the Java 4.4.20 legacy driver. Both compromises are written down in the file, with the reasoning, so nobody has to reverse-engineer why a column is missing.

What the tests broke

The first full run lit up red, which was more or less the point. Everything from the audit list is now fixed in 26.7.2:

  • Native temporal structures. Date, Time, LocalTime, LocalDateTime, and DateTime are emitted as native PackStream structures.
  • Duration and Point. Both round-trip as native Bolt structures in both directions, bound parameters included.
  • Neo.TransientError.*. Retryable conflicts now map to transient error codes, so the drivers’ executeWrite retry loop works out of the box instead of failing your write on the first optimistic-lock conflict.
  • HA-aware ROUTE. neo4j:// returns the real cluster topology instead of the local node three times.
  • Bolt 5.x negotiation. No more silent downgrade.
  • Populated write counters. ResultSummary counters reflect what actually happened.

One breaking change to plan for

The temporal fix changes behavior, and it is the one thing to read before you upgrade. Values that used to arrive as strings now arrive as typed objects:

// Before 26.7.2: this returned an ISO-8601 String
String when = record.get("created").asString();

// 26.7.2 and later: it is a real temporal value
ZonedDateTime when = record.get("created").asZonedDateTime();

This is what every Neo4j driver expects, and it is still a change, so the 26.7.2 release notes call it out explicitly.

The part that keeps it true

A test suite that runs when someone remembers to run it eventually stops being true. The pipeline matters more than any single bug on that list.

All five language suites gate every pull request. On top of that, a nightly workflow runs the full cross-product: every scenario, every driver, every pinned version. Each language’s JUnit-style report becomes a set of per-cell results, the cells merge into a single bolt-compat-matrix.json, and that JSON is cross-referenced against the expected cell set from driver-versions.md. A cell that should exist and does not is treated exactly like a cell that ran and failed. Silence does not pass.

Three things then happen without anyone touching them:

  1. A regression issue opens itself. Any red cell auto-opens a bolt-compat-regression issue, which closes itself when the matrix goes green again.
  2. The matrix is rendered and committed. A small renderer turns the nightly JSON plus the spec metadata into COMPATIBILITY.md. Rows are scenarios grouped by area, columns are language:version, and cells are pass, fail, known limitation, not applicable, or not reported. Non-passing cells link to their tracking issue. The page carries a “last verified” timestamp and a link to the CI run that produced it.
  3. The badge updates. A shields.io endpoint in the README goes green only when every applicable cell passes.

The red nights get published the same way the green ones do. A matrix that could only ever come out green would not be worth publishing.

What is not green

Every applicable cell passes today. Two cells are not green, both deliberately:

  • ERR-003 (unauthenticated request returns Neo.ClientError.Security.Forbidden) is marked not applicable. It cannot be triggered through any official driver’s public API, because every official driver completes HELLO/LOGON internally before it hands your code a session. Reaching it would take a bespoke raw-socket client, which our own rules exclude. The row stays in the matrix with the explanation attached.
  • CONN-004 (neo4j:// routing reflecting a real multi-node topology) is skipped, because the nightly runs against a single node. The HA-aware ROUTE implementation shipped in 26.7.2; the multi-node nightly harness for it did not. The cell carries a footnote about the HA_SERVER_LIST configuration it depends on, and it stays visible until something actually covers it.

The Java 4.4.20 column is sparse on purpose. It runs the legacy-driver subset that proves 4.4 wire negotiation still works, not the full modern-API suite.

Try it

Point any official Neo4j driver at an ArcadeDB server. Nothing special is required. Here is the same query in all five certified drivers:

try (Driver driver = GraphDatabase.driver("bolt://localhost:7687",
         AuthTokens.basic("root", "playwithdata"));
     Session session = driver.session(SessionConfig.forDatabase("beer"))) {

  session.run("MATCH (b:Beer)-[:HasCategory]->(c:Category) "
            + "WHERE c.name = $cat RETURN b.name AS name LIMIT 5",
      Map.of("cat", "Irish Ale"))
      .forEachRemaining(r -> System.out.println(r.get("name").asString()));
}
import neo4j from 'neo4j-driver';

const driver = neo4j.driver('bolt://localhost:7687',
  neo4j.auth.basic('root', 'playwithdata'));
const session = driver.session({ database: 'beer' });

const result = await session.run(
  'MATCH (b:Beer)-[:HasCategory]->(c:Category) ' +
  'WHERE c.name = $cat RETURN b.name AS name LIMIT 5',
  { cat: 'Irish Ale' }
);
result.records.forEach(r => console.log(r.get('name')));

await session.close();
await driver.close();
from neo4j import GraphDatabase

driver = GraphDatabase.driver("bolt://localhost:7687",
                              auth=("root", "playwithdata"))

with driver.session(database="beer") as session:
    result = session.run(
        "MATCH (b:Beer)-[:HasCategory]->(c:Category) "
        "WHERE c.name = $cat RETURN b.name AS name LIMIT 5",
        cat="Irish Ale",
    )
    for record in result:
        print(record["name"])
await using var driver = GraphDatabase.Driver("bolt://localhost:7687",
    AuthTokens.Basic("root", "playwithdata"));
await using var session = driver.AsyncSession(o => o.WithDatabase("beer"));

var names = await session.ExecuteReadAsync(async tx => {
    var cursor = await tx.RunAsync(
        "MATCH (b:Beer)-[:HasCategory]->(c:Category) " +
        "WHERE c.name = $cat RETURN b.name AS name LIMIT 5",
        new { cat = "Irish Ale" });
    return await cursor.ToListAsync(r => r["name"].As<string>());
});

names.ForEach(Console.WriteLine);
ctx := context.Background()

driver, err := neo4j.NewDriverWithContext("bolt://localhost:7687",
    neo4j.BasicAuth("root", "playwithdata", ""))
if err != nil {
    log.Fatal(err)
}
defer driver.Close(ctx)

session := driver.NewSession(ctx, neo4j.SessionConfig{DatabaseName: "beer"})
defer session.Close(ctx)

result, err := session.Run(ctx,
    "MATCH (b:Beer)-[:HasCategory]->(c:Category) "+
        "WHERE c.name = $cat RETURN b.name AS name LIMIT 5",
    map[string]any{"cat": "Irish Ale"})
if err != nil {
    log.Fatal(err)
}

for result.Next(ctx) {
    fmt.Println(result.Record().Values[0])
}

Managed transactions, bookmarks, neo4j:// routing, and typed temporal and spatial values behave the way the driver documentation says they should. Where they do not, the matrix says so.

Why we bothered

A large part of the graph world evaluates a database by pointing an existing driver at it, running existing Cypher, and seeing what breaks. That deserves a real answer instead of a compatibility adjective.

It is the same instinct behind our Jepsen work: publish the harness, publish the results, publish the parts that do not pass. All of it is open:


Get ArcadeDB 26.7.2: GitHub Releases or docker pull arcadedata/arcadedb:26.7.2

Run a Bolt workload against ArcadeDB and hit a case the matrix does not cover? Open an issue. A scenario we never thought to write is the most useful bug report we can get.

Roberto Franchini Director R&D