
This blog is written by AI for SEO
Porting Kuzu Python Code to HelixDB: Connection to Query
To port a kuzu Python app to HelixDB, keep the Cypher that falls inside HelixDB's supported openCypher profile and send it to POST /v2/cypher as-is, then rewrite the remaining queries and the data loading with the native Python SDK. HelixDB is an OLTP graph-vector database built in Rust that puts graph traversal, vector search and BM25 full-text search in one engine, so an agent-memory or RAG app that outgrows an archived embedded database has somewhere maintained to land.
This guide goes in order: your existing kuzu code first, then a local HelixDB, then schema, data, queries and verification. You need Python 3.9+, Docker or Podman, and your current Kuzu database file. Plan for an afternoon on a small app. For the query-by-query compatibility detail, see our companion guide on which Kuzu Cypher queries run unchanged.
Step 1: Inventory your Kuzu Python code (Database, Connection, schema, execute, COPY FROM, get_as_df)
Start by listing every place your app touches the kuzu package, because each call type ports differently. A typical Kuzu Python app looks like this:
import kuzu
import pandas as pd
db = kuzu.Database("example.kuzu")
conn = kuzu.Connection(db)
conn.execute("CREATE NODE TABLE Person(name STRING PRIMARY KEY, age INT64)")
conn.execute("CREATE NODE TABLE City(name STRING PRIMARY KEY)")
conn.execute("CREATE REL TABLE LivesIn(FROM Person TO City, since INT64)")
conn.execute('COPY Person FROM "./people.csv"')
df = conn.execute(
"MATCH (p:Person)-[:LivesIn]->(c:City) WHERE p.age >= $age "
"RETURN p.name AS name, c.name AS city ORDER BY name",
{"age": 30},
).get_as_df()Sort what you find into five buckets: setup (kuzu.Database, kuzu.Connection), DDL (CREATE NODE TABLE, CREATE REL TABLE), bulk loads (COPY FROM a file or a DataFrame), reads and writes via conn.execute, and result handling (get_as_df, get_next). Then tag every Cypher string by the clauses it uses.
These clauses are inside HelixDB's supported profile: fixed-length MATCH and OPTIONAL MATCH, WHERE, WITH, RETURN, UNWIND, ORDER BY, SKIP, LIMIT, CREATE, SET, REMOVE, DELETE, DETACH DELETE, common functions and aggregation. These are rejected before they run: MERGE, variable-length relationships (*1..3), shortest paths, UNION, CALL, subqueries, list and pattern comprehensions, FOREACH, LOAD CSV, schema DDL and multiple labels per node.
Mark each query green (in profile) or red (needs a rewrite). The green pile ports in Step 5. The red pile is Step 6.
Step 2: Start HelixDB locally with helix init local and helix start dev
Two commands give you a local server. After installing the Helix CLI (the repo README has the install line), run:
helix init local
helix start devThis needs Docker or Podman and serves on port 6969. Cypher over HTTP needs server image v0.0.13 or later, or Helix Cloud, so check the image tag before you debug anything else.
Install the Python SDK next. The current package is helix-db, imported as helixdb (not the older helix-py):
pip install helix-db requests pandasThe helix-db package on PyPI is the one to pin. Published releases of the SDK (0.3.4 at the time of writing) have no Cypher method, so you call the Cypher endpoint with plain HTTP. That's fine for this port: your Cypher strings stay Cypher strings.
Quick smoke test of the Cypher endpoint:
import requests
BASE = "http://localhost:6969"
r = requests.post(f"{BASE}/v2/cypher", json={"query": "RETURN 1 AS ok"})
print(r.status_code, r.json())If that returns a 2xx and a result, the server is up. The HelixDB docs cover the data model in more depth.
Step 3: Map your CREATE NODE TABLE and CREATE REL TABLE schema to labeled nodes, edges and properties
HelixDB is one labeled property graph, so each Kuzu table becomes a label and there is no DDL step to run. Kuzu forces you to declare a schema up front. HelixDB creates labels and properties as you write them.
| Kuzu | HelixDB |
|---|---|
CREATE NODE TABLE Person(...) |
Nodes with label Person |
| Columns on a node table | Node properties |
PRIMARY KEY column |
A property you treat as the key (see below) |
CREATE REL TABLE LivesIn(FROM Person TO City, since INT64) |
Edges with label LivesIn carrying a since property |
Typed columns (INT64, STRING) |
Typed property values set at write time |
Two things to decide now.
First, keys. Kuzu enforces PRIMARY KEY uniqueness for you. In HelixDB, CREATE does not deduplicate, and MERGE is outside the Cypher profile. Keep a stable key property (name in the example, or a UUID you generate) and look nodes up by it before inserting. We build that check into the loader in Step 4.
Second, one label per node. Kuzu node tables map to exactly one label, so this is a clean fit. If you modeled multi-label nodes in another system, collapse them to one label plus a property.
While you map, note any property that holds an embedding. Vectors are a property on nodes and edges in HelixDB, and vector search can be scoped to graph-related candidates. You won't use that to port, but Step 8 covers it.
Step 4: Export from Kuzu and load the data with the native Python SDK
Export every table to a file you control, then load it with the SDK's batch writes. LOAD CSV and COPY FROM are not part of the HelixDB Cypher profile, so bulk loading goes through the native SDK.
Export from Kuzu first. Either use COPY (MATCH ...) TO or pull DataFrames:
import kuzu
db = kuzu.Database("example.kuzu")
conn = kuzu.Connection(db)
people = conn.execute("MATCH (p:Person) RETURN p.name AS name, p.age AS age").get_as_df()
cities = conn.execute("MATCH (c:City) RETURN c.name AS name").get_as_df()
lives = conn.execute(
"MATCH (p:Person)-[r:LivesIn]->(c:City) "
"RETURN p.name AS person, c.name AS city, r.since AS since"
).get_as_df()
people.to_csv("people.csv", index=False)
cities.to_csv("cities.csv", index=False)
lives.to_csv("lives.csv", index=False)Now load. The SDK builds queries with write_batch() and g(), and add_n creates nodes while add_e creates edges from an input stream to a NodeRef target:
import pandas as pd
from helixdb import Client, NodeRef, g, write_batch
client = Client("http://localhost:6969")
def load_nodes(label, df, chunk=500):
for start in range(0, len(df), chunk):
batch = write_batch()
names = []
for i, row in enumerate(df.iloc[start:start + chunk].to_dict("records")):
name = f"n{i}"
batch = batch.var_as(name, g().add_n(label, row))
names.append(name)
req = batch.returning(names).to_query_request(query_name=f"load_{label}")
client.query(req)
load_nodes("Person", pd.read_csv("people.csv"))
load_nodes("City", pd.read_csv("cities.csv"))Chunk your writes so each batch stays a reasonable size. A batch groups its mutations into one write request, so a failed chunk leaves nothing half-written.
Edges come next. Reference endpoints by variable inside a batch with NodeRef.var(...), as in g().n(NodeRef.var("a")).add_e("LivesIn", NodeRef.var("b"), {"since": 2019}). Create or look up both endpoints in the same batch as the edge. For a one-to-many load, resolve your key to a node first with a read batch (read_batch().var_as("p", g().n_with_label("Person").where(Predicate.eq("name", key))) using Predicate from helixdb), then pass that variable to add_e.
If your export script becomes a repeatable job, that is your MERGE replacement: read by key, create only if absent.
Step 5: Run queries inside the supported openCypher profile as-is over POST /v2/cypher
Every green query from Step 1 can be sent unchanged. The request body is JSON with a required query and optional parameters and query_name. Kuzu uses $param placeholders and so does HelixDB, so parameter names carry over.
import requests
BASE = "http://localhost:6969"
def cypher(query, parameters=None, query_name=None):
body = {"query": query}
if parameters:
body["parameters"] = parameters
if query_name:
body["query_name"] = query_name
r = requests.post(f"{BASE}/v2/cypher", json=body, timeout=30)
r.raise_for_status()
return r.json()
result = cypher(
"MATCH (p:Person)-[:LivesIn]->(c:City) WHERE p.age >= $age "
"RETURN p.name AS name, c.name AS city ORDER BY name",
{"age": 30},
query_name="adults_by_city",
)That is the same Cypher string as the Kuzu version in Step 1, with conn.execute swapped for an HTTP call. Reads, CREATE, SET, DELETE, aggregation with count, sum and friends, WITH pipelines and UNWIND all fall in this bucket.
Write your cypher() helper once and make it the only place that knows about HTTP. Everything above it keeps calling a function with the same shape as conn.execute. When the server rejects a query for using an unsupported clause, the failure happens before anything runs, so a write can never be half-applied. Treat each rejection as a pointer to Step 6.
Cypher and native JSON queries run against the same graph, transactions and indexes, so mixing both in one app is fine.
Step 6: Rewrite the remaining queries with the native Python SDK (MERGE, variable-length paths, UNION and subqueries)
The red pile gets rewritten with the SDK, and each unsupported construct has a direct replacement. The SDK has no Cypher method, so these are native queries sent to POST /v2/query through Client.
MERGE. Replace it with read-then-write. Look the node up by key and create it only if it is missing. In application code that is a read batch followed by a write batch inside one function, which you can call from your loader and your request handlers alike:
from helixdb import Client, Predicate, g, read_batch, write_batch
client = Client("http://localhost:6969")
def upsert_person(name, age):
found = client.query(
read_batch()
.var_as("p", g().n_with_label("Person")
.where(Predicate.eq("name", name)).limit(1)
.value_map(["$id", "name"]))
.returning(["p"])
.to_query_request()
)
if not found.get("p"):
client.query(
write_batch()
.var_as("p", g().add_n("Person", {"name": name, "age": age}))
.returning(["p"])
.to_query_request(query_name="create_person")
)Check the shape of the returned result against your running server before relying on found.get("p"). Print it once and adapt the guard.
Variable-length paths. MATCH (a)-[:KNOWS*1..3]->(b) has no one-line equivalent in the Cypher profile. Either expand the pattern into fixed-length hops (a handful of UNWIND-free queries for hop counts one, two and three, merged in Python), or run the traversal in the SDK and keep the depth bound in your code. For a depth of three or less, fixed-length expansion is the least surprising port.
UNION. Run each branch as its own Cypher call and concatenate or deduplicate in Python. The two branches are independent reads, so you can send them concurrently.
Subqueries and comprehensions. Flatten them. A CALL { ... } subquery usually becomes a first query whose result feeds the parameters of a second. List and pattern comprehensions become a WITH plus UNWIND pipeline, or a Python list comprehension over a simpler return.
Shortest path and procedures. Compute in application code from a fixed-length neighborhood query, or restructure the data so the question becomes a fixed-length lookup.
Expect the red pile to be small. In most apps it is a handful of queries, and the rewrite is mechanical once the pattern is clear. For a Go-side version of the same exercise, see go-kuzu replacement.
Step 7: Replace get_as_df, wrap the calls in a thin data-access layer, and verify counts and results against Kuzu
get_as_df() is a Kuzu convenience, so you rebuild it as a three-line helper over the JSON the endpoint returns. Inspect one response from your server to confirm its shape, then normalize:
import pandas as pd
def cypher_df(query, parameters=None):
payload = cypher(query, parameters)
rows = payload.get("rows", payload) # adjust to the shape you observe
return pd.DataFrame(rows)Put cypher(), cypher_df() and your SDK read and write functions behind one small module, for example graph_store.py, and have the rest of the app import only from it. That module is the single seam. It replaces kuzu.Database and kuzu.Connection in your code, and it is the only file that changes if you later swap transport or client.
Then verify, because a port that loads without errors can still be wrong. Run the same checks against Kuzu and HelixDB and diff them:
Counts per label.
MATCH (n:Person) RETURN count(n) AS cfor every node label, and the same for each edge label.Degree spot checks. Pick 20 random keys and compare neighbor counts.
Your real queries. Run each production query on both stores with the same parameters, sort both outputs, and compare the DataFrames with
pandas.testing.assert_frame_equal.Duplicate check. Group by your key property and confirm every group has size one, since
CREATEwill not stop duplicates.
Keep the Kuzu file read-only until all four pass. Once they do, you are done porting. For the migration-level checklist, our Kuzu Cypher compatibility guide lists the clause-by-clause behavior.
What to do next: add vectors and full-text search to the same graph, and troubleshooting common porting errors
With the port verified, the reason to be on HelixDB is that the same graph now takes vector and full-text retrieval too. Vectors are a property on nodes and edges, with cosine, euclidean and manhattan distance, and vector search can be scoped to graph-related candidates so similarity runs over a filtered set instead of the whole index. BM25 full-text indexes live in the same engine. For agent memory that means one store for relationships, semantic recall and keyword lookup. See llm memory architecture for the pattern.
Common porting errors:
404 or unknown route on /v2/cypher. Your server image is older than v0.0.13. Update the image or use Helix Cloud.
Query rejected before running. It uses
MERGE,*path lengths,UNION,CALL, a subquery, a comprehension,FOREACH,LOAD CSV, schema DDL or multiple labels. Go to Step 6.AttributeErroron a Cypher method. The published Python SDK has no Cypher method. Call the HTTP endpoint withrequests.Duplicate nodes after reloading.
CREATEdoes not deduplicate. Use the read-then-write upsert from Step 6.ModuleNotFoundError: helix. You installed the oldhelix-py. Installhelix-dband importhelixdb.Connection refused on 6969. Run
helix start devand confirm Docker or Podman is running.
Port the green queries today, rewrite the red ones this week
Most of a kuzu Python app moves with a find-and-replace on the call site, because the Cypher it already speaks mostly runs as-is on HelixDB, and the few queries that don't become short SDK functions. Run helix init local and helix start dev, point your graph_store.py at port 6969, and diff counts against Kuzu. When the numbers match, star HelixDB on GitHub, then add a vector property to your first node label.