
This blog is written by AI for SEO
Kuzu Cypher on HelixDB: Which Queries Run Unchanged
Most read queries and plain CREATE/SET/DELETE statements from a Kuzu agent-memory app run unchanged on HelixDB's openCypher profile at POST /v2/cypher. MERGE, variable-length paths, UNION and CALL do not, and those get rewritten with the native SDK. This guide walks a kuzu cypher migration query by query: write down the Kuzu schema and the Cypher your app runs, export the data, then sort every query into run-as-is or rewrite. You don't have to rewrite everything.
HelixDB is an open-source graph-vector database built in Rust that handles graph traversal, vector search and BM25 full-text search in one engine. Kuzu was archived around September 2025, so the question for most teams is how much of their Cypher survives the move. You'll need Docker or Podman, an export of your Kuzu database, and the Cypher strings your app sends. This guide assumes you're comfortable with Cypher, embeddings and HTTP APIs.
Step 1: Write down your Kuzu schema and the Cypher your app runs
Start from the source side, before touching HelixDB. Pull the DDL and grep your codebase for every Cypher string. A typical Kuzu agent-memory schema looks like this:
CREATE NODE TABLE User(id STRING PRIMARY KEY, name STRING);
CREATE NODE TABLE Session(id STRING PRIMARY KEY, started_at TIMESTAMP);
CREATE NODE TABLE Memory(id STRING PRIMARY KEY, text STRING, created_at TIMESTAMP, embedding FLOAT[384]);
CREATE NODE TABLE Entity(id STRING PRIMARY KEY, name STRING, kind STRING);
CREATE REL TABLE HAS_SESSION(FROM User TO Session);
CREATE REL TABLE WROTE(FROM Session TO Memory);
CREATE REL TABLE MENTIONS(FROM Memory TO Entity);
CREATE REL TABLE RELATED_TO(FROM Entity TO Entity, weight DOUBLE);Now list the queries the app actually runs. Here is a representative set, numbered so we can triage them later:
-- Q1: recent memories for a user
MATCH (u:User {id: $uid})-[:HAS_SESSION]->(:Session)-[:WROTE]->(m:Memory)
WHERE m.created_at > $since
RETURN m.id, m.text ORDER BY m.created_at DESC LIMIT 20;
-- Q2: entities mentioned by a memory
MATCH (m:Memory {id: $mid})-[:MENTIONS]->(e:Entity)
RETURN e.name, e.kind;
-- Q3: insert a memory
MATCH (s:Session {id: $sid})
CREATE (s)-[:WROTE]->(:Memory {id: $mid, text: $text, created_at: $ts});
-- Q4: upsert an entity
MERGE (e:Entity {id: $eid}) SET e.name = $name;
-- Q5: entities within three hops
MATCH (a:Entity {id: $eid})-[:RELATED_TO*1..3]->(b:Entity)
RETURN DISTINCT b.name;
-- Q6: memory ids from two sources
MATCH (m:Memory)-[:MENTIONS]->(:Entity {name: $a}) RETURN m.id
UNION
MATCH (m:Memory)-[:MENTIONS]->(:Entity {name: $b}) RETURN m.id;
-- Q7: delete a user's memories
MATCH (u:User {id: $uid})-[:HAS_SESSION]->(:Session)-[:WROTE]->(m:Memory)
DETACH DELETE m;
-- Q8: Kuzu vector index query
CALL QUERY_VECTOR_INDEX('Memory', 'mem_idx', $vec, 10) RETURN node.id;Keep this file. It is your triage worksheet.
Step 2: Export the Kuzu database to Parquet or CSV
Kuzu's EXPORT DATABASE writes schema and data files you can load elsewhere. Parquet is the default. Pass format='csv' for CSV.
EXPORT DATABASE '/path/to/export';
-- or
EXPORT DATABASE '/path/to/export_csv' (format='csv', header=true);You get one data file per node table and per relationship table, plus the schema and copy statements Kuzu would use to re-import. HelixDB does not read Kuzu's import script, so treat the files as plain tabular data. Note each table's primary key column and each relationship file's from/to key columns. You will need them in Step 7 to rebuild edges.
Step 3: Start HelixDB locally and check the /v2/cypher endpoint
Install the Helix CLI, then use the deterministic local setup from the repo README:
helix init local
helix start devThis needs Docker or Podman and serves on port 6969. The Cypher endpoint needs server image v0.0.13 or newer, or Helix Cloud. Send a trivial statement to confirm it is live:
curl -s localhost:6969/v2/cypher \
-H 'content-type: application/json' \
-d '{"query": "RETURN 1 AS ok"}'The endpoint takes one statement with optional parameters per request. The HelixDB docs cover the full request shape at docs.helix-db.com. Check the exact payload field names there before wiring a client, since the published SDK releases (TypeScript 3.2.0, Python 0.3.4, Rust 3.0.0, Go v0.3.1) do not have Cypher methods yet. Use plain HTTP for Cypher and the SDKs for native queries.
Step 4: Triage each query against the supported openCypher profile
HelixDB supports a profile of openCypher, not the whole language. Inside the profile: 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 standard aggregation. Outside it, statements are rejected before execution: 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.
Sort the worksheet from Step 1:
| Query | What it uses | Verdict |
|---|---|---|
| Q1 recent memories | fixed-length MATCH, WHERE, ORDER BY, LIMIT | Run as-is |
| Q2 entities per memory | fixed-length MATCH, RETURN | Run as-is |
| Q3 insert memory | MATCH + CREATE | Run as-is |
| Q4 upsert entity | MERGE | Rewrite |
| Q5 three-hop neighbours | [:RELATED_TO*1..3] |
Rewrite |
| Q6 two-source ids | UNION | Rewrite |
| Q7 delete memories | MATCH + DETACH DELETE | Run as-is |
| Q8 vector index call | CALL | Rewrite |
Four of eight survive here, and in a real app the read-heavy bulk of the workload is usually the fixed-length kind. A construct outside the profile is rejected before execution rather than returning a silent wrong answer.
Step 5: Run the compatible queries as-is over HTTP
For the queries marked run-as-is, keep the Cypher text and change only the transport: swap the Kuzu client call for an HTTP POST. Q1 with parameters:
curl -s localhost:6969/v2/cypher \
-H 'content-type: application/json' \
-d '{
"query": "MATCH (u:User {id: $uid})-[:HAS_SESSION]->(:Session)-[:WROTE]->(m:Memory) WHERE m.created_at > $since RETURN m.id, m.text ORDER BY m.created_at DESC LIMIT 20",
"params": {"uid": "u_42", "since": "2026-01-01T00:00:00Z"}
}'Wrap this in a small cypher(query, params) function in your app and route Q1, Q2, Q3 and Q7 through it unchanged. These statements run on the same graph, transactions and indexes as native JSON queries, so a write made through Cypher is visible to an SDK query immediately.
Two things to watch. First, every node needs a single label, since multiple labels per node are rejected. Kuzu's tables already enforce one label per node, so this rarely bites. Second, Kuzu's typed schema is declared up front with DDL, and schema DDL is not accepted over Cypher on HelixDB, so don't carry the CREATE NODE TABLE statements across. Properties are written by your CREATE statements and the load in Step 7.
Step 6: Rewrite MERGE, variable-length and UNION queries with the native SDK
The remaining queries get rewritten, not abandoned, and the rest of your app does not change. The native SDKs build JSON queries and send them to POST /v2/query. There is no separate query language to learn. The sketches below show the shape of each rewrite. Confirm exact builder names against the SDK types and the HelixDB docs before shipping, because builder method names are the part most likely to differ from a sketch.
Q4, the MERGE upsert. MERGE is rejected before execution. Replace it with an explicit read-then-write inside one write batch, so the check and the create commit or roll back together:
import { writeBatch, g } from "@helix-db/helix-db";
// Pseudocode shape: look up Entity by id; if absent, add it; then set name.
// Build with writeBatch() and g(), run with client.query(batch.toQueryRequest()).send().If you'd rather keep the logic in Cypher, split it in two requests in your app: MATCH (e:Entity {id: $eid}) RETURN e first, then CREATE (:Entity {id: $eid, name: $name}) only when no row came back. The SDK batch is the safer choice under concurrent writers because the whole thing is one transaction.
Q5, variable-length paths. [:RELATED_TO*1..3] is rejected. If your hop count is fixed and small, write the fixed-length patterns directly. One explicit hop is plain Cypher and runs as-is:
MATCH (a:Entity {id: $eid})-[:RELATED_TO]->(b:Entity) RETURN b.name AS nameFor depth 2 and 3, run the two- and three-hop fixed patterns as separate statements and merge the results in your app. When depth really is data-dependent, do the traversal in the SDK with a loop of frontier expansions.
Q6, UNION. Run the two MATCH statements as two requests and union the id sets in application code. Or fold them into one statement with WHERE e.name IN [$a, $b], which stays inside the profile:
MATCH (m:Memory)-[:MENTIONS]->(e:Entity) WHERE e.name IN [$a, $b] RETURN DISTINCT m.idQ8, the CALL vector query. Procedures are rejected. Vectors in HelixDB are a property on nodes and edges, queried with the SDK's vector search, and the search can be scoped to a graph-filtered candidate set instead of the whole index. That is usually an upgrade over Kuzu's standalone index call for agent memory, since you can restrict similarity to one user's memories. Mind the documented limits: an unrestricted vector search caps at 800 effective results, and a traversal-scoped search rejects rather than silently clamping. See Knowledge Graph RAG: How to Retrieve Over Entities and Edges for a worked retrieval pattern.
Step 7: Load the exported data and verify results against Kuzu
Load nodes first, then edges, using the key columns you noted in Step 2. The simplest path is a script that reads each Parquet or CSV file and issues batched writes through the SDK, or CREATE statements over /v2/cypher for modest volumes. For nodes, use one batch per table. For edges, match both endpoints by primary key, then create the edge:
MATCH (s:Session {id: $from}), (m:Memory {id: $to})
CREATE (s)-[:WROTE]->(m)Batch many rows per write so each commit carries graph and vector data together. Put the embedding column on the Memory node as a vector property when you load it, so the Q8 rewrite has something to search.
Then verify. For each query in the worksheet, run it against Kuzu and against HelixDB with the same parameters and diff the results:
Row counts per node and edge table match the export.
Q1, Q2 and Q7 return identical row sets, with the same ORDER BY and LIMIT outcome.
The rewritten Q4, Q5, Q6 and Q8 return the same ids as the Kuzu originals on a fixed sample of parameters.
Order-sensitive results need a deterministic ORDER BY on both sides, or the diff will flag noise. Run the same check after a restart to confirm writes persisted.
What to do next: troubleshooting unsupported_feature errors
An unsupported construct is rejected before execution. Treat it as a triage label, not a bug. Map the detail to the fix:
merge: rewrite as read-then-create in one SDK write batch.variable_length_pattern: expand into fixed-length patterns, or traverse in the SDK.UNION: issue separate statements and combine in app code, or fold into
IN.CALL: replace with the SDK's vector search or the equivalent native query.
Multiple labels on one node: collapse to a single label and move the second label into a
kindproperty.
If an error names a feature you thought was supported, check the server image version first. The Cypher endpoint needs v0.0.13 or newer. Two related reads: go-kuzu Replacement: Move a Go App to HelixDB covers the client-side swap for Go, and How to Delete an AI Agent's Memory of a User builds on the Q7 delete pattern.
Start with the queries that already run
A kuzu cypher migration is not all-or-nothing. Reads and CRUD inside the openCypher profile move over as-is, and the handful that use MERGE, variable-length paths, UNION or CALL get a targeted SDK rewrite. Next step: run helix init local and helix start dev, push your Q1-style read query through POST /v2/cypher, and see how much of your worksheet turns green. HelixDB is Apache-2.0, so star and try it at github.com/HelixDB/helix-db.