Docs
View as markdown

Script the CLI

Run relate from shell scripts with statements from files and stdin, machine-readable output, parameters, timeouts, transactions and exit codes.

cli

Before you start: Your first graph in the CLI

This guide shows you how to run relate from shell scripts: statements from a file or stdin, output a program can parse, parameters, a time bound per statement, all-or-nothing scripts, and exit codes to branch on. Each command opens the store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary, runs, and exits, so a script is a sequence of ordinary commands.

Prerequisites

  • relate on your PATH (Install relate).
  • bash, or another POSIX shell.
  • The examples build team.rl, the people-and-projects store from Your first graph in the CLI, in a new, empty directory. The first step creates it there.

Pin the output mode

Pass --mode on every scripted command. Without it, relate picks the output from where stdout goes, and a script can run in all three places:

Where stdout goesDefault output
A terminaltable
A pipe or a filejsonl, one JSON object per row, then one timing line per statement
A pipe or a file, with agent mode on (a detected agent harness, RELATE_AGENT_MODE=1 or --agent-mode)toon

For a program, use --mode envelope: one JSON object per result set, the envelope One compact JSON object per result set, printed by --mode envelope, that keeps the column list even when there are no rows. Read more in the glossary, with columns, columnTypes, rows and truncated, plus the statement's timings and planCache (timings and the plan cache). An empty result still lists its columns. The output modes table lists the others.

Run statements from a file or stdin

Make a new directory for the examples and move into it, so the first step starts from an empty store:

mkdir relate-scripting && cd relate-scripting

Write the statements to a file, each ending with ;:

cat > seed.cypher <<'EOF'
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);
MATCH (n) RETURN count(n) AS nodes;
EOF

Then redirect the file into relate query. With no statement argument, query reads a script from stdin:

relate query --database ./team.rl --mode envelope < seed.cypher
{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":1.884792,"execute_ms":1.53525,"lex_parse_ms":0.060958,"lower_ms":0.022833,"parse_ms":0.328208,"plan_ms":0.014167,"result_generation_ms":0.000125,"rewrite_ms":0.000458},"truncated":false}
{"columnTypes":["INTEGER"],"columns":["nodes"],"notifications":[],"planCache":"miss","rows":[{"nodes":5}],"timings":{"elapsed_ms":0.08841700000000001,"execute_ms":0.012792000000000001,"lex_parse_ms":0.013417,"lower_ms":0.020583,"parse_ms":0.044417,"plan_ms":0.026875,"result_generation_ms":0.000167,"rewrite_ms":0.001708},"truncated":false}

Each statement prints its own envelope, so the output is JSON Lines. A pipe works the same way as a file:

printf 'MATCH (p:Person) RETURN count(p) AS people;\nMATCH (p:Project) RETURN count(p) AS projects;\n' | relate query --database ./team.rl --read-only --mode envelope
{"columnTypes":["INTEGER"],"columns":["people"],"notifications":[],"planCache":"miss","rows":[{"people":3}],"timings":{"elapsed_ms":0.35266699999999995,"execute_ms":0.17525000000000002,"lex_parse_ms":0.031916,"lower_ms":0.048583,"parse_ms":0.11541699999999999,"plan_ms":0.053625,"result_generation_ms":0.000375,"rewrite_ms":0.009458},"truncated":false}
{"columnTypes":["INTEGER"],"columns":["projects"],"notifications":[],"planCache":"miss","rows":[{"projects":2}],"timings":{"elapsed_ms":0.040834,"execute_ms":0.0029579999999999997,"lex_parse_ms":0.006625,"lower_ms":0.010458,"parse_ms":0.024208,"plan_ms":0.011292,"result_generation_ms":0.000083,"rewrite_ms":0.00075},"truncated":false}

For a short list, -c takes the statements as one argument, separated by ;.

Keep the word query in a command that reads stdin. Without it, a redirected script is not read at all: relate prints its help and exits 0, so the script looks like it succeeded.

# Avoid: prints the help text and exits 0; seed.cypher never runs
relate --database ./team.rl < seed.cypher
# Instead:
relate query --database ./team.rl --mode envelope < seed.cypher

Pass parameters

--param name=value binds $name, and repeats for each parameter. A value that parses as JSON keeps its JSON type. A value that starts like JSON (a digit, -, ", [ or {) but doesn't parse exits 2. Any other bare word is a string:

relate --mode envelope --param name=Ines --param since=2026 --param 'tags=["graph","cli"]' 'RETURN $name AS name, $since AS since, $tags AS tags'
{"columnTypes":["STRING","INTEGER","LIST"],"columns":["name","since","tags"],"notifications":[],"planCache":"miss","rows":[{"name":"Ines","since":2026,"tags":["graph","cli"]}],"timings":{"elapsed_ms":0.34808300000000003,"execute_ms":0.194666,"lex_parse_ms":0.021084,"lower_ms":0.043250000000000004,"parse_ms":0.107333,"plan_ms":0.037875,"result_generation_ms":0.00020800000000000001,"rewrite_ms":0.01025},"truncated":false}

Single quotes keep the shell from expanding $name in the statement. To pass a string that starts like a number, such as a date or a version, JSON-quote it: --param 'since="2026-11-07"' binds the string 2026-11-07, where a bare since=2026-11-07 exits 2. A string that reads as a boolean or null needs the same quoting.

For data in a JSON file, --fetch-params name=<path> reads the file, parses it, and binds the result. It also accepts an http(s) URL. This file holds two people, and the statement merges them into the store:

printf '[{"name": "Ines", "role": "engineer"}, {"name": "Kofi", "role": "maintainer"}]\n' > people.json
relate --database ./team.rl --mode envelope --fetch-params people=people.json 'UNWIND $people AS p MERGE (n:Person {name: p.name}) SET n.role = p.role RETURN count(n) AS merged'
{"columnTypes":["INTEGER"],"columns":["merged"],"notifications":[],"planCache":"miss","rows":[{"merged":2}],"timings":{"elapsed_ms":2.125458,"execute_ms":1.924125,"lex_parse_ms":0.005458,"lower_ms":0.008334,"parse_ms":0.18954200000000002,"plan_ms":0.005,"result_generation_ms":0.00020800000000000001,"rewrite_ms":0.000375},"truncated":false}

Bound each statement with a timeout

--timeout cancels any statement that runs longer than the duration: 500ms, 10s, 2m, or a bare number of seconds. The bound applies to each statement of a script on its own, and a timed-out statement exits 7:

relate --mode envelope --timeout 100ms "UNWIND range(1, 20000) AS a UNWIND range(1, 20000) AS b RETURN count(*) AS pairs"
echo "exit=$?"
Error: query timed out after 100ms
{"error":{"code":"timeout","exit_code":7,"kind":"execution","message":"query timed out after 100ms","retryable":false}}
exit=7

The first line is stderr; the error object is stdout. Without --timeout, a statement has no time bound.

Open the store read-only for reads

A script step that only reads opens the store with --read-only. relate never writes or checkpoints a read-only An open mode (--read-only) that never writes, checkpoints or migrates the store; writes fail, and a directory store can be read beside its writer. Read more in the glossary store, and a write fails before it changes anything:

relate --database ./team.rl --read-only --mode envelope "CREATE (:Person {name: 'Zed'})"
echo "exit=$?"
semantic error: WriteOnReadOnlyAccessDatabase: No write operations are allowed on this database. The database is in read-only mode on this Neo4j instance.
{"error":{"code":"validation_error","exit_code":6,"kind":"semantic","message":"semantic error: WriteOnReadOnlyAccessDatabase: No write operations are allowed on this database. The database is in read-only mode on this Neo4j instance.","retryable":false}}
exit=6

Branch on exit codes

Every failure exits with the exit code The number a relate run returns to the shell; each nonzero value names a class of failure, such as 2 for usage, 5 for conflict, 6 for a rejected statement. Read more in the glossary of its class. When a statement or the store open fails, --mode envelope also prints an error object on stdout whose code names the class. A usage error (exit 2: an unknown flag, or a bad --param or --timeout value) or a --fetch-params failure (exit 1) prints only its stderr line and leaves stdout empty, so branch on the exit code, not on stdout.

The case at the end of this section branches on these codes:

Exit codecodeMeaning
0—Success, including an empty result
5conflictWrite-write conflict, or another process holds the store
6validation_errorThe statement was rejected: syntax, semantic, argument or type error, including a write on a read-only store
7timeoutA statement exceeded --timeout

retryable is true for a write-write conflict (exit 5) and for a --concurrent-logical-workspace-limit refusal while other statements hold the shared pool (exit 1, kind resource; reachable when statements run concurrently, as under relate serve). A one-shot CLI run meets only the first. Running the same statement again can succeed after either. A locked store also exits 5, with "retryable":false until the other process exits. Exit codes and errors lists every code, including 1 and 2, and the error object's fields.

Capture the code with || status=$?, which also keeps set -e from stopping the script, then branch on it:

status=0
relate --database ./team.rl --read-only --mode envelope --timeout 5s "MATCH (p:Person) RETRUN p.name" > result.jsonl 2> error.txt || status=$?
case "$status" in
  0) echo "ok" ;;
  5) echo "conflict" ;;
  6) echo "rejected: $(head -n 1 error.txt)" ;;
  7) echo "timed out" ;;
  *) echo "failed with exit code $status" ;;
esac
rejected: syntax error: parse error: unexpected token "RETRUN"

Apply scripts in a loop

A script stops at its first failing statement and exits with that statement's code. Statements before it are already committed, unless the script opens a transaction with .begin and ends it with .commit: then a failure rolls back everything since .begin. The shell dot commands lists .begin, .commit, .rollback and the others a script can use.

This example applies a directory of migration scripts in order. Each one is a transaction, and the second has a typo:

mkdir -p migrations
cat > migrations/001-ines.cypher <<'EOF'
.begin
MATCH (p:Person {name: 'Ines'}), (proj:Project {name: 'relate'})
MERGE (p)-[:WORKS_ON {since: 2026}]->(proj);
.commit
EOF
cat > migrations/002-teams.cypher <<'EOF'
.begin
MATCH (p:Person) SET p.team = 'core';
MATCH (p:Person) RETRUN count(p) AS people;
.commit
EOF

The loop runs under set -e, so the first migration that fails stops it, and the shell exits with relate's code:

cat > migrate.sh <<'EOF'
set -e
for f in migrations/*.cypher; do
  echo "applying $f"
  relate query --database ./team.rl --mode envelope < "$f"
done
echo "all migrations applied"
EOF
bash migrate.sh
echo "exit=$?"
applying migrations/001-ines.cypher
{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":0.500167,"execute_ms":0.278458,"lex_parse_ms":0.006166,"lower_ms":0.008542000000000001,"parse_ms":0.20925,"plan_ms":0.007458,"result_generation_ms":0.00025,"rewrite_ms":0.000375},"truncated":false}
applying migrations/002-teams.cypher
{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":0.404584,"execute_ms":0.2205,"lex_parse_ms":0.003708,"lower_ms":0.006709,"parse_ms":0.174375,"plan_ms":0.005,"result_generation_ms":0.00025,"rewrite_ms":0.00029099999999999997},"truncated":false}
syntax error: parse error: unexpected token "RETRUN"
  MATCH (p:Person) RETRUN count(p) AS people
                   ^
statement 3 of 4 failed; 2 completed, not committed (open transaction); 1 not run
{"error":{"code":"validation_error","column":18,"committed":0,"exit_code":6,"kind":"syntax","message":"syntax error: parse error: unexpected token \"RETRUN\"   MATCH (p:Person) RETRUN count(p) AS people                    ^","retryable":false,"source":"MATCH (p:Person) RETRUN count(p) AS people","statement":3,"statements":4}}
Warning: exiting with an open transaction; rolling back uncommitted changes. Use .commit before exiting to persist them.
exit=6

The progress line and the error object's statement, statements and committed say how far the script got. .begin counts as statement 1, and "committed":0 means none of the migration's statements are durable: its SET was rolled back. "all migrations applied" never printed.

Verify

Read the store back, read-only, with two statements in one -c argument. Ines works on relate from the first migration, and no one has a team from the second:

relate --database ./team.rl --read-only --mode envelope -c "MATCH (:Person {name: 'Ines'})-[w:WORKS_ON]->(proj:Project) RETURN proj.name AS project, w.since AS since; MATCH (p:Person) WHERE p.team IS NOT NULL RETURN count(p) AS with_team"
{"columnTypes":["STRING","INTEGER"],"columns":["project","since"],"notifications":[],"planCache":"miss","rows":[{"project":"relate","since":2026}],"timings":{"elapsed_ms":0.45795800000000003,"execute_ms":0.267375,"lex_parse_ms":0.032541,"lower_ms":0.045250000000000005,"parse_ms":0.119959,"plan_ms":0.060791,"result_generation_ms":0.00033299999999999996,"rewrite_ms":0.008},"truncated":false}
{"columnTypes":["INTEGER"],"columns":["with_team"],"notifications":[],"planCache":"miss","rows":[{"with_team":0}],"timings":{"elapsed_ms":0.08483399999999999,"execute_ms":0.029584,"lex_parse_ms":0.009290999999999999,"lower_ms":0.015708,"parse_ms":0.036667,"plan_ms":0.015833,"result_generation_ms":0.000084,"rewrite_ms":0.002792},"truncated":false}

If it goes wrong

What you seeCauseRemedy
The relate help text, and exit code 0, instead of resultsA script was redirected into relate without the word queryRun relate query --database <path> --mode envelope < <script>
Error: invalid --param value: parameter <name>: …, exit code 2A --param value that starts like JSON (a digit, -, ", [ or {) doesn't parse; a date such as 2026-11-07 countsQuote the whole argument in single quotes and fix the JSON, or JSON-quote a string: 'since="2026-11-07"'
Failed to open Relate: storage locked: <path>, exit code 5Another process holds the storeWait for it to exit, or read with --read-only (a directory store can be read beside its writer; a .rl file only while no writer holds it)
warning: result truncated to 100 rows (the statement returned <total>); pass --max-rows 0 for all rowsWith agent mode on (a detected agent harness, for example), a piped statement prints at most 100 rows, and the envelope says "truncated":truePass --max-rows 0 to print every row, or bound the result with LIMIT
note: write statement changed nothing; the graph is unchanged on stderrNot an error: a write matched nothing, often a MATCH with no rowsCheck the MATCH; the exit code stays 0
  • CLI flags: every flag in this guide, its default, and its environment variable.
  • Exit codes and errors: the exit code table and the error object.
  • Errors: every message, its cause, and its remedy.
  • Agent mode: the defaults that change under an agent harness.