Skip to Content
We are live but in Staging 🎉
Data EnginesGraphCypher & Traversals

Cypher & Traversals

Preview — the Cypher subset is a whitelist, not a dialect. Four MATCH shapes parse, each of which may project node properties, filter on them, order, limit or count; everything else is a typed rejection naming what is unsupported and what to use instead. The subset grows, so treat the unsupported list below as the contract for today, not a permanent boundary.

Two spellings, one engine. The native graph_* table functions are the primary surface; cypher() is an AGE-compatible compatibility layer whose whitelisted MATCH shapes compile to exactly the same traversal calls — same serve path, same rows. The Bolt wire runs the same subset.

Since tables v0.1.38 the subset also carries property projection (RETURN b.name), WHERE on properties, ORDER BY / LIMIT / count(*), fixed 2–3 hop chains, and typed $name parameters over Bolt — documented in their own sections below.

Native table functions

Each appears as a FROM target — on its own, or joined and decorated inside a larger statement (details below):

SELECT * FROM graph_khop('<graph>', <start>, <depth> [, '<direction>' [, '<freshness>']]); SELECT * FROM graph_neighbors('<graph>', <node> [, '<direction>' [, '<freshness>']]); SELECT * FROM graph_shortest_path('<graph>', <a>, <b> [, '<direction>' [, '<freshness>']]);
  • direction'out' (default) · 'in' · 'both' (undirected view).
  • freshness'eventual' (default) · 'strong' — see Freshness.
  • Arguments are positional literals or bound parameters (since v0.1.27): the start/anchor id may be a prepared-statement parameter — graph_khop('g', $1, 5) over the pg wire — so a prepared statement no longer has to string-inline the id. What is still refused is a correlated argument driven by a column of the enclosing query — see Composing traversals with SQL.
  • A malformed call is a loud invalid_argument, never a silent fall-through.

Output shapes (stable wire contract):

FunctionColumnsTypes
graph_khopnode, hop_distanceBIGINT, INT
graph_neighborsneighborBIGINT
graph_shortest_pathstep, nodeINT, BIGINT

These names are the projection for a bare RETURN <var> — always. cypher() projects the same node / hop_distance columns, never the RETURN variable name: cypher('g', 'MATCH (r)<-[*1..5]-(c) WHERE id(r) = 1 RETURN c') returns node/hop_distance, and SELECT b FROM cypher(…) (or selecting the node table’s own key column) fails with a binder error. Project node, or alias it: SELECT node AS b FROM cypher(…).

A RETURN <var>.<prop> projection replaces the node-key column with the named columns (see Property projection); the traversal’s own hop_distance / step survives after them.

Semantics (deliberately stated, Neo4j/AGE-aligned):

  • graph_khop returns every node within 1..=depth hops — each node once, at its minimum hop distance, start node excluded. Want exactly-depth? Filter on hop_distance. Depth 0 → empty.
  • graph_neighbors with 'out'/'in' returns the adjacency row verbatim — parallel edges appear as repeated rows. 'both' is the deduplicated union.
  • graph_shortest_path is unweighted BFS. Rows are the path in order (step 0 = start). Unreachable target → zero rows, not an error. Ties between equal-length paths break deterministically. a = b → the single row (0, a).
  • Unknown node key → zero rows (SQL semantics: neighbors of a non-existent node is empty, not a 500).
-- verified fixture: 1→2→3→4→5, 2→6→7 SELECT * FROM graph_khop('social', 1, 2); -- node | hop_distance -- 2 | 1 -- 3 | 2 -- 6 | 2 SELECT * FROM graph_shortest_path('social', 1, 7); -- step | node -- the path 1 → 2 → 6 → 7 -- 0 | 1 -- 1 | 2 -- 2 | 6 -- 3 | 7

The cypher() function

SELECT * FROM cypher('<graph>', '<cypher body>' [, '<freshness>']);

The body may be single-quoted or $$…$$ dollar-quoted (the Apache AGE spelling). Keywords are case-insensitive. Exactly three shapes parse — everything else is a typed error naming what’s unsupported.

Shape 1 — variable-length expand → k-hop

SELECT * FROM cypher('social', 'MATCH (a)-[:knows*1..2]->(b) WHERE id(a) = 1 RETURN b'); -- identical rows to graph_khop('social', 1, 2)
  • Variable-length specs: *k, *..k, *1..kall mean “up to k hops” (the ≤-depth k-hop reading). This intentionally differs from Neo4j, where *2 means exactly length 2 — filter on hop_distance for exact-depth.
  • The lower bound must be 1: *2..3 and *0..3 are rejected.
  • An unbounded [*] is rejected here (only legal inside shortestPath).

Shape 2 — single hop → neighbors

SELECT * FROM cypher('social', 'MATCH (a)-[]->(b) WHERE id(a) = 2 RETURN b'); -- identical rows to graph_neighbors('social', 2)

Bare arrows work too: (a)-->(b), (a)<--(b), (a)--(b).

Shape 3 — shortestPath

SELECT * FROM cypher('social', $$ MATCH p = shortestPath((a)-[*]-(b)) WHERE id(a) = 1 AND id(b) = 7 RETURN p$$); -- identical rows to graph_shortest_path('social', 1, 7, 'both')
  • The path must be bound to a variable and that variable returned.
  • The relationship must be the unbounded [*] (bounded [*..k] rejected).
  • Both endpoints must be anchored (AND-joined).

Shape 4 — a fixed 2–3 hop chain

SELECT * FROM cypher('social', $$ MATCH (a)-[:knows]->(b)-[:works_at]->(c) WHERE id(a) = 1 RETURN c$$);

Two or three hops, each one edge, each with its own direction (mix them freely — the artifact carries both orientations). Anchored on the first node, returning the terminal one.

  • The answer is the deduplicated terminal set — one row per distinct terminal node, not one row per path. Path multiplicity needs a path binding the subset does not have.
  • The start node is not excluded: a chain that cycles back to it is a match, which is openCypher’s relationship-uniqueness reading.
  • Four hops is refused, pointing at [*1..k]. A * inside a chain is refused. Returning an intermediate variable (RETURN b) is refused, because it would mean “only those b that also have the remaining hops” — split the pattern instead.
  • Each hop’s frontier is capped; past it the call refuses naming the hop rather than returning a truncated answer.

Direction comes from the arrow

PatternDirection
(a)-[…]->(b) / (a)-->(b)out
(a)<-[…]-(b) / (a)<--(b)in
(a)-[…]-(b) / (a)--(b)both

Anchoring rules

  • Every query needs an anchor: WHERE id(a) = <int> or the equivalent a.id = <int>. An unanchored whole-graph MATCH is rejected.
  • For expands (shapes 1–2) the anchor must be the left node and RETURN must name the right (non-anchored) node. An anchored right node is rejected with a rewrite hint (flip the arrow) — the parser rejects ambiguous mappings rather than guessing.
  • Keys are integer literals (negative allowed). String keys rejected.

Labels: inert for a bare RETURN, load-bearing for a projection

(a:Person)-[:knows]->(b) parses. For a bare RETURN b the label is a no-op filter, because a v1 graph has exactly one node table and one edge table — the label can only name that table. Multi-label graphs arrive with multi-table CREATE GRAPH.

The moment you read a property, the label becomes load-bearing: it names the node table the properties and their declared types are read from. A projection, a property WHERE or a property ORDER BY on an unlabelled node is refused saying exactly that, rather than resolved to the graph’s sole node source — and a label that does not name that table is refused too.

Property projection

RETURN <var>.<prop> [AS <alias>][, …] over one labelled node variable, on any of the four shapes:

SELECT * FROM cypher('social', $$ MATCH (a)-[:knows*1..2]->(b:person) WHERE id(a) = 1 RETURN b.name, b.born AS year$$); -- b.name | year | hop_distance
  • Projected columns come first, in RETURN order (an unaliased item is named var.prop); the node-key column is replaced by them and the traversal’s own hop_distance / step survives after them.
  • Each column keeps the node table’s declared type, so a DATE property is a DATE on the wire — over Bolt it arrives as a PackStream Date, a TIMESTAMP as a DateTime in UTC, a BYTEA as real Bytes. Never inferred by sniffing whether a string looks like a date.
  • The properties are one batched read at the traversal’s own freshness — cypher(…, 'strong') reads them strong too, so a read-your-writes traversal is never joined to a stale property.
  • A node key with no row in the label table is filtered out (that is what (b:person) means); a row that exists with no value projects NULL; an unknown property name is refused at plan time naming the table’s actual columns. Unknown-property and missing-value are never conflated.
  • A shortestPath projects through its path variable (RETURN p.name), because its rowset is one row per node on the path — which is why both endpoints must carry the same label.

Filtering, ordering, counting

Any shape may add AND-joined property comparisons and a trailing ordering, cap or count:

SELECT * FROM cypher('social', $$ MATCH (a)-[:knows]->(b:person) WHERE id(a) = 1 AND b.age >= 18 AND b.born < date('2000-01-01') RETURN b.name, b.born AS year ORDER BY b.born DESC LIMIT 10$$);
ClauseAccepted
WHERE <var>.<prop> <op> <value>=, <> / !=, <, <=, >, >= — AND-joined with the mandatory id anchor, on the one labelled variable the RETURN reads
valueinteger · fractional number · 'string' · TRUE/FALSE · date('YYYY-MM-DD') · datetime('<ISO-8601>')
ORDER BY <var>.<prop> [ASC|DESC] [, …]typed and stable — ties keep traversal order
LIMIT <n>non-negative integer, applied after the ordering
RETURN count(*)alone in the RETURN list; one long column counting what survives the whole pipeline

The filter runs after the traversal, over the same batched label-table read the projection performs — one read serves projection, filtering and ordering alike. LIMIT and count(*) touch no property, so neither needs a label and neither reads the table at all.

Every value binds to the column’s declared type exactly, or is refused at plan time. An integer is range-checked against the declared width (32768 against a SMALLINT refuses, never wraps); a DECIMAL(p,s) compares as decimal digits and refuses a fractional value rather than rounding it through a float; a DATE/TIMESTAMP takes its constructor and refuses a bare string, naming the constructor; strings compare binary-collated — byte order over UTF-8, the same order the SQL door’s default collation gives, so the two doors agree. JSON, BYTEA and vectors are not comparable here and say which they are. A comparison against a NULL cell is not a match for any operator, <> included (SQL three-valued logic, which is also openCypher’s). An impossible comparison is an error, never an empty result.

Stated difference from openCypher: NULLs sort LAST in BOTH directions. openCypher puts them last on ASC and — treating null as the largest value — first on DESC. The subset states the simpler rule rather than inheriting the split one. Like *k*1..k above, this is documented rather than mis-mapped silently.

Parameters ($name)

Parameters are a typed channel, not string interpolation: over Bolt a driver’s parameter dictionary is decoded into typed values and the query text crosses untouched.

session.run( "MATCH (a)-[:knows]->(b:person) WHERE id(a) = $start AND b.born >= $min " "RETURN b.name ORDER BY b.name LIMIT $n", start=1, min=date(2000, 1, 1), n=10, )
  • An Integer stays an integer (exact past 2^53, where a double would round); a Float stays fractional (2.0 never collapses to 2); a driver Date is a date and a DateTime an instant in UTC.
  • An id anchor and a LIMIT require an integer binding. A property comparison binds to the column’s declared type through the same exact-or-refuse rules a literal goes through.
  • A refusal names the parameter“parameter $min (32768) is outside age’s declared SMALLINT range”, born is declared date and parameter $d carries the string 2000-01-01 — declare it DATE” — rather than a rendered literal. So are an unbound $name and a binding the query never mentions.
  • Kinds with no scalar form — Null, Bytes, List, Dictionary, LocalDateTime, Time, Duration, Point — are refused by name at the door, before the engine is called.
  • SQL’s cypher() has no parameter channel of its own (a table function takes values, not bindings), so a $name there is refused naming it: write the value inline, or run the query over Bolt. A bound SQL parameter as a graph_* anchor — graph_khop('g', $1, 5) — is a different mechanism and works.

Freshness

The optional trailing argument on every traversal (native or cypher()):

  • 'eventual' (default) — serves the pinned artifact as-is. May lag writes that haven’t drained + rebuilt yet. Fastest.
  • 'strong' — read-your-writes: the traversal merges the edge table’s not-yet-compacted writes (inserts and deletes, including brand-new node keys) into the pinned artifact at query time.
INSERT INTO knows VALUES (1000, 5, 500); -- not drained yet SELECT * FROM graph_khop('social', 5, 1, 'out', 'eventual'); -- 500 absent SELECT * FROM graph_khop('social', 5, 1, 'out', 'strong'); -- 500 present

'oltp' is rejected for graph queries (a graph read is never overlay-only), as is any other value. Analytics take no freshness argument at all.

This is a function argument, not the Freshness request field. The freshness field on the Tables request protos is deprecated and ignored by the runtime — a frontier check decides freshness for ordinary queries. The trailing 'strong' argument on a graph_* / cypher() call is a different, live mechanism: it selects the overlay-merged serve path for that one traversal. No graph request message carries a freshness field.

See Graph DDL → How table edits reflect.

Not supported (and what to do instead)

The parser rejects everything below by name — a typed error prefixed cypher subset:, never a silent wrong answer:

RejectedInstead
Write Cypher — CREATE, MERGE, SET, DELETE, DETACH, REMOVE, FOREACHSQL INSERT/UPDATE/DELETE on the node/edge tables (transactions included)
WITH, UNWIND, CALL, UNION, OPTIONAL MATCHcompose in the enclosing SQL / application
SKIP, DISTINCT, count(n) / count(n.prop)count(*) counts rows; SKIP and DISTINCT belong in the enclosing SQL
OR, NOT, IS NULL, STARTS WITH, IN in WHEREAND-joined comparisons only — run the branches separately, or UNION them in SQL
Functions, expressions or arithmetic anywhere (in RETURN, WHERE or ORDER BY)compute in the enclosing SQL
Properties of two variables at once, a bare variable mixed with properties, duplicate output columns, an alias colliding with hop_distance/stepproject one variable’s properties; join the other in SQL
A property read on an unlabelled nodeadd the label — it names the node table the properties live in
Chains longer than 3 hops, a * inside a chain, returning an intermediate chain variableuse [*1..k], or split the pattern
Multiple MATCH patterns or clausesone pattern per call
Inline property maps {id: 1}anchor with WHERE id(a) = <literal> or $param
allShortestPaths, bounded shortestPath([*..k])shortestPath((a)-[*]-(b)) — one deterministic path
Anonymous nodes (), both-headed arrows <-…->, *0../*2.. bounds, missing upper bound *1.., unbounded [*] outside shortestPathname both nodes; use *1..k
OR in WHERE; range comparisons on id (WHERE id(a) > 1)only AND-joined = anchors; filter ranges in SQL
Reusing one variable for both pattern ends ((a)-->(a))name the ends distinctly
RETURN r where r is the relationship variablereturn the target node; join the edge table in SQL for edge rows
String node keys (WHERE id(a) = 'bob')v1 graphs are integer-keyed; look the key up in SQL first

Composing traversals with SQL

A graph_* / cypher() call composes inside a larger statement. Two plans:

  • BareSELECT * FROM graph_khop('social', 1, 2) with no decoration. Served directly off the CSR, straight back to the client.
  • Composed — anything else (a projection other than SELECT *, WHERE, DISTINCT, GROUP BY, HAVING, ORDER BY, LIMIT/OFFSET, a WITH CTE, a JOIN, a subquery, or a set-op branch such as UNION — since v0.1.27 a graph call composes anywhere a relation can appear, not only the top-level FROM). Each graph factor is served plane-side, spliced into the statement as an inline (VALUES …) relation under its alias, and the rewritten statement continues down the normal relational path.

So the canonical traverse → aggregate rollup is one statement — traverse in a subquery, aggregate outside it:

-- e.g. a CRM rollup: total deal value across an account's 5-hop network SELECT SUM(amount) FROM deals WHERE account_id IN (SELECT node FROM graph_khop('accts', 1, 5));

And so is the join that rehydrates properties:

-- traversal + properties in one go SELECT p.id, p.name, k.hop_distance FROM graph_khop('social', 1, 2) k JOIN person p ON p.id = k.node ORDER BY k.hop_distance, p.name; -- aggregate directly SELECT count(*) FROM graph_neighbors('social', 2); -- exactly-depth-2, filtered after the fact SELECT * FROM graph_khop('social', 1, 2) WHERE hop_distance = 2; -- two traversals joined against each other SELECT * FROM graph_neighbors('social', 1) a JOIN graph_neighbors('social', 2) b ON a.neighbor = b.neighbor; -- set-op branches work too SELECT node FROM graph_khop('social', 1, 2) UNION SELECT node FROM graph_khop('social', 9, 2);

Two limits that stay honest:

  • No correlated arguments. A graph call parameterised by a column of the enclosing query, e.g. FROM users u JOIN graph_neighbors('social', u.id) ON true, is a loud invalid_argument, never a silent fall-through — this is the one roadmap limit. (A bound parameter as the anchor — graph_khop('social', $1, 5) in a prepared statement — is fine; it is a per-statement value, not a per-row one.) Batched per-row expansion is a documented follow-up.
  • 10,000 rows per composed call. A graph factor that has to be inlined as VALUES is capped; past it you are told to run the bare call instead. (The bare plan has no such cap — only the 1,000,000-row result ceiling.)

GraphQL does the property join for you in one request too (nested edge fields rehydrate node properties) — see Converging engines.

Over the Bolt wire

The same subset serves Neo4j drivers and cypher-shell at bolt+s://bolt.uk-lon-1.dodil.io:7687.

Use the bolt+s:// scheme. The door terminates TLS on connect with a public Let’s Encrypt certificate, so the official drivers verify it against their system roots with nothing to configure.

The neo4j+s:// and neo4j:// schemes still fail, and TLS is not the reason: both ask for the routing protocol, and the adapter answers the driver’s ROUTE message with “routing is not supported”. Use a single-instance bolt scheme.

Plain bolt:// connects but is unencrypted — and your Bolt password is your API-key or service-account secret, so keep it to local development. bolt+ssc:// (TLS without certificate verification) is likewise only for self-signed/local setups.

cypher-shell \ -a bolt+s://bolt.uk-lon-1.dodil.io:7687 \ -u "$DK_KEY_ID" -p "$DK_SECRET" \ -d kb-prod \ "MATCH (a)-[:knows*1..3]->(b) WHERE id(a) = 1 RETURN b"

To exercise the actual Bolt protocol, use a driver or cypher-shell — the first two tabs. dodil data bolt is a convenience over gRPC.

  • Protocol versions: Bolt 5.0–5.4 (5.4 preferred), falling back to 4.4. 4.3 and below are refused at the handshake. On 5.1+ the driver’s HELLO/LOGON split is handled.
  • Auth: API-key pair as user/password, or a service account, or a bearer JWT (Connect). The declared auth scheme is ignored — the credential is dispatched by shape. Bad credentials give Neo.ClientError.Security.Unauthorized.
  • A database is required on every session. A db-less RUN/BEGIN is rejected at the gateway with Neo.ClientError.Security.Forbidden — there is no accessible server default. Send the bare bucket name; the gateway qualifies it to your org.
  • Query parameters work (tables v0.1.38+). The driver’s parameter dictionary is decoded into typed values and the query text is forwarded verbatim — see Parameters. Integers stay exact past 2^53, floats stay fractional, date/datetime cross as themselves, and a value the compared column cannot hold exactly is a SyntaxError naming the parameter, never a coercion. None, bytes, lists, maps, LocalDateTime, Time, Duration and Point are refused by name at the door.
  • Autocommit RUN only. Explicit transactions (session.begin_transaction()) fail with Neo.ClientError.Request.Invalid; graph reads don’t need them, and writes go through SQL.
  • Only Cypher — no SQL. Everything sent over Bolt is wrapped as a cypher() body, so the statement must begin with MATCH. Sending SELECT * FROM graph_khop(…) is rejected with “expected MATCH, got SELECT”. The analytics functions have no Cypher spelling and are therefore unreachable over Bolt — use SQL.
  • Cypher outside the subset (e.g. an unanchored MATCH) surfaces as Neo.ClientError.Statement.SyntaxError — the driver’s CypherSyntaxError — carrying the cypher subset: message verbatim. After any failure the session ignores further messages until RESET, which drivers send for you.
  • One open result per connection. RUN buffers the full result up front; a second RUN before draining is Neo.ClientError.Request.Invalid.
  • Projected values carry their column’s declared type (tables v0.1.38+): a DATE property arrives as a PackStream Date, a TIMESTAMP as a DateTime in UTC (the legacy structure with identical fields on 4.4), a BYTEA as real Bytes; NUMERIC, JSON and UUID cross as their canonical strings, since Bolt has no scalar for them. A bare RETURN b still yields node keys and hop counters — v1 does not synthesize Neo4j Node/Relationship/Path structs, so record["b"] on a bare return is an integer key, not a node object. Project properties to get values.

Selecting a graph over Bolt is a v1 gap. The gateway reads the session database as the bucket and rewrites it to your org-qualified db id, while the tabled Bolt adapter reads that same field as the graph name. Through the production gateway there is no remaining slot to name a graph, so a bucket holding more than one graph cannot be targeted on this wire. Until that is resolved, drive graphs by name over SQL (cypher('<graph>', …)) or dodil data bolt -b <bucket> -g <graph>.

See also