Cypher.
Cypher reads the same rows SQL does. What it adds is a pattern grammar: instead of joining tables on keys, you draw the shape you are looking for - (a)-[:rel]->(b) - and the engine walks it.
Reach for it when the question is "what is this row connected to, and what is that connected to". A three-hop question is three arrows in Cypher and three joins in SQL, and the arrows stay readable. For aggregation, string matching, arithmetic or anything with a GROUP BY in it, use SQL - this dialect deliberately does not compete there.
POST /v1/tenants/:t/cypher with a body of { cypher, default_schema?, params? }. Reads and writes both go here. No SDK wraps this route yet, so every tab on this page builds the request by hand - which is three lines of boilerplate and then plain query strings.
Before you start: the graph shape.
A relationship is not a table. It is a column on the row that holds another row's primary key, plus a [[relations]] block naming it. Declare the block and every write to that table starts maintaining the edge for you. Graph walks through the modelling in depth; here is the shape these examples use.
Register shop.customers first - a relation's target table must already exist when the pointing table is registered. It refers to itself through referred_by, which is what makes a variable-length referral chain legal later on.
namespace = "shop"
table = "customers"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
required = true
[[columns]]
name = "name"
ty = "str"
[[columns]]
name = "country"
ty = "str"
[[columns]]
name = "referred_by"
ty = "str" # another customer's id - or absent
# A customer points at the customer who referred them. Source and target
# are the SAME table, which is what makes variable-length paths legal.
[[relations]]
name = "referrer"
from_col = "referred_by"
target = { namespace = "shop", table = "customers", pk = "id" }
bidirectional = true
# REQUIRED for MATCH (c:customers {id: '...'}) to resolve.
[[indexes]]
name = "by_id"
columns = ["id"] namespace = "shop"
table = "orders"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
required = true
[[columns]]
name = "customer"
ty = "str" # a shop.customers id
[[columns]]
name = "amount_cents"
ty = "i64"
[[columns]]
name = "status"
ty = "str"
[[columns]]
name = "notes"
ty = "str"
[[columns]]
name = "placed_ms"
ty = "u64"
# The edge: an order points at the customer who placed it.
[[relations]]
name = "placed_by"
from_col = "customer"
target = { namespace = "shop", table = "customers", pk = "id" }
bidirectional = true
[[indexes]]
name = "by_id"
columns = ["id"] MATCH (o:orders {id: "..."}) resolves through an index named by_<primary key column>. Without that [[indexes]] block the point match does not resolve, and since every traversal has to start from a pinned node, nothing on this page will work. Declare it on any table you intend to query with Cypher.
1. MATCH and RETURN.
The simplest query pins one node and projects some properties off it. A label - :orders - is matched case-insensitively against registered table names, and default_schema supplies the namespace, so pass both.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders {id: \"01JTRX9KQ3YH8K2WMX0F5JZAB7\"}) RETURN o.status, o.amount_cents",
"default_schema": "shop.orders"
}'// No SDK wraps /cypher yet - all four tabs call the route directly.
const BASE = `https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}`;
const H = {
"Authorization": `Bearer ${process.env.OC_TOKEN}`,
"Content-Type": "application/json",
};
async function cypher(query: string, defaultSchema?: string, params = {}) {
const res = await fetch(`${BASE}/cypher`, {
method: "POST",
headers: H,
body: JSON.stringify({
cypher: query,
default_schema: defaultSchema,
params,
}),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
const out = await cypher(
`MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})
RETURN o.status, o.amount_cents`,
"shop.orders",
);
console.log(out.kind, out.rows);# No SDK wraps /cypher yet - all four tabs call the route directly.
import os, requests
BASE = f"https://{os.environ['OC_HOST']}/v1/tenants/{os.environ['OC_TENANT']}"
H = {"Authorization": f"Bearer {os.environ['OC_TOKEN']}"}
def cypher(query, default_schema=None, params=None):
r = requests.post(
f"{BASE}/cypher",
headers=H,
json={
"cypher": query,
"default_schema": default_schema,
"params": params or {},
},
)
r.raise_for_status()
return r.json()
out = cypher(
'MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"}) '
'RETURN o.status, o.amount_cents',
default_schema="shop.orders",
)
print(out["kind"], out["rows"])// No SDK wraps /cypher yet - all four tabs call the route directly.
type cypherReq struct {
Cypher string `json:"cypher"`
DefaultSchema string `json:"default_schema,omitempty"`
Params map[string]any `json:"params,omitempty"`
}
func cypher(ctx context.Context, req cypherReq) (map[string]any, error) {
body, _ := json.Marshal(req)
hreq, _ := http.NewRequestWithContext(ctx, "POST",
"https://"+os.Getenv("OC_HOST")+"/v1/tenants/"+os.Getenv("OC_TENANT")+"/cypher",
bytes.NewReader(body))
hreq.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
hreq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(hreq)
if err != nil { return nil, err }
defer resp.Body.Close()
var out map[string]any
err = json.NewDecoder(resp.Body).Decode(&out)
return out, err
}
out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})
RETURN o.status, o.amount_cents`,
DefaultSchema: "shop.orders",
})
if err != nil { /* handle */ }
fmt.Println(out["kind"], out["rows"]) {
"kind": "select",
"rows": [
{ "status": "paid", "amount_cents": 12950 }
]
} RETURN o parses, runs, returns 200, and hands back {} for every row. Whole-node return is not implemented - the projection looks for a column literally named o and finds none. Always name the properties you want. The exceptions are variables bound by WITH … AS n or UNWIND … AS n, which do carry values.
Note the column names in that response. o.status came back as status - the variable prefix is dropped, and the rest of the dotted path becomes the key. Use AS when you want to control it.
2. Filtering.
WHERE supports =, <> (and !=), <, <=, >, >=, AND, OR, NOT, and IS NULL / IS NOT NULL. Each comparison puts a property on one side and a literal on the other.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders) WHERE o.status = \"paid\" AND o.amount_cents > 5000 RETURN o.id, o.amount_cents",
"default_schema": "shop.orders"
}'const out = await cypher(
`MATCH (o:orders)
WHERE o.status = "paid" AND o.amount_cents > 5000
RETURN o.id, o.amount_cents`,
"shop.orders",
);out = cypher(
'MATCH (o:orders) '
'WHERE o.status = "paid" AND o.amount_cents > 5000 '
'RETURN o.id, o.amount_cents',
default_schema="shop.orders",
)out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders)
WHERE o.status = "paid" AND o.amount_cents > 5000
RETURN o.id, o.amount_cents`,
DefaultSchema: "shop.orders",
})
Three limits to internalise now, because the error messages for them are generic. There is no IN, no STARTS WITH / CONTAINS / ENDS WITH, and no regex - those all surface as cypher parse: trailing tokens after query, which reads like a syntax slip rather than a missing feature. And you cannot compare two nodes to each other: WHERE o.customer = c.id is refused.
3. Walking one hop.
-[:placed_by]-> names the relation declared on shop.orders. The engine reads the pre-built edge index rather than scanning the target table, so a hop costs a prefix lookup plus one point-get per neighbour.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders {id: \"01JTRX9KQ3YH8K2WMX0F5JZAB7\"})-[:placed_by]->(c) RETURN c.name, c.country",
"default_schema": "shop.orders"
}'const out = await cypher(
`MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})-[:placed_by]->(c)
RETURN c.name, c.country`,
"shop.orders",
);out = cypher(
'MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})-[:placed_by]->(c) '
'RETURN c.name, c.country',
default_schema="shop.orders",
)out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})-[:placed_by]->(c)
RETURN c.name, c.country`,
DefaultSchema: "shop.orders",
}) {
"kind": "select",
"rows": [
{ "name": "Ada Okafor", "country": "NG" }
]
} Backwards, and both ways
<-[:rel]- walks the edge in reverse, and -[:rel]- walks it in both. Reverse traversal only works when the relation declares bidirectional = true - which is the default, but a relation explicitly set to false silently returns nothing rather than erroring.
# Which orders did this customer place? Walk the edge backwards.
# Only legal because placed_by declares bidirectional = true.
MATCH (c:customers {id: "01JTRX1H4Q9P0N2WMX0F5JZ001"})<-[:placed_by]-(o)
RETURN o.id, o.amount_cents
The first node of any pattern that contains a hop must pin its primary key in the property map. MATCH (o:orders)-[:placed_by]->(c) is a hard error, not a slow full-graph query - the message asks you to add {id: ...}. A bare MATCH (o:orders) with no hop is fine and scans.
4. Walking several hops.
Chain arrows to cross more than one relation in a single pattern. Each hop names its own relation, and only the last hop's nodes come back as rows.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders {id: \"01JTRX9KQ3YH8K2WMX0F5JZAB7\"})-[:placed_by]->(c)-[:referrer]->(r) RETURN r.name, r.country",
"default_schema": "shop.orders"
}'// order -> customer -> the customer who referred them
const out = await cypher(
`MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})-[:placed_by]->(c)-[:referrer]->(r)
RETURN r.name, r.country`,
"shop.orders",
);# order -> customer -> the customer who referred them
out = cypher(
'MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})'
'-[:placed_by]->(c)-[:referrer]->(r) '
'RETURN r.name, r.country',
default_schema="shop.orders",
)// order -> customer -> the customer who referred them
out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders {id: "01JTRX9KQ3YH8K2WMX0F5JZAB7"})
-[:placed_by]->(c)-[:referrer]->(r)
RETURN r.name, r.country`,
DefaultSchema: "shop.orders",
}) Variable-length paths
When you don't know the depth in advance, -[:rel*1..3]-> walks between one and three hops and returns everything it reaches. Bind the path with p = to use length(p), nodes(p) and relationships(p).
This only works on a self-recursive relation - one whose target table declares a relation of the same name, like referrer on shop.customers. You cannot walk a variable number of hops across placed_by, because orders do not point at orders.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH p = (c:customers {id: \"01JTRX1H4Q9P0N2WMX0F5JZ001\"})-[:referrer*1..3]->(up) RETURN up.name, length(p)",
"default_schema": "shop.customers"
}'// Everyone up to 3 referral hops above this customer.
const out = await cypher(
`MATCH p = (c:customers {id: "01JTRX1H4Q9P0N2WMX0F5JZ001"})-[:referrer*1..3]->(up)
RETURN up.name, length(p)`,
"shop.customers",
);# Everyone up to 3 referral hops above this customer.
out = cypher(
'MATCH p = (c:customers {id: "01JTRX1H4Q9P0N2WMX0F5JZ001"})'
'-[:referrer*1..3]->(up) '
'RETURN up.name, length(p)',
default_schema="shop.customers",
)// Everyone up to 3 referral hops above this customer.
out, err := cypher(ctx, cypherReq{
Cypher: `MATCH p = (c:customers {id: "01JTRX1H4Q9P0N2WMX0F5JZ001"})
-[:referrer*1..3]->(up)
RETURN up.name, length(p)`,
DefaultSchema: "shop.customers",
}) {
"kind": "select",
"rows": [
{ "name": "Ravi Menon", "length(p)": 1 },
{ "name": "Sofia Duarte","length(p)": 2 }
]
}
A bare * means *1..64; 64 is the hard depth ceiling. Two shapes are refused inside a longer chain: a variable-length hop cannot be one link of a multi-hop pattern, and a chain may contain at most three undirected hops (each one doubles the work). shortestPath(…) is available over a variable-length pattern when you only want the shortest route between two pinned nodes.
5. Ordering and limiting.
ORDER BY, SKIP and LIMIT attach to RETURN and nowhere else - a WITH … ORDER BY in the middle of a query does not parse. Sort keys must be a property access or a bare variable; ASC is the default.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders) WHERE o.status = \"paid\" RETURN o.id AS order_id, o.amount_cents AS cents ORDER BY o.amount_cents DESC SKIP 0 LIMIT 10",
"default_schema": "shop.orders"
}'const out = await cypher(
`MATCH (o:orders) WHERE o.status = "paid"
RETURN o.id AS order_id, o.amount_cents AS cents
ORDER BY o.amount_cents DESC
SKIP 0 LIMIT 10`,
"shop.orders",
);out = cypher(
'MATCH (o:orders) WHERE o.status = "paid" '
'RETURN o.id AS order_id, o.amount_cents AS cents '
'ORDER BY o.amount_cents DESC '
'SKIP 0 LIMIT 10',
default_schema="shop.orders",
)out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders) WHERE o.status = "paid"
RETURN o.id AS order_id, o.amount_cents AS cents
ORDER BY o.amount_cents DESC
SKIP 0 LIMIT 10`,
DefaultSchema: "shop.orders",
}) SKIP and LIMIT take integer literals. A parameter there does not parse, so build the number into the query string when you paginate.
6. Parameters.
$name placeholders are substituted from the request's params map before the query is planned. Values must be scalars - string, number, boolean or null; arrays and objects are rejected. Referencing a parameter you didn't supply is a 400, not a silent null.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders) WHERE o.status = $want RETURN o.id",
"default_schema": "shop.orders",
"params": { "want": "paid" }
}'const out = await cypher(
`MATCH (o:orders) WHERE o.status = $want RETURN o.id`,
"shop.orders",
{ want: "paid" },
);out = cypher(
"MATCH (o:orders) WHERE o.status = $want RETURN o.id",
default_schema="shop.orders",
params={"want": "paid"},
)out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders) WHERE o.status = $want RETURN o.id`,
DefaultSchema: "shop.orders",
Params: map[string]any{"want": "paid"},
})
Parameters work in WHERE, RETURN and UNWIND. They do not work inside a pattern's property map, so the very place you most want one - (o {id: $id}) - is refused, and the anchor id has to be interpolated into the query text. Escape it yourself.
7. Writing through Cypher.
All four write verbs are implemented and go through the same route. Each returns its own response shape rather than rows.
# CREATE - insert one node. Returns {"kind":"insert","rows_inserted":1}
CREATE (o:orders {
id: "01JTRXNEW00000000000000001",
customer: "01JTRX1H4Q9P0N2WMX0F5JZ001",
amount_cents: 4200,
status: "pending",
notes: "gift wrap",
placed_ms: 1714480000000
})# MERGE - insert only if absent. Returns {"kind":"merge","rows_inserted":0|1}
MERGE (c:customers {
id: "01JTRX1H4Q9P0N2WMX0F5JZ009",
name: "Nia Blake",
country: "GB"
})# SET - update properties on an anchored node.
# Returns {"kind":"update","rows_affected":1}
MATCH (o:orders {id: "01JTRXNEW00000000000000001"})
SET o.status = "paid"# DELETE - retire an anchored node and its derived index/edge state.
# Returns {"kind":"delete","rows_deleted":1}
MATCH (o:orders {id: "01JTRXNEW00000000000000001"})
DELETE o Sent over the wire, an update looks like any other Cypher call:
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/cypher" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cypher": "MATCH (o:orders {id: \"01JTRXNEW00000000000000001\"}) SET o.status = \"paid\"",
"default_schema": "shop.orders"
}'const out = await cypher(
`MATCH (o:orders {id: "01JTRXNEW00000000000000001"}) SET o.status = "paid"`,
"shop.orders",
);
// { kind: "update", rows_affected: 1 }out = cypher(
'MATCH (o:orders {id: "01JTRXNEW00000000000000001"}) SET o.status = "paid"',
default_schema="shop.orders",
)
# {"kind": "update", "rows_affected": 1}out, err := cypher(ctx, cypherReq{
Cypher: `MATCH (o:orders {id: "01JTRXNEW00000000000000001"}) SET o.status = "paid"`,
DefaultSchema: "shop.orders",
})
// map[kind:update rows_affected:1] SET and DELETE both require a preceding MATCH that pins a primary key. An unanchored mutation would be a whole-table rewrite, so it is refused outright. A trailing RETURN after a write parses but is ignored - the write's own counter is what you get back. A constraint violation returns 409 with {"error":"constraint_violation","detail":"…"}.
Result shapes.
Every response carries a kind discriminator; branch on it. There is no columns array and no data envelope - reads hand back plain JSON objects keyed by the projection's output names.
| kind | Body | Emitted by |
|---|---|---|
| select | rows: [ … ] | Any query ending in RETURN |
| insert | rows_inserted: n | CREATE, and FOREACH bodies |
| merge | rows_inserted: n | MERGE - counts only rows that were absent |
| update | rows_affected: n | SET |
| delete | rows_deleted: n | DELETE |
Every response also carries an X-OC-Query-Id header - log it, it is what support will ask for.
Every clause that works.
| Clause | Notes |
|---|---|
| MATCH | Node patterns, relationship patterns, chained hops, and comma-separated patterns. |
| OPTIONAL MATCH | Left-outer semantics. Cannot be the first clause of a query - put a MATCH before it. |
| WHERE | After MATCH, OPTIONAL MATCH or WITH. Property-vs-literal comparisons only. |
| RETURN / RETURN DISTINCT | Projections and aliases. See the RETURN n warning below. |
| ORDER BY / SKIP / LIMIT | Only inside RETURN. SKIP and LIMIT take non-negative integer literals, not parameters. |
| WITH | Projection between stages. WITH DISTINCT and aggregates inside WITH are both refused. |
| UNWIND | Expands a literal list, or a list column on a matched row, into rows. |
| CREATE | Inserts a node. Can also set a relationship column when it follows a MATCH. |
| MERGE | Insert-if-absent on a node pattern. Existing rows are left untouched. |
| SET | var.prop = literal, on a node anchored by primary key. |
| DELETE | One variable, anchored by primary key. Removes derived index and edge state too. |
| FOREACH | Write-only bodies: CREATE, SET, DELETE, or a nested FOREACH. |
| CALL { … } YIELD | Subquery form only, read-only, no nesting. |
| shortestPath(…) | Over a variable-length pattern between two primary-key-anchored nodes. |
Aggregates - count(*), count(x), sum, avg, min, max - work, but only on their own: there is no GROUP BY, so an aggregate cannot share a RETURN with a plain column. The scalar functions upper, lower, length, coalesce and abs are also available.
Every clause that does not.
This is the list people arrive expecting. Most of these fail with a generic parse error rather than a helpful one, so check here before assuming you have a syntax bug.
| Not supported | What to do instead |
|---|---|
| UNION | Refused with a pointer to SQL UNION on /sql, or merge client-side. |
| REMOVE | Refused. Use SQL UPDATE ... SET col = NULL instead. |
| DETACH DELETE | Parses, then refused. Plain DELETE already clears derived edge state. |
| IN | No such operator. Write it as OR-ed equalities. |
| STARTS WITH / CONTAINS / ENDS WITH | No string predicates at all. Use full-text search or SQL LIKE. |
| =~ (regex) | The ~ character does not even lex - you get a lex error, not a parse error. |
| CASE | No conditional expressions. Do it in SQL or in your application. |
| EXISTS(…) | Not a recognised function. |
| collect() | Not implemented. count / sum / avg / min / max are. |
| count(DISTINCT x) | DISTINCT inside an aggregate is rejected. RETURN DISTINCT works. |
| RETURN * | Not an expression. List the properties you want. |
| Arithmetic in WHERE or RETURN | o.amount_cents * 2 is refused; the error points you at /sql. |
| Aggregates mixed with plain columns | There is no GROUP BY in this dialect. Aggregate alone, or use SQL. |
| Relationship variables -[r:TYPE]-> | The parser demands a colon straight after the bracket. Edges cannot be bound. |
| Untyped edges: --> or -[]-> | Every hop must name a declared relation. |
| Edge property maps / type alternation | -[:R {since: 2020}]-> and -[:A|:B]-> both fail to parse. |
| Multi-label nodes (n:A:B) | One label per node pattern. |
| Parameters inside pattern maps | (o {id: $x}) is refused - a property map takes literals. $params work in WHERE, RETURN and UNWIND. |
| allShortestPaths(…) | Only the singular shortestPath() exists. |
| CALL db.labels() and other procedures | Only the CALL { subquery } form is implemented. |
| Cross-variable comparison a.x = b.y | WHERE compares a property against a literal, not against another node. |
Limits and gotchas.
- Composite primary keys cannot be used. Every anchored pattern resolves through a single primary-key column. A table with a two-column key is not reachable from Cypher.
- Labels resolve to table names, first match wins. If two namespaces both hold a table called
orders, a bare:orderslabel is ambiguous. Always senddefault_schema. - Two MATCH clauses need a WITH between them. Back-to-back MATCHes are refused; project through
WITHto chain stages. - Comma-separated patterns are a separate, stricter mode.
MATCH (a)-[:r]->(b), (b)-[:r]->(c) RETURN a, b, cruns a dedicated pattern-matching join, and it accepts only that exact shape: no other clauses, no variable-length, no undirected hops, every node needs a variable, RETURN takes bare node variables only, and WHERE may contain nothing but AND-eda <> bdistinctness checks. - A cyclic graph can fan out. Multi-hop chains do not de-duplicate visited rows, so a cycle expands combinatorially with each hop. Keep chains short and prefer a bounded variable-length range.
- Large results are refused, not truncated. Exceeding the result-row or result-byte budget returns
413with the observed size and the cap. Add aLIMIT. - Under load you may get
429. Cypher counts as a heavy operation and is admission-controlled. HonourRetry-After. - The request body cap is 8 MiB, which matters if you generate long
UNWINDliteral lists. - Every Cypher call is treated as a write for authorisation, including pure reads. A read-only token cannot call this route.