
This blog is written by AI for SEO
Kuzu Java Replacement: Port a JVM App to HelixDB
To replace Kuzu in a Java app, run HelixDB as a local server, swap the embedded Database and Connection for a small java.net.http.HttpClient wrapper, send supported Cypher to POST /v2/cypher, and rewrite the rest as JSON queries for POST /v2/query. HelixDB has no Java SDK, so the JVM talks to it over HTTP. The change is smaller than it sounds: most of your code is query strings and result handling, and both survive.
Kuzu's repository was archived in October 2025, which leaves an agent-memory or GraphRAG app on an unmaintained embedded engine. HelixDB is an open-source (Apache-2.0) graph-vector database built in Rust, with graph traversal, vector search and BM25 full-text search in one engine. This guide goes step by step, starting from the Kuzu code you already have. You need Java 11 or later (for HttpClient), Docker or Podman, and the Helix CLI. Kotlin and Scala work the same way, since the wire format is plain HTTP and JSON.
Step 1: Write down your Kuzu Java code and schema
Before you touch HelixDB, list exactly what your app does against Kuzu: the schema, every query, and how results are consumed. This inventory decides which queries port as-is and which get rewritten.
A typical embedded Kuzu setup looks like this:
import com.kuzudb.*;
Database db = new Database("agent-memory.kuzu");
Connection conn = new Connection(db);
conn.query("CREATE NODE TABLE User(id STRING, name STRING, PRIMARY KEY(id))");
conn.query("CREATE NODE TABLE Memory(id STRING, text STRING, ts TIMESTAMP, PRIMARY KEY(id))");
conn.query("CREATE REL TABLE REMEMBERS(FROM User TO Memory, weight DOUBLE)");
PreparedStatement stmt = conn.prepare(
"MATCH (u:User)-[:REMEMBERS]->(m:Memory) WHERE u.id = $uid RETURN m.id, m.text");
QueryResult result = conn.execute(stmt, Map.of("uid", "user-42"));
while (result.hasNext()) {
FlatTuple row = result.getNext();
System.out.println(row);
}Note the pieces: Database owns the on-disk files, Connection runs queries, prepare plus execute bind parameters, and QueryResult is iterated with hasNext() and getNext(). Kuzu reuses the same FlatTuple object on each call, so you process a row before advancing. Write down every CREATE NODE TABLE and CREATE REL TABLE, the primary key of each node table, and any vector or full-text index calls. Then tag each query by shape: plain MATCH/RETURN, MERGE, variable-length path, vector search, full-text search. You will use those tags in Steps 5 and 6.
If you have already ported another language, the same inventory step applies in Porting Kuzu Python Code to HelixDB and Porting Kuzu Node.js Code to HelixDB in TypeScript.
Step 2: Export the Kuzu database with EXPORT DATABASE from the Java Connection
Kuzu has a built-in export that writes schema and data in one pass, and you can call it from the same Java Connection you already use. Parquet is the default format.
Database db = new Database("agent-memory.kuzu");
Connection conn = new Connection(db);
conn.query("EXPORT DATABASE '/tmp/kuzu-export' (format='parquet')");That writes the schema DDL plus one data file per node table and rel table into /tmp/kuzu-export. Open the schema file and check it against your inventory from Step 1. For each rel table, note which columns hold the source and target keys, because you need them to rebuild edges later. If you prefer CSV, pass format='csv' instead.
Take the export from a quiet database. Stop writers first so the files reflect one consistent state, and leave the original .kuzu directory untouched. You will compare counts against it in Step 7.
Step 3: Start HelixDB locally with helix init local and helix start dev on port 6969
A local HelixDB server is two CLI commands and a container runtime. Install the Helix CLI, then from your project root:
helix init local
helix start devThis needs Docker or Podman and serves on port 6969. The server image must be v0.0.13 or later for the Cypher endpoint, or you can use Helix Cloud instead.
You now have two endpoints on http://localhost:6969: POST /v2/cypher for supported openCypher, and POST /v2/query for JSON queries. Both hit the same graph, the same transactions and the same indexes, so you can mix them freely inside one app. That matters for migration: you can move query by query instead of in one cutover.
The open-source core runs in memory, on disk, or against S3-compatible object storage, as a configuration choice. HelixDB Cloud is the managed, object-storage-backed option if you later want someone else to run it. Local dev is enough for this port.
Step 4: Replace the embedded Database and Connection with a small HttpClient wrapper
There is no Java SDK, so the replacement for Database and Connection is a thin class around java.net.http.HttpClient. It holds a base URL, posts JSON, and returns the response body. Keep it that small on purpose.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class HelixClient {
private final HttpClient http = HttpClient.newHttpClient();
private final String base;
public HelixClient(String base) { this.base = base; }
private String post(String path, String json) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(base + path))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() / 100 != 2) {
throw new RuntimeException(res.statusCode() + ": " + res.body());
}
return res.body();
}
public String cypher(String json) throws Exception { return post("/v2/cypher", json); }
public String query(String json) throws Exception { return post("/v2/query", json); }
}Two things change in how you think about the code. First, there is no file handle: a Database pointed at a directory becomes a URL, and the Connection lifecycle disappears because HttpClient is reusable and thread-safe. Second, results arrive as JSON text, so the while (result.hasNext()) loop becomes parsing a response with whatever JSON library you already use (Jackson, Gson, kotlinx.serialization). Rows are no longer a reused FlatTuple, so you can hold results without copying them first.
Put this class behind an interface your app already depends on, such as MemoryStore, so the rest of the codebase never sees HTTP.
Step 5: Send compatible Cypher to POST /v2/cypher and triage the queries that need rewriting
HelixDB runs a supported profile of openCypher, and queries inside it can keep their Cypher text. Supported: MATCH and OPTIONAL MATCH with fixed-length patterns, WHERE, WITH, RETURN, UNWIND, ORDER BY, SKIP, LIMIT, CREATE, SET, REMOVE, DELETE, DETACH DELETE, common functions and aggregation. The full triage table is in Kuzu Cypher on HelixDB: Which Queries Run Unchanged.
Use your tags from Step 1. Plain MATCH/RETURN reads, CREATE, SET and DELETE statements go to /v2/cypher with the query text you already have. Check the request body shape against the HelixDB docs for your server version. The change is transport, not query text.
These constructs are rejected before they run: MERGE, variable-length relationships, shortest paths, UNION, CALL and procedures, subqueries, list and pattern comprehensions, FOREACH, LOAD CSV, schema DDL, APOC, and multiple labels per node. A rejection is an explicit error, not a silent wrong answer, so run your whole query inventory once and let the errors build your rewrite list.
Two practical notes. Schema DDL is on the rejected list, so your CREATE NODE TABLE and CREATE REL TABLE statements do not port as text. HelixDB graphs are labeled property graphs, so you create nodes with a label and properties instead. And the published SDK releases do not have Cypher methods yet, which is another reason the HTTP route is the right one for a JVM app anyway.
Step 6: Rewrite MERGE, variable-length, vector and full-text queries as JSON queries for POST /v2/query
Everything on the rejected list gets rewritten as a JSON query. The native SDK DSLs produce this same JSON and the database parses it and runs it, so there is no separate query language to learn. From Java you send the JSON directly.
Here is a read that fetches up to 25 User nodes with status active:
{
"request_type": "read",
"query_name": "active_users",
"query": {
"read": {
"entries": [{
"query": {
"name": "users",
"root": {
"value_map": {
"input": {
"limit": {
"input": {
"where": {
"input": { "nodes_where": { "predicate": {
"eq": { "left": { "property": "$label" },
"right": { "constant": { "string": "User" } } } } } },
"predicate": {
"eq": { "left": { "property": "status" },
"right": { "constant": { "string": "active" } } } }
}
},
"count": { "literal": 25 }
}
},
"properties": ["$id", "name"]
}
}
}
}],
"returns": ["users"]
}
}
}Verify every shape you send against the live docs for your server version before you rely on it, because the query model is what you are porting to.
MERGE. Replace it with an idempotent upsert keyed on your primary key from Step 1: look the node up by key, and write only if it is absent, inside one write batch. Writes commit or roll back atomically, so the check and the insert stay consistent. This also matters for repeated events. MERGE-style idioms can collapse parallel edges, so if your agent records the same relationship many times, model each occurrence as its own edge and keep the history.
Variable-length paths and UNION. Express a bounded traversal as explicit multi-hop steps in the JSON, and run multiple read entries in one batch where you used UNION, merging in Java.
Vector and full-text search. Kuzu's vector and FTS procedure calls become native index queries. Vectors are a property on nodes and edges, text indexes are BM25, and vector search can be pre-filtered to a graph-scoped candidate set. Details and limits are in Replacing Kuzu's Vector and Full-Text Search Indexes. One query can combine graph traversal with similarity, which replaces the stitched-together Cypher plus procedure calls in your Kuzu app.
Most teams rewrite a small minority of queries. Keep the rest on /v2/cypher.
Step 7: Load the export, nodes before edges, and verify counts and results against Kuzu
Load every node table first, then every rel table, because an edge needs both endpoints to exist. Read the Parquet or CSV files from Step 2 with a Parquet or CSV reader in your JVM stack, and for each node row send a create through /v2/cypher (a CREATE with a label and properties) or a write through /v2/query. Batch rows into write batches of a few hundred so each request is one atomic transaction.
For edges, use the key columns you noted in Step 2: look up the source and target nodes by primary key, then add the edge. Make the loader idempotent, so a failed run can be restarted without duplicating anything. Embeddings carry over as vector properties on the nodes.
Then verify against Kuzu, which is still on disk:
Compare node counts per label and edge counts per relationship type.
Run your ten most important queries against both engines and diff the results.
Spot-check a few nodes' properties, including timestamps and vectors.
Re-run your vector and full-text queries and confirm the top results overlap sensibly.
Point production traffic at HelixDB only when counts and results line up. Keep the Kuzu directory read-only until you are confident. The HelixDB Kuzu migration guide walks the same sequence with a TypeScript loader if you want a second reference.
What to do next: deployment choices, common errors and where to get help
You have three run modes, all on the same engine and endpoint: a Docker server (in memory, on disk, or S3-compatible object storage), an embedded build inside an application, or HelixDB Cloud. For a JVM app that already speaks HTTP, the Docker server locally and Helix Cloud in production is the straightforward path. HelixDB Cloud plans start at $5 a month for the Idea tier. Check helix-db.com/pricing.md for current allowances before you commit.
Common errors to expect:
A query is rejected before running. It uses a construct on the unsupported list, such as MERGE or variable-length relationships. Rewrite it as a JSON query for
/v2/query.404 or an unknown endpoint on /v2/cypher. Your server image is older than v0.0.13. Update the image or use Helix Cloud.
Connection refused.
helix start devis not running, or Docker or Podman is down. The server listens on port 6969.Edges missing after load. You loaded edges before nodes, or the key columns did not match. Reload nodes first and check key types.
Vector search rejects a query. An unrestricted vector search caps at 800 effective results, and a traversal-scoped search rejects rather than clamping.
If you also run other language clients, the same pattern is covered for Rust and Go. The source is at github.com/HelixDB/helix-db, and the docs at docs.helix-db.com cover the query model in full.
Port one query this afternoon, then keep going
Skip the big-bang cutover. Run helix init local and helix start dev, drop in the HttpClient wrapper, and send your simplest MATCH query to /v2/cypher today. Then work down the rewrite list one query at a time, and star HelixDB on GitHub at github.com/HelixDB/helix-db when your counts match Kuzu's.