
This blog is written by AI for SEO
Kuzu Rust Crate Replacement: Port a Rust App to HelixDB
To replace the archived kuzu crate in a Rust service, export the graph, start a local HelixDB server, send the Cypher that fits HelixDB's supported openCypher profile to POST /v2/cypher over HTTP, and rewrite the remainder with the HelixDB Rust SDK 3.0.0. You don't rewrite every query, but you do leave in-process for a local server.
HelixDB is an open-source graph-vector database built in Rust that puts graph traversal, vector search and BM25 full-text search in one engine. This guide starts from the Kuzu side (your DDL, your Database and Connection calls, your export) and ports from there. Prerequisites: a Rust service on the kuzu crate, Docker or Podman for the local server, and the Helix CLI installed. If you are coming from another binding, the same path is covered for Python, Node.js and Go.
Step 1: Inventory what your kuzu crate code actually does
Start by writing down every call your service makes into Kuzu. The port is a sorting exercise, and you can't sort what you haven't listed.
The kuzu crate gives you Database::new(path, SystemConfig::default()), then Connection::new(&db), then conn.query(...) returning a QueryResult. A typical service looks like this:
use kuzu::{Connection, Database, SystemConfig};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = Database::new("mydb", SystemConfig::default())?;
let conn = Connection::new(&db)?;
conn.query("CREATE NODE TABLE Person(name STRING, PRIMARY KEY(name));")?;
conn.query("CREATE (:Person {name: 'Alice'});")?;
let result = conn.query("MATCH (p:Person) RETURN p.name;")?;
println!("{result}");
Ok(())
}Grep your repo for Database::new, Connection::new and .query( and dump every Cypher string into one file. Next to it, list your schema DDL: each CREATE NODE TABLE and CREATE REL TABLE, plus any vector or full-text index definitions. Note which statements are reads, which are writes, and which use MERGE, variable-length paths or procedure calls. That file drives Steps 4 through 6.
Also note how your app handles the connection. In-process Kuzu means no network hop and no second process. That changes in Step 5.
Step 2: Export the Kuzu graph with EXPORT DATABASE and COPY ... TO
Export from Kuzu into files you control before you touch HelixDB, so the migration is replayable.
EXPORT DATABASE dumps your schema and table data. Run it through the same crate you already use:
conn.query("EXPORT DATABASE '/tmp/kuzu_export';")?;If you want one table at a time, or CSV you can read by eye, use COPY ... TO:
conn.query("COPY (MATCH (p:Person) RETURN p.*) TO '/tmp/person.csv';")?;Pause writes first. Exporting a graph that is still taking writes is how you end up reconciling counts for two days. Record node and relationship counts per table at export time (MATCH (n:Person) RETURN count(n)) and keep them somewhere safe. You'll compare against them in Step 7.
The broader schema-mapping questions (tables to labels, embeddings, full-text indexes) are covered in the Kuzu migration guide.
Step 3: Start a local HelixDB with helix init local and helix start dev
Stand up the target before you port any code. helix init local scaffolds the project and helix start dev runs the server.
helix init local
helix start devThis needs Docker or Podman, and the server listens on port 6969. The Cypher endpoint needs server image v0.0.13 or later (or Helix Cloud), so check the version if /v2/cypher returns a not-found. The open-source core is Apache-2.0 at github.com/HelixDB/helix-db, and the docs cover the rest of the CLI.
Step 4: Sort your Cypher into runs-unchanged and needs-a-rewrite
Take the query file from Step 1 and split it into two piles: statements the supported openCypher profile accepts as written, and statements that need a rewrite.
Runs unchanged: 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.
Rejected before running, so these go in the rewrite pile: 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.
In a Kuzu app, your reads (MATCH ... WHERE ... RETURN) usually land in the first pile. Your CREATE NODE TABLE DDL, any MERGE upserts, and any QUERY_VECTOR_INDEX or QUERY_FTS_INDEX procedure calls land in the second. The per-query breakdown lives in Kuzu Cypher on HelixDB: Which Queries Run Unchanged.
There is no table DDL step on the HelixDB side. Nodes carry labels and properties, so your CREATE NODE TABLE statements become a mapping decision, not a query to port.
Step 5: Call POST /v2/cypher from Rust over HTTP
The published Rust SDK 3.0.0 has no Cypher methods, so the unchanged pile goes through a plain HTTP client posting to /v2/cypher.
Any Rust HTTP client works. This sketch uses reqwest and serde_json. Confirm the request body shape against the HelixDB docs for your server version before you depend on it:
use serde_json::json;
async fn run_cypher(query: &str) -> Result<serde_json::Value, reqwest::Error> {
let client = reqwest::Client::new();
let resp = client
.post("http://localhost:6969/v2/cypher")
.json(&json!({ "query": query }))
.send()
.await?
.error_for_status()?;
resp.json().await
}Wrap this in a small repository function so the rest of your service never knows whether a call is Cypher or SDK. That's the whole trick. Your old conn.query("MATCH ...")? call sites become run_cypher("MATCH ...").await?, and the Cypher string stays byte for byte the same.
Two things change for the code around it. The call is now async and can fail over the network, so add timeouts and retries where Kuzu gave you none. And writes go over HTTP to a server, not into a file your process owns.
Step 6: Rewrite the rest with the HelixDB Rust SDK 3.0.0
Everything in the rewrite pile moves to the HelixDB Rust SDK, the helix-db crate imported as helixdb, which builds queries with a DSL and sends them to /v2/query.
You build a Client, then send a QueryRequest. The DSL produces JSON, the database parses it and executes it, so there's no separate query language and nothing is compiled or deployed ahead of time. Check the crate's module docs for the exact builder signatures. They're the source of truth, and the surface can move.
Map your rewrite pile like this:
MERGEupserts become explicit lookups plus idempotent writes keyed on a stable application ID. Don't rely on Kuzu internal row IDs.Variable-length paths and shortest paths become traversals expressed in the DSL.
QUERY_VECTOR_INDEXandQUERY_FTS_INDEXcalls become vector and BM25 text index queries. Vectors are a property on nodes and edges, and vector search can be pre-filtered to a graph-scoped candidate set instead of the whole index.
Graph and vector writes commit or roll back together, so your graph and your embedding store can't drift apart. For the shape of retrieval over entities and edges, see Knowledge Graph RAG: How to Retrieve Over Entities and Edges.
Step 7: Load the data and verify counts against Kuzu
Load nodes before edges, make every write safe to re-run, then compare counts to the numbers you recorded in Step 2.
Read the Parquet or CSV files from Step 2 and write nodes first, keyed on a stable application ID. Then write edges, looking up both endpoints by that ID. If the loader dies halfway, a re-run should converge to the same graph rather than duplicating nodes. That's why you key on application IDs and avoid anything that depends on insertion order.
Then verify. For each label and relationship type, run a count through /v2/cypher and compare it with your Kuzu count:
let v = run_cypher("MATCH (p:Person) RETURN count(p)").await?;Matching counts are necessary, not sufficient. Pick a sample of 20 to 50 entities and compare the neighbours returned by the same traversal on both databases, with Kuzu still readable from its frozen export. Run your repository-level functions against both and diff the returned structs. Cut over only when the diffs are empty.
What changes from in-process to a local server, and what to do next
You're trading a library inside your process for a server your process talks to, and you should plan for that.
What you give up: the in-process model where Database::new opens a file and a query is a function call. Now there's a running server to start, monitor and upgrade, and every query crosses HTTP. What you get: a maintained engine, one store for graph, vector and full-text retrieval instead of stitching procedure calls together, and a path from your laptop to a self-hosted Docker server or HelixDB Cloud on the same engine and endpoint. If you want embedded operation, the TypeScript client has an embedded mode, but check the Rust crate docs before assuming the same in Rust.
Practical follow-ups: put the server in your docker-compose so integration tests start it, add health checks and request timeouts to the wrapper from Step 5, and keep the Kuzu export in object storage until you've run on HelixDB through a full release cycle.
Start with the unchanged pile, then port the rest
Your kuzu crate service doesn't need a rewrite, it needs a new address. Run helix init local and helix start dev, point your supported Cypher at /v2/cypher, and move the leftovers to the Rust SDK 3.0.0 one query at a time. Star HelixDB at github.com/HelixDB/helix-db and try the first read query against a loaded sample of your export today.