Docs
View as markdown

Migrate from Neo4j

Decide whether a Neo4j workload fits relate, copy the graph with relate pull, check its statements against the dialect, and rehearse the cut-over on a copy.

clicypherbolt

Before you start: Install relate

This guide shows you how to move a graph and the application that queries it from Neo4j to relate. relate runs Neo4j's Cypher The declarative graph query language relate runs, written as ASCII-art patterns such as (a)-[:KNOWS]->(b); relate follows Neo4j's Cypher 25 by default. Read more in the glossary inside one process, on one machine, so most statements carry over unchanged. What doesn't carry over is the server around them: procedure libraries, several databases, user accounts, and clustering. The first section lists each of those with what relate offers instead, so you can decide before you copy anything.

Prerequisites

  • relate on your PATH (Install relate).
  • A Neo4j or Aura database that answers the HTTP Query API, for the copy. Pull a subgraph from Neo4j has the connection file and every refusal.
  • The application's Cypher statements, collected in one place, to check against the dialect.

Check what you'd miss

Each row is something a Neo4j deployment commonly relies on. The relate column says what happens when a statement or a deployment reaches for it, quoting the error where there is one:

Neo4j featureIn relateWhat to use instead
The APOC libraryNone. syntax error: UnknownFunction: unknown function apoc.text.join(); syntax error: ProcedureNotFound: apoc.meta.schemaBuilt-in functions such as string.join, string.regexReplace, coll.sort, coll.distinct and coll.flatten; CALL db.schema.nodeTypeProperties() for the schema; CALL { … } IN TRANSACTIONS for batched writes; the CLI's --fetch-params to load a JSON file or URL. SHOW FUNCTIONS and SHOW PROCEDURES list what exists
The Graph Data Science (GDS) libraryNone. syntax error: ProcedureNotFound: gds.pageRank.streamCypher patterns and aggregation: degree with a COUNT { } subquery, paths with SHORTEST. Other algorithms run in the application's own code over a query result
Several databases per server (CREATE DATABASE, USE <name>)Three fixed namespaces per store, neo4j, system and system_ontologies. semantic error: UnsupportedAdministrationCommand: Unsupported administration command: CREATE DATABASE moviesOne store per graph: a directory or a .rl file each
Users, roles, and privilegesNone. CREATE USER bob SET PASSWORD 'secret' fails with syntax error: parse error: expected a patternFile permissions on the store; --read-only opens, which refuse every write; on a served store, one Basic-auth credential for every surface, from relate serve --auth <user>:<password>
Clustering: replicas, failover, shardingNone. One process writes a store, and nothing replicates it to another machineOn the same machine, readers beside the writer with --read-only on a directory store, or --read-only=live to follow its commits; relate serve for many clients of one process; relate clone for a copy
Several processes writing one databaseOne writing process per store. A second fails at open with storage locked: <path>, exit code 5Route every writer through one relate serve process (two processes, one store)
Graphs larger than memoryThe whole graph and its indexes are held in memory, with no pagingMeasure first (size a store and its memory); --logical-graph-size-limit fails a growing write instead of exhausting memory
Encrypted connectionsrelate serve has no TLS. Every surface binds to 127.0.0.1 unless --host names another addressA TLS-terminating proxy in front of relate serve (auth and TLS)
LOAD CSV from a URLLocal files under the --load-csv-dir directory only, denied by default; anything else fails with LOAD CSV file unavailableDownload the file, then --load-csv-dir <dir> (import a CSV)

If a row is a requirement, relate is the wrong home for that workload, and where relate fits says what to use instead. The limitations page lists every limit, and differences from Neo4j every statement-level habit.

Copy the data

relate pull runs your statements against Neo4j over its HTTP Query API and writes the nodes and relationships they return into a new local store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary, with the source's constraints and its range, text, and point indexes. This command writes to ./mirror, which must not exist yet or be empty:

relate pull --from neo4j.env --query "MATCH (n)-[r]->(m) RETURN n, r, m" --database ./mirror

That statement copies every node with at least one relationship; add MATCH (n) WHERE NOT EXISTS { (n)--() } RETURN n as a second statement, separated by ;, for nodes without any. Two things don't come across:

  • Full-text and vector indexes. The pull skips them with a warning on stderr; create them again with CREATE FULLTEXT INDEX or CREATE VECTOR INDEX (search).
  • element id The string elementId() returns for a node or relationship, such as 4:relate-neo4j:n0; it stays the same when the store is closed and opened again. Read more in the glossary. Every node and relationship gets a new one, so an application that stored Neo4j's element ids needs its own key property instead.

Pull a subgraph from Neo4j covers the connection file, the value types, and every error.

Check the application's statements

Run each statement the application sends with EXPLAIN in front, against the copy. EXPLAIN plans without running, and an unknown function or procedure fails at planning. The pull and the driver connection are described here, not run in the docs build; the cells run on one in-memory store holding the team graph, standing in for the copy:

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)
Recorded result
(no rows) · nodes created: 5, relationships created: 5, properties set: 13, labels added: 5

A GDS call fails before anything runs:

Cypher
EXPLAIN CALL gds.degree.stream('team') YIELD nodeId, score
RETURN nodeId, score
Expected error
error: syntax error: ProcedureNotFound: gds.degree.stream

Degree, the simplest centrality, is a COUNT subquery:

Cypher
MATCH (p:Person)
RETURN p.name AS person, COUNT { (p)--() } AS degree
ORDER BY degree DESC, person
Recorded result
persondegree
"Grace"3
"Ada"2
"Noor"1

3 rows

A shortest path is a pattern:

Cypher
MATCH path = SHORTEST 1 (:Person {name: 'Ada'})-[*]-(: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

APOC's list and text helpers have built-in counterparts. This statement does with string.join, coll.sort and coll.distinct what apoc.text.join, apoc.coll.sort and apoc.coll.toSet would:

Cypher
MATCH (p:Person)-[:WORKS_ON]->(proj:Project)
WITH proj, collect(p.name) AS names, collect(p.role) AS roles
RETURN proj.name AS project, string.join(coll.sort(names), ', ') AS people, coll.distinct(roles) AS roles
ORDER BY project
Recorded result
projectpeopleroles
"opencypher-tck""Grace"["engineer"]
"relate""Ada, Grace"["engineer"]

2 rows

A statement that plans cleanly can still return different results where the dialect differs; differences from Neo4j lists each case with the form to write instead.

Connect the application

Two routes, depending on how much of the application should change:

RouteWhat changesStart here
Embed relateThe Neo4j driver gives way to the relate client for the language, which opens the store in the application's process. No server, no portPython, Node.js, or the other clients
Keep the Neo4j driverNothing in the code but the URI. relate serve --bolt <port> speaks Bolt, and a neo4j:// driver gets a single-server routing tableServe a store over Bolt

The served form, with the copy opened read-only:

relate serve --database ./mirror --bolt 7687 --read-only

To allow writes, remove --read-only. Without --auth or RELATE_AUTH, relate generates a token, prints it at startup, and keeps it in ./mirror.auth-token for the next start.

Rehearse the cut-over

Run any change the move needs, such as replacing APOC calls in stored data or renaming a relationship type, on a clone of the copy, so the pull stays as it came. This writes the new store ./rehearsal:

relate clone ./mirror ./rehearsal

The migration rehearsal recipe is the pattern for checking the result: fingerprint the graph before the change, write down what the change may touch, and compare afterwards. It catches a migration that keeps every node count right while it deletes 352 relationships.

Verify

The same count statements run on Neo4j and on relate, so the copy is checked against the source with no translation. Nodes per label:

Cypher
MATCH (n)
RETURN labels(n) AS labels, count(*) AS nodes
ORDER BY nodes DESC
Recorded result
labelsnodes
["Person"]3
["Project"]2

2 rows

Relationships per type:

Cypher
MATCH ()-[r]->()
RETURN type(r) AS type, count(*) AS relationships
ORDER BY relationships DESC, type
Recorded result
typerelationships
"WORKS_ON"3
"KNOWS"1
"MAINTAINS"1

3 rows

On the copy itself, open it read-only: relate --database ./mirror --read-only --mode table "<statement>". Equal counts on both sides, and every application statement planning cleanly with EXPLAIN, mean the data and the statements have arrived.

If it goes wrong

MessageCauseRemedy
syntax error: UnknownFunction: unknown function apoc.<name>() or syntax error: ProcedureNotFound: apoc.<name>The statement calls APOCUse the built-in from Check what you'd miss; SHOW FUNCTIONS lists them.
syntax error: ProcedureNotFound: gds.<name>The statement calls GDSWrite the measure in Cypher, or compute it in the application from a query result.
Error: destination database is not empty; pull writes into an empty database only (ADR-0158)relate pull targets a store that holds dataPull into a new path.
semantic error: DatabaseNotFound: Graph not found: <name>The statement or driver session names a database other than the three namespacesDrop the USE clause or the session's database name; put each graph in its own store.
Failed to open Relate: storage locked: <path>, exit code 5A second process opened the store for writingSend every writer through one relate serve process, or open the reader with --read-only on a directory store.
A search returns nothing on the copyThe pull skipped the source's full-text and vector indexesCreate them with CREATE FULLTEXT INDEX or CREATE VECTOR INDEX.