Back to blog
go-kuzu Replacement: Move a Go App to HelixDB

This blog is written by AI for SEO

go-kuzu Replacement: Move a Go App to HelixDB

HelixDB11 min read

Kuzu is archived, and your Go service still imports it. The agent-memory or GraphRAG code you wrote around kuzu.OpenDatabase and conn.Query works today, but nobody is shipping fixes for it, and every dependency bump is a small gamble. You need a maintained home for the graph, and you'd rather not rewrite the whole data layer to get there.

You probably don't have to. HelixDB runs a supported openCypher profile at POST /v2/cypher, so queries inside that profile run as they are. Only the queries that use unsupported features get rewritten as JSON queries with the Go SDK. This guide walks the port in order: inventory, export, local server, query triage, the HTTP call from Go, the rewrites, and a count check against Kuzu.

Prerequisites: a Go service using go-kuzu, your Kuzu schema DDL, Docker or Podman for the local server, and the helix CLI installed. The Cypher endpoint needs server image v0.0.13 or later (or HelixDB Cloud). One caveat up front: Kuzu ran inside your process, and HelixDB runs as a server you call over HTTP. That trade-off is covered at the end.

Step 1: Inventory what your go-kuzu app actually does

Before touching HelixDB, write down what your service asks of Kuzu. Grep your repo for conn.Query, conn.Prepare and kuzu.OpenDatabase, and paste every distinct Cypher string into one file. That file is your migration spec.

For each query, record four things: whether it reads or writes, which node and relationship tables it touches, which Cypher features it uses, and how often it runs. A typical agent-memory service has a handful of shapes: upsert an entity, link two entities, fetch an entity's neighbours, and a few retrieval queries that walk two or three hops. Most services have far fewer distinct query shapes than call sites.

Also copy your schema DDL. Kuzu is schema-first, so your CREATE NODE TABLE and CREATE REL TABLE statements tell you the labels, property names and types you need to carry over. Note any MERGE statements and any variable-length patterns like [:KNOWS*1..3]. Those two are the usual suspects in the rewrite pile, and you want to know how many you have before you start.

Finally, note how the process is deployed. If Kuzu lives in the same binary as your HTTP handlers, the move to a local server adds a network hop and a second process to supervise. Write that down now so it doesn't surprise you in Step 3.

Don't skip this step to save time. A one-hour inventory lets you tell your team, with a number, how many queries move untouched and how many need real work.

Step 2: Export the Kuzu graph with COPY and EXPORT DATABASE

Get the data out of Kuzu in a format you control. Kuzu gives you two routes, and you should use both.

EXPORT DATABASE writes the schema and the data to a directory. Run it from your existing Go code or from the Kuzu shell against the on-disk database:

db, _ := kuzu.OpenDatabase("./kuzu-data", kuzu.DefaultSystemConfig())
conn, _ := kuzu.OpenConnection(db)
_, err := conn.Query("EXPORT DATABASE './kuzu-export'")

That directory is your archive and your record of the original DDL. Keep it in version control or object storage even if you never use it again.

For the actual load into HelixDB you want flat files per table, so also run COPY (...) TO for each node table and each relationship table:

COPY (MATCH (e:Entity) RETURN e.id, e.name, e.kind, e.created_at) TO 'entities.csv';
COPY (MATCH (a:Entity)-[r:RELATES]->(b:Entity) RETURN a.id, b.id, r.type, r.weight) TO 'relates.csv';

Export node IDs explicitly. Edges reference nodes by your own stable key, not by Kuzu's internal row offsets, so every relationship file needs the source and target keys as columns. If you store embeddings as FLOAT[] columns, export them too, in a format your loader can parse back into a float array (JSON arrays per row are simplest).

Write down the row count of every file as you export. You'll compare against those numbers in Step 7. If a count looks wrong now, fix it here, because after the load you won't know which side lost the rows.

Step 3: Start a local HelixDB server with helix init and helix start dev

Stand up the target before you port any code. With Docker or Podman running, create a project and start the dev server:

helix init local
helix start dev

The server listens on port 6969. Use this path for a repeatable setup: two commands, same result on every machine, so a teammate can reproduce the migration exactly.

Smoke-test the server from the shell before writing any Go:

curl -s -X POST http://localhost:6969/v2/cypher \
  -H 'Content-Type: application/json' \
  -d '{"query": "RETURN 1 AS ok"}'

If that returns a result, the endpoint is up and the image is new enough. If it errors, check that you're on server image v0.0.13 or later, since the Cypher endpoint needs it.

Here's the in-process to local-server change in practice. With go-kuzu, a crash in your service took the database down with it and restart was free. Now the database is its own process: add it to your docker-compose file, give it a volume, and add a health check so your Go service waits for it on startup. The open-source core is Apache-2.0 (github.com/HelixDB/helix-db), so running it beside your service costs nothing in licensing. If you later want managed operations and reader scaling, HelixDB Cloud is the same engine behind a gateway, and the same endpoint shape applies.

Step 4: Sort your Cypher into runs-unchanged and needs-a-rewrite

Take the query file from Step 1 and sort every statement into two piles. The supported openCypher profile covers 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. Anything built from those pieces goes in the runs-unchanged pile.

The endpoint rejects the following before running them, so these go in the rewrite pile:

  • MERGE

  • variable-length relationships such as [:KNOWS*1..3]

  • shortest paths

  • UNION

  • CALL and procedures

  • subqueries

  • list and pattern comprehensions

  • FOREACH

  • LOAD CSV

  • schema DDL

  • APOC

  • multiple labels per node

Two items deserve a closer look because Kuzu code leans on them. First, schema DDL: your CREATE NODE TABLE statements don't run on /v2/cypher, which is fine, because you aren't recreating a fixed schema there. Second, MERGE: upserts are the most common write in agent-memory code. Each MERGE becomes either a read-then-write in your Go code or a JSON query, depending on the shape.

Be specific in your tracking. A spreadsheet with columns for query text, call site, verdict and rewrite owner is enough. For a typical service, the read-heavy queries (neighbour lookups, filtered matches, aggregations) land in the unchanged pile, while the write path with upserts and the multi-hop retrieval land in the rewrite pile. That split is the whole point: you port a few queries, not the whole data layer.

Step 5: Call POST /v2/cypher from Go with net/http

The HelixDB Go SDK v0.3.1 provides the Cypher HTTP endpoint at POST /v2/cypher, or you can call the HTTP API directly. That's a small wrapper over net/http. Keep it in one file so the rest of your service talks to an interface, not to HTTP.

package graph

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
)

type CypherClient struct {
    BaseURL string
    HTTP    *http.Client
}

type cypherRequest struct {
    Query  string         `json:"query"`
    Params map[string]any `json:"parameters,omitempty"`
}

func (c *CypherClient) Run(ctx context.Context, q string, params map[string]any) (json.RawMessage, error) {
    body, err := json.Marshal(cypherRequest{Query: q, Params: params})
    if err != nil {
        return nil, err
    }
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v2/cypher", bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")
    resp, err := c.HTTP.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    out, _ := io.ReadAll(resp.Body)
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("cypher: %d: %s", resp.StatusCode, out)
    }
    return out, nil
}

The request and response field names above follow the shape of the endpoint as you saw it in your Step 3 smoke test. Confirm them against the live docs at docs.helix-db.com before you commit, and adjust the struct if the server returns a different envelope.

Now swap call sites. Where you had conn.Query("MATCH ...") and iterated a QueryResult, you now call client.Run(ctx, "MATCH ...", params) and decode JSON. Start with one read query from the unchanged pile, run it against both Kuzu and HelixDB with the same parameters, and compare the rows. Pass values as parameters rather than interpolating strings, exactly as you would with conn.Prepare. Set a client timeout on the http.Client. The default has none, and a network hop needs one.

Step 6: Rewrite the unsupported queries as JSON queries with the Go SDK

Now the rewrite pile. These queries use features the Cypher profile rejects, so you express them as JSON queries through the native Go SDK, which sends them to POST /v2/query. The SDK builds the same JSON the other SDKs do, and the database parses it and turns it into the code that executes the query. There's no separate query language to learn and nothing to compile or push ahead of time.

Three rewrites cover most Kuzu services.

MERGE upserts. Replace MERGE (e:Entity {id: $id}) SET e.name = $name with an explicit sequence: look the node up by key, and write only if it's missing or changed. Put the lookup and the write in one write batch so they commit or roll back together. Transactions provide atomic graph operations, including vectors.

Variable-length traversals. [:RELATES*1..3] becomes explicit hops in the traversal, one step per hop, with a limit on each. This is more typing, but you see exactly how wide each hop fans out, which helps when an agent's neighbourhood gets large.

Retrieval with embeddings. If you stored FLOAT[] columns and computed similarity in your own code, this is where HelixDB removes work. Vectors are a property on nodes and edges, and vector search can be scoped to related entities, so similarity runs over a graph-filtered candidate set instead of the whole index. Note the documented ceiling: an unrestricted vector search caps at 800 effective results. For the details of mixing keyword and vector ranking in one query, see How to Combine BM25 and Vector Search in One Query for RAG.

Check every SDK call against the current Go SDK types and the query guide at docs.helix-db.com. Don't copy method names from older write-ups. Some of them predate the JSON query model.

Step 7: Load the data and verify counts against Kuzu

With the server running and queries ported, load the exported files. Write the loader as a small Go program that reads each CSV, batches rows, and writes them through a write batch (nodes first, then edges, so every edge endpoint exists). Batches of a few hundred to a few thousand rows keep each transaction manageable. If you exported embeddings, set them as the vector property in the same batch as the node so the node and its vector commit together.

Order matters. Load node tables in dependency order, build your key-to-node lookup as you go, then load relationship files using your own stable keys from Step 2. If an edge references a key you didn't load, fail loudly rather than skipping it.

Then verify, and be strict about it. Take the row counts you recorded in Step 2 and compare:

MATCH (e:Entity) RETURN count(e) AS n
MATCH ()-[r:RELATES]->() RETURN count(r) AS n

Run those on /v2/cypher for each label and relationship type and check them against your export counts and against the live Kuzu database. Counts must match exactly. Then go one level deeper: sample 50 random entities, fetch each one's neighbours from both databases, and compare the neighbour sets. Count equality catches missing rows. Neighbour comparison catches mis-wired edges.

Finally, replay your inventory. Run every query from the Step 1 file against both systems with production-like parameters and diff the results. Keep Kuzu read-only and running during this phase, then cut traffic over query by query rather than all at once. Retire the go-kuzu import last, once the diffs are clean.

What changes: in-process to local server, and what to do next

Be honest about the trade. go-kuzu gave you a database inside your process: no network, no second binary, no ports. HelixDB as a local server gives that up. You now run a separate process, call it over HTTP, and handle timeouts, retries and startup ordering. For most agent-memory and GraphRAG services that cost is small. In return you get a maintained engine, one store for graph, vector and full-text retrieval, and the same endpoint whether you run it locally, in Docker, or on HelixDB Cloud.

If you want to stay in-process, HelixDB also runs embedded inside your application, but the embedded client is documented for TypeScript, so a Go service should plan on the server route for now.

Two things to do after cutover. First, pin your server image version and record it, since the Cypher endpoint depends on v0.0.13 or later. Second, treat the rewritten JSON queries as your long-term interface and move more Cypher over to them as the service grows, because that's where vector scoping and full-text live.

For context on the wider picture, Kuzu Alternatives in 2026: Embedded Graph Databases compares your options, and the Kuzu Migration Guide covers the data-model side in more depth.

Conclusion

Your go-kuzu service doesn't need a rewrite, it needs a new address. Run helix init local and helix start dev, point one read query at /v2/cypher, and diff it against Kuzu today. If it matches, you have your first migrated query and a clear count of how many remain. Then port the MERGE and variable-length queries as JSON queries and cut over one call site at a time.

HelixDB is open source under Apache-2.0, so the whole migration can run on your laptop before anyone approves a cloud bill. Star the repo at github.com/HelixDB/helix-db, read the query guide at docs.helix-db.com, and bring your hardest query to the Discord. A maintained graph under your agent's memory is a one-afternoon spike, not a quarter-long project.

Build with HelixDB

Give your coding agent the setup prompt, or sign up and deploy a database.

Sign up