Cypher & Traversals
Preview — the Cypher subset is a whitelist, not a dialect. Four
MATCHshapes 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):
| Function | Columns | Types |
|---|---|---|
graph_khop | node, hop_distance | BIGINT, INT |
graph_neighbors | neighbor | BIGINT |
graph_shortest_path | step, node | INT, 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_khopreturns every node within1..=depthhops — each node once, at its minimum hop distance, start node excluded. Want exactly-depth? Filter onhop_distance. Depth 0 → empty.graph_neighborswith'out'/'in'returns the adjacency row verbatim — parallel edges appear as repeated rows.'both'is the deduplicated union.graph_shortest_pathis unweighted BFS. Rows are the path in order (step0 = 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 | 7The 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..k— all mean “up to k hops” (the ≤-depth k-hop reading). This intentionally differs from Neo4j, where*2means exactly length 2 — filter onhop_distancefor exact-depth. - The lower bound must be 1:
*2..3and*0..3are rejected. - An unbounded
[*]is rejected here (only legal insideshortestPath).
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 thosebthat 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
| Pattern | Direction |
|---|---|
(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 equivalenta.id = <int>. An unanchored whole-graphMATCHis rejected. - For expands (shapes 1–2) the anchor must be the left node and
RETURNmust 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
RETURNorder (an unaliased item is namedvar.prop); the node-key column is replaced by them and the traversal’s ownhop_distance/stepsurvives after them. - Each column keeps the node table’s declared type, so a
DATEproperty is aDATEon the wire — over Bolt it arrives as a PackStreamDate, aTIMESTAMPas aDateTimein UTC, aBYTEAas realBytes. 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 projectsNULL; an unknown property name is refused at plan time naming the table’s actual columns. Unknown-property and missing-value are never conflated. - A
shortestPathprojects 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$$);| Clause | Accepted |
|---|---|
WHERE <var>.<prop> <op> <value> | =, <> / !=, <, <=, >, >= — AND-joined with the mandatory id anchor, on the one labelled variable the RETURN reads |
| value | integer · 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
ASCand — treating null as the largest value — first onDESC. The subset states the simpler rule rather than inheriting the split one. Like*k≡*1..kabove, 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
Integerstays an integer (exact past 2^53, where a double would round); aFloatstays fractional (2.0never collapses to2); a driverDateis a date and aDateTimean instant in UTC. - An id anchor and a
LIMITrequire 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 outsideage’s declared SMALLINT range”, “bornis declared date and parameter$dcarries the string2000-01-01— declare it DATE” — rather than a rendered literal. So are an unbound$nameand 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$namethere is refused naming it: write the value inline, or run the query over Bolt. A bound SQL parameter as agraph_*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
Freshnessrequest field. Thefreshnessfield 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 agraph_*/cypher()call is a different, live mechanism: it selects the overlay-merged serve path for that one traversal. No graph request message carries afreshnessfield.
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:
| Rejected | Instead |
|---|---|
Write Cypher — CREATE, MERGE, SET, DELETE, DETACH, REMOVE, FOREACH | SQL INSERT/UPDATE/DELETE on the node/edge tables (transactions included) |
WITH, UNWIND, CALL, UNION, OPTIONAL MATCH | compose 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 WHERE | AND-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/step | project one variable’s properties; join the other in SQL |
| A property read on an unlabelled node | add the label — it names the node table the properties live in |
Chains longer than 3 hops, a * inside a chain, returning an intermediate chain variable | use [*1..k], or split the pattern |
Multiple MATCH patterns or clauses | one 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 shortestPath | name 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 variable | return 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:
- Bare —
SELECT * 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, aWITHCTE, aJOIN, a subquery, or a set-op branch such asUNION— since v0.1.27 a graph call composes anywhere a relation can appear, not only the top-levelFROM). 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 loudinvalid_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
VALUESis 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://andneo4j://schemes still fail, and TLS is not the reason: both ask for the routing protocol, and the adapter answers the driver’sROUTEmessage with “routing is not supported”. Use a single-instanceboltscheme.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
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/LOGONsplit 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/BEGINis rejected at the gateway withNeo.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/datetimecross as themselves, and a value the compared column cannot hold exactly is aSyntaxErrornaming the parameter, never a coercion.None, bytes, lists, maps,LocalDateTime,Time,DurationandPointare refused by name at the door. - Autocommit
RUNonly. Explicit transactions (session.begin_transaction()) fail withNeo.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 withMATCH. SendingSELECT * 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 asNeo.ClientError.Statement.SyntaxError— the driver’sCypherSyntaxError— carrying thecypher subset:message verbatim. After any failure the session ignores further messages untilRESET, which drivers send for you. - One open result per connection.
RUNbuffers the full result up front; a secondRUNbefore draining isNeo.ClientError.Request.Invalid. - Projected values carry their column’s declared type (tables v0.1.38+):
a
DATEproperty arrives as a PackStreamDate, aTIMESTAMPas aDateTimein UTC (the legacy structure with identical fields on 4.4), aBYTEAas realBytes;NUMERIC,JSONandUUIDcross as their canonical strings, since Bolt has no scalar for them. A bareRETURN bstill yields node keys and hop counters — v1 does not synthesize Neo4jNode/Relationship/Pathstructs, sorecord["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>', …)) ordodil data bolt -b <bucket> -g <graph>.
See also
- Graph → DDL —
CREATE GRAPH, the build lifecycle, and LOAD/RELEASE residency - Graph → Analytics — whole-graph algorithms as SQL table functions
- Connect & wire adapters — the Bolt door, the
bolt://scheme, and credentials