Docs
View as markdown

Migrate from Kuzu

Export a Kuzu database's node and relationship tables as JSON, declare its schema as a relate graph type, load the files, and translate the Cypher that differs.

clicypher

Before you start: Install relate

This guide shows you how to move a graph from Kuzu, another embedded graph database that speaks a Cypher dialect, into a relate store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary. A short Python script exports each node and relationship table as JSON. In relate, a graph type A schema for a whole graph, declared in Cypher, that lists node and relationship types with their properties; writes that break it fail. Read more in the glossary declares the same tables, keys and endpoints, and one script loads the files with --fetch-params and keyed MERGE statements. The last sections translate the statements that differ between the two dialects, each with the relate form runnable on this page.

The Kuzu GitHub repository is archived, and its last release is 0.11.3, published 2025-10-10 (kuzudb/kuzu); its README says earlier releases keep working without changes. The Kuzu steps here ran on 0.11.3, through the kuzu Python package. The relate steps run in a terminal and write to ./team.rl; the cells run in page order on one in-memory store, in your browser and in the docs build.

Prerequisites

  • relate on your PATH (Install relate).
  • Python with the kuzu package at the version that opens your database, for the export.
  • Each table's JSON export at most 64 MiB, the --fetch-params limit per file.
  • The whole graph, its indexes included, fits in memory (size a store and its memory).

Plan the mapping

In KuzuIn relate
CREATE NODE TABLE Person(...)A label, declared in a graph type as (:Person => {...}). Outside a graph type, a label needs no declaration
PRIMARY KEYIS KEY on the property: a node key constraint, unique and required
CREATE REL TABLE WORKS_ON(FROM Person TO Project, ...)A relationship type with its endpoints: (:Person =>)-[:WORKS_ON => {...}]->(:Project =>)
A relationship table with several FROM … TO pairsOne relationship type. A graph type declares one endpoint pair per type, and a second fails with semantic error: SyntaxError: duplicate identifying label; leave that type out of the graph type, and relate accepts it between any labels
INT64, DOUBLE, STRING, BOOLEANINTEGER, FLOAT, STRING, BOOLEAN
DATE, TIMESTAMPDATE with date(), LOCAL DATETIME with localdatetime(): the JSON export writes both as strings
STRING[]LIST<STRING NOT NULL>
SERIALAn INTEGER key. relate has no sequences: a statement assigns the next value (Assign the next id)
One table per nodeAny number of labels per node

Export the tables from Kuzu

The example is the team graph the Get started pages use, with issues filed against projects, in this Kuzu schema:

CREATE NODE TABLE Person(name STRING PRIMARY KEY, role STRING);
CREATE NODE TABLE Project(name STRING PRIMARY KEY, language STRING);
CREATE NODE TABLE Issue(id SERIAL PRIMARY KEY, title STRING, opened DATE, labels STRING[]);
CREATE REL TABLE WORKS_ON(FROM Person TO Project, since INT64);
CREATE REL TABLE MAINTAINS(FROM Person TO Project);
CREATE REL TABLE KNOWS(FROM Person TO Person);
CREATE REL TABLE FILED_IN(FROM Issue TO Project);

Kuzu's EXPORT DATABASE writes CSV or Parquet. relate has no Parquet reader, and LOAD CSV reads Kuzu's CSV form of a list as one string: Kuzu writes the elements unquoted inside brackets, so an element that holds a comma can't be told apart from two. This script exports each table to JSON instead, which keeps numbers, booleans, lists and NULL as they are. It opens the database read-only, reads the tables from Kuzu's catalog, and writes one file per node table and one per relationship table and endpoint pair, with the endpoints' primary keys as source and target:

export_kuzu.py
import os
import kuzu

conn = kuzu.Connection(kuzu.Database("team.kuzu", read_only=True))


def rows(query):
    result = conn.execute(query)
    while result.has_next():
        yield result.get_next()


os.makedirs("export", exist_ok=True)
for _, table, kind, *_ in rows("CALL show_tables() RETURN *"):
    props = [row[1] for row in rows(f"CALL table_info('{table}') RETURN *")]
    if kind == "NODE":
        columns = ", ".join(f"n.{p} AS {p}" for p in props)
        queries = {table: f"MATCH (n:{table}) RETURN {columns}"}
    else:
        queries = {}
        for src, dst, src_key, dst_key in rows(f"CALL show_connection('{table}') RETURN *"):
            columns = ", ".join([f"a.{src_key} AS source", f"b.{dst_key} AS target"] + [f"r.{p} AS {p}" for p in props])
            queries[f"{table}_{src}_{dst}"] = f"MATCH (a:{src})-[r:{table}]->(b:{dst}) RETURN {columns}"
    for name, query in queries.items():
        conn.execute(f"COPY ({query}) TO 'export/{name}.json'")
        print(f"export/{name}.json")

Run it in the directory that holds team.kuzu. With Kuzu 0.11.3 it prints the files it wrote:

python export_kuzu.py
export/FILED_IN_Issue_Project.json
export/KNOWS_Person_Person.json
export/MAINTAINS_Person_Project.json
export/Project.json
export/Person.json
export/WORKS_ON_Person_Project.json
export/Issue.json

Write the export files

These are the files the script wrote for the example, byte for byte, so the remaining steps run without Kuzu. With your own export, skip this step. This step writes the directory ./export:

mkdir export
cat > export/Person.json <<'JSON'
[
{"name":"Ada","role":"engineer"},
{"name":"Grace","role":"engineer"},
{"name":"Noor","role":"maintainer"}
]
JSON
cat > export/Project.json <<'JSON'
[
{"name":"relate","language":"Rust"},
{"name":"opencypher-tck","language":"Gherkin"}
]
JSON
cat > export/Issue.json <<'JSON'
[
{"id":0,"title":"Plan cache misses on quoted labels","opened":"2026-10-02","labels":["bug","planner"]},
{"id":1,"title":"Scenario for OPTIONAL MATCH ordering","opened":"2026-09-14","labels":[]},
{"id":2,"title":"Add a Kuzu migration guide","opened":"2026-10-20","labels":["docs"]}
]
JSON
cat > export/WORKS_ON_Person_Project.json <<'JSON'
[
{"source":"Ada","target":"relate","since":2024},
{"source":"Grace","target":"opencypher-tck","since":2022},
{"source":"Grace","target":"relate","since":2023}
]
JSON
cat > export/MAINTAINS_Person_Project.json <<'JSON'
[
{"source":"Noor","target":"relate"}
]
JSON
cat > export/KNOWS_Person_Person.json <<'JSON'
[
{"source":"Ada","target":"Grace"}
]
JSON
cat > export/FILED_IN_Issue_Project.json <<'JSON'
[
{"source":0,"target":"relate"},
{"source":1,"target":"opencypher-tck"},
{"source":2,"target":"relate"}
]
JSON

Declare the schema as a graph type

A graph type holds what Kuzu's CREATE NODE TABLE and CREATE REL TABLE statements hold: each label's properties and their types, the primary keys as IS KEY, and each relationship type's endpoints. Setting it creates the constraints, so every later write is checked. This step writes the script and runs it on ./team.rl, a single-file store it creates:

cat > schema.cypher <<'CYPHER'
CREATE GRAPH TYPE team {
  (person:Person => {name :: STRING NOT NULL IS KEY, role :: STRING}),
  (project:Project => {name :: STRING NOT NULL IS KEY, language :: STRING}),
  (issue:Issue => {id :: INTEGER NOT NULL IS KEY, title :: STRING, opened :: DATE, labels :: LIST<STRING NOT NULL>}),
  (person)-[:WORKS_ON => {since :: INTEGER}]->(project),
  (person)-[:MAINTAINS =>]->(project),
  (person)-[:KNOWS =>]->(person),
  (issue)-[:FILED_IN =>]->(project)
};
ALTER CURRENT GRAPH TYPE SET GRAPH TYPE team;
CYPHER
relate query --database ./team.rl < schema.cypher

The statements print nothing. A graph type is open where a Kuzu schema is closed: a label, a property or a relationship type it doesn't declare is accepted, not refused (Translate the statements shows both).

Load the files

The load script writes the nodes first, then the relationships, so every MATCH on a key finds a node that already exists. Each file arrives whole as a list, and UNWIND turns it into one row per record. date(row.opened) turns the exported string back into a DATE, which the graph type requires:

cat > load.cypher <<'CYPHER'
UNWIND $person AS row
MERGE (p:Person {name: row.name})
SET p.role = row.role;

UNWIND $project AS row
MERGE (p:Project {name: row.name})
SET p.language = row.language;

UNWIND $issue AS row
MERGE (i:Issue {id: row.id})
SET i.title = row.title, i.opened = date(row.opened), i.labels = row.labels;

UNWIND $works_on AS row
MATCH (a:Person {name: row.source}), (b:Project {name: row.target})
MERGE (a)-[r:WORKS_ON]->(b)
SET r.since = row.since;

UNWIND $maintains AS row
MATCH (a:Person {name: row.source}), (b:Project {name: row.target})
MERGE (a)-[:MAINTAINS]->(b);

UNWIND $knows AS row
MATCH (a:Person {name: row.source}), (b:Person {name: row.target})
MERGE (a)-[:KNOWS]->(b);

UNWIND $filed_in AS row
MATCH (a:Issue {id: row.source}), (b:Project {name: row.target})
MERGE (a)-[:FILED_IN]->(b);
CYPHER

This command writes to ./team.rl. Each --fetch-params <name>=<file> binds one export file as a parameter A named value a statement refers to as $name and the caller binds separately, such as with --param name=value on the CLI. Read more in the glossary. MERGE on each key makes a second run match instead of create:

relate query --database ./team.rl \
  --fetch-params person=export/Person.json \
  --fetch-params project=export/Project.json \
  --fetch-params issue=export/Issue.json \
  --fetch-params works_on=export/WORKS_ON_Person_Project.json \
  --fetch-params maintains=export/MAINTAINS_Person_Project.json \
  --fetch-params knows=export/KNOWS_Person_Person.json \
  --fetch-params filed_in=export/FILED_IN_Issue_Project.json \
  < load.cypher

The load prints nothing: no statement returns rows, and the exit code is 0. The issues arrived with their types intact. The store opens read-only:

relate --database ./team.rl --read-only --mode table "MATCH (i:Issue)-[:FILED_IN]->(p:Project)
RETURN i.id AS id, i.opened AS opened, valueType(i.opened) AS type, i.labels AS labels, p.name AS project
ORDER BY id"
+----+------------+-----------------+--------------------+------------------+
| id | opened     | type            | labels             | project          |
+----+------------+-----------------+--------------------+------------------+
| 0  | 2026-10-02 | "DATE NOT NULL" | ["bug", "planner"] | "relate"         |
| 1  | 2026-09-14 | "DATE NOT NULL" | []                 | "opencypher-tck" |
| 2  | 2026-10-20 | "DATE NOT NULL" | ["docs"]           | "relate"         |
+----+------------+-----------------+--------------------+------------------+

Translate the statements

Most of Kuzu's Cypher runs on relate unchanged: MATCH, CREATE, MERGE, WHERE, WITH, UNWIND, aggregation and COUNT { } subqueries. The table lists what differs, with what Kuzu 0.11.3 returned; the cells after it run the relate form. They start from the same graph type and rows, in an in-memory store:

TaskIn KuzuIn relate
Declare the schemaCREATE NODE TABLE, CREATE REL TABLE, required before any writeCREATE GRAPH TYPE, optional
A write to an undeclared label, property or relationship typeRefused: Binder exception: Table Team does not exist., Binder exception: Cannot find property email for a.Accepted, also under a graph type
A second label on a nodeRefused: Binder exception: Table Maintainer does not exist.Accepted
A value of another typeCREATE (:Person {name: 'Zed', role: 7}) stores the string '7'Refused under the graph type's type constraint
A duplicate or missing primary key, a relationship between the wrong tablesRefusedRefused under the graph type
Assign the next idSERIAL, starting at 0max(id) + 1 in the creating statement
A shortest path-[* SHORTEST 1..5]-SHORTEST 1 (…)-[*1..5]-(…)
A node's labellabel(n) and labels(n) both return the string 'Person'labels(n) returns a list, ['Person']; labels(n)[0] is the string
Convert a stringCAST('2024' AS INT64); toInteger() fails with Catalog exception: function TOINTEGER does not exist.toInteger('2024'), toFloat(), toBoolean(), date(), localdatetime()
Read a CSV fileLOAD FROM 'people.csv' (header = true) RETURN *LOAD CSV WITH HEADERS FROM 'file:///people.csv' AS row, from the directory --load-csv-dir allows (import a CSV)

The graph type from Declare the schema as a graph type:

Cypher
CREATE GRAPH TYPE team {
  (person:Person => {name :: STRING NOT NULL IS KEY, role :: STRING}),
  (project:Project => {name :: STRING NOT NULL IS KEY, language :: STRING}),
  (issue:Issue => {id :: INTEGER NOT NULL IS KEY, title :: STRING, opened :: DATE, labels :: LIST<STRING NOT NULL>}),
  (person)-[:WORKS_ON => {since :: INTEGER}]->(project),
  (person)-[:MAINTAINS =>]->(project),
  (person)-[:KNOWS =>]->(person),
  (issue)-[:FILED_IN =>]->(project)
};
ALTER CURRENT GRAPH TYPE SET GRAPH TYPE team
Recorded result
(no rows) · constraints added: 23

The rows the load wrote:

Cypher
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace),
       (:Issue {id: 0, title: 'Plan cache misses on quoted labels', opened: date('2026-10-02'), labels: ['bug', 'planner']})-[:FILED_IN]->(relate),
       (:Issue {id: 1, title: 'Scenario for OPTIONAL MATCH ordering', opened: date('2026-09-14'), labels: []})-[:FILED_IN]->(tck),
       (:Issue {id: 2, title: 'Add a Kuzu migration guide', opened: date('2026-10-20'), labels: ['docs']})-[:FILED_IN]->(relate)
Recorded result
(no rows) · nodes created: 8, relationships created: 8, properties set: 25, labels added: 8

Convert the exported values

The JSON export keeps INT64, DOUBLE, BOOLEAN and lists as JSON values, which arrive as relate's types; DATE and TIMESTAMP arrive as strings and need a conversion. This row has the shape the export writes:

Cypher
RETURN valueType($row.since) AS int64, valueType($row.score) AS double,
       valueType($row.active) AS boolean, valueType($row.labels) AS list,
       date($row.opened) AS date, localdatetime($row.updated) AS timestamp
Parameters
Recorded result
int64doublebooleanlistdatetimestamp
"INTEGER NOT NULL""FLOAT NOT NULL""BOOLEAN NOT NULL""LIST<STRING NOT NULL> NOT NULL""2026-10-02""2026-10-02T09:30:00"

1 row

Assign the next id

A SERIAL column numbers new rows on its own. In relate, the statement that creates the node reads the largest id and adds one, and the node key refuses a duplicate if two writes ever pick the same number:

Cypher
MATCH (i:Issue)
WITH coalesce(max(i.id), -1) + 1 AS next
MATCH (p:Project {name: 'relate'})
CREATE (:Issue {id: next, title: 'Document SERIAL columns', opened: date('2026-11-02'), labels: ['docs']})-[:FILED_IN]->(p)
RETURN next AS id
Recorded result
id
3

1 row · nodes created: 1, relationships created: 1, properties set: 4, labels added: 1

coalesce(…, -1) + 1 starts an empty label at 0, as SERIAL does. A key that need not be a number can come from randomUUID() instead.

Find a shortest path

Kuzu writes the shortest-path keyword inside the relationship pattern; relate writes SHORTEST before the pattern, as GQL does. For this graph, Kuzu's MATCH p = (a:Person {name: 'Ada'})-[* SHORTEST 1..5]-(b:Project {name: 'opencypher-tck'}) RETURN length(p) AS hops returned 2:

Cypher
MATCH path = SHORTEST 1 (:Person {name: 'Ada'})-[*1..5]-(:Project {name: 'opencypher-tck'})
RETURN [n IN nodes(path) | n.name] AS route, length(path) AS hops
Recorded result
routehops
["Ada", "Grace", "opencypher-tck"]2

1 row

Read a node's label and convert strings

labels(n) returns a list, because a node can carry several labels. toInteger() and the other conversion functions take the place of CAST:

Cypher
MATCH (p:Person {name: 'Grace'})
RETURN labels(p) AS labels, labels(p)[0] AS label, toInteger('2024') AS year, toFloat('1.5') AS score
Recorded result
labelslabelyearscore
["Person"]"Person"20241.5

1 row

Writes the graph type refuses

The three writes Kuzu refuses for its primary key and its relationship table, the graph type refuses too. A second person named Ada:

Cypher
CREATE (:Person {name: 'Ada', role: 'designer'})
Expected error
error: semantic error: ConstraintValidationFailed: `constraint_bda177ce` uniqueness

A person without a name:

Cypher
CREATE (:Person {role: 'designer'})
Expected error
error: semantic error: ConstraintValidationFailed: `constraint_238af3a3` requires properties

A WORKS_ON from a project to a person:

Cypher
MATCH (p:Project {name: 'relate'}), (a:Person {name: 'Noor'})
CREATE (p)-[:WORKS_ON {since: 2020}]->(a)
Expected error
error: ConstraintValidationFailed: endpoint label presence verification failed: expected Person

A number where the graph type declares a string, which Kuzu stored as the string '7':

Cypher
CREATE (:Person {name: 'Zed', role: 7})
Expected error
error: semantic error: ConstraintValidationFailed: `constraint_819770c5` property type

Writes the graph type accepts

A second label, an undeclared property, an undeclared relationship type and an undeclared label, each of which Kuzu refuses, all succeed in one statement:

Cypher
MATCH (ada:Person {name: 'Ada'})
SET ada:Maintainer, ada.email = '[email protected]'
CREATE (ada)-[:MEMBER_OF]->(team:Team {name: 'core'})
RETURN labels(ada) AS labels, ada.email AS email, labels(team) AS team
Recorded result
labelsemailteam
["Person", "Maintainer"]"[email protected]"["Team"]

1 row · nodes created: 1, relationships created: 1, properties set: 2, labels added: 2

To refuse these too, declare them: a property in the graph type with NOT NULL is required, and a constraint can require any property (indexes and constraints).

Verify

Count the records in each export file and the nodes and relationships in the store, then compare. The store opens read-only, and the files are bound as parameters again:

relate --database ./team.rl --read-only --mode table \
  --fetch-params person=export/Person.json \
  --fetch-params project=export/Project.json \
  --fetch-params issue=export/Issue.json \
  --fetch-params works_on=export/WORKS_ON_Person_Project.json \
  --fetch-params maintains=export/MAINTAINS_Person_Project.json \
  --fetch-params knows=export/KNOWS_Person_Person.json \
  --fetch-params filed_in=export/FILED_IN_Issue_Project.json \
  'UNWIND [["Person", size($person)], ["Project", size($project)], ["Issue", size($issue)],
         ["WORKS_ON", size($works_on)], ["MAINTAINS", size($maintains)],
         ["KNOWS", size($knows)], ["FILED_IN", size($filed_in)]] AS t
WITH t[0] AS name, t[1] AS in_export
RETURN name, in_export,
       COUNT { MATCH (n) WHERE name IN labels(n) } + COUNT { MATCH ()-[r]->() WHERE type(r) = name } AS in_relate
ORDER BY name'
+-------------+-----------+-----------+
| name        | in_export | in_relate |
+-------------+-----------+-----------+
| "FILED_IN"  | 3         | 3         |
| "Issue"     | 3         | 3         |
| "KNOWS"     | 1         | 1         |
| "MAINTAINS" | 1         | 1         |
| "Person"    | 3         | 3         |
| "Project"   | 2         | 2         |
| "WORKS_ON"  | 3         | 3         |
+-------------+-----------+-----------+

Equal columns mean every record arrived and every relationship found both of its endpoints. A relationship count lower than its file's is a source or target whose MATCH found no node, which drops the row without an error. Then run each statement the application sends with EXPLAIN in front: it plans without running, and a Kuzu-only form fails at planning.

If it goes wrong

The Kuzu forms with no relate counterpart fail before they run. Kuzu's schema statements:

Cypher
CREATE NODE TABLE Person(name STRING PRIMARY KEY, role STRING)
Expected error
error: syntax error: parse error: expected a pattern
  CREATE NODE TABLE Person(name STRING PRIMARY KEY, role STRING)
         ^

Kuzu's shortest-path form:

Cypher
MATCH p = (a:Person {name: 'Ada'})-[* SHORTEST 1..5]-(b:Project {name: 'opencypher-tck'})
RETURN length(p) AS hops
Expected error
error: syntax error: InvalidRelationshipPattern: parse error: expected RightBracket, got "SHORTEST" (Identifier)
  MATCH p = (a:Person {name: 'Ada'})-[* SHORTEST 1..5]-(b:Project {name: 'opencypher-tck'})
                                        ^

CAST:

Cypher
RETURN CAST('2024' AS INT64) AS year
Expected error
error: syntax error: parse error: expected RightParen, got "AS" (AS)
  RETURN CAST('2024' AS INT64) AS year
                     ^

label():

Cypher
MATCH (p:Person {name: 'Grace'}) RETURN label(p) AS label
Expected error
error: syntax error: UnknownFunction: unknown function label()
MessageCauseRemedy
syntax error: parse error: expected a patternA CREATE NODE TABLE or CREATE REL TABLE statementDeclare the tables in a graph type, as in Declare the schema as a graph type, or leave them undeclared.
syntax error: InvalidRelationshipPattern: parse error: expected RightBracket, got "SHORTEST" (Identifier)Kuzu's -[* SHORTEST 1..5]-Write SHORTEST 1 before the pattern: MATCH path = SHORTEST 1 (a)-[*1..5]-(b).
syntax error: parse error: expected RightParen, got "AS" (AS)CAST(<value> AS <type>)Use toInteger(), toFloat(), toBoolean(), toString(), date() or localdatetime().
syntax error: UnknownFunction: unknown function label()Kuzu's label(n)Use labels(n)[0], or test a label with n:Person.
semantic error: ConstraintValidationFailed: `<name>` property typeA DATE or TIMESTAMP written as the exported stringWrap it in date() or localdatetime().
ConstraintValidationFailed: endpoint label presence verification failed: expected PersonA relationship whose endpoints the graph type doesn't declare for its typeCheck the direction of the MATCH and MERGE against the relationship table's FROM and TO.
Error: --fetch-params <name>: invalid JSON: EOF while parsing a value at line 1 column 0An export file is emptyCheck that the export script finished for that table, and run it again.