OriginChainDB docs
reference · graph

Graph queries

Graph queries walk relationships between rows. Examples: "all customers a supplier sells to", "friends of friends", "shortest path from order to fulfillment center". You declare which columns are relationships on the schema, then the graph endpoints walk them.

Edges are not stored separately - they're a view over your existing row columns. Writing a row creates / updates / removes the edges automatically. See Schemas → relations for the declaration.

1. Declare a relation.

Add a [[relations]] block to the schema of the table that holds the foreign-key column. After registering this schema, every row write creates the edges automatically.

# manifest.toml - declare the relation on the table holding the FK column.
# The reverse edge is written atomically when bidirectional = true.
namespace   = "social"
table       = "follows"
primary_key = ["id"]

[[columns]]
name = "id"
ty   = "str"
required = true

[[columns]]
name = "follower_id"
ty   = "str"
required = true

[[columns]]
name = "followee_id"
ty   = "str"
required = true

[[relations]]
name          = "followee"          # the verb you walk in queries
from_col      = "followee_id"       # column on THIS table
bidirectional = true

[relations.target]
namespace = "social"
table     = "users"
pk        = "id"

Self-relations work - direction tags in the key prevent collisions. See Schemas → relations for the full reference.

2. One-hop neighbors.

what this does

Return the primary keys of every row directly connected to a given row through a relation. The fastest graph query - just an adjacency list lookup.

GET /v1/tenants/:t/graph/:schema/neighbors
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/social.follows/neighbors?rel=followee&pk=f001" \
  -H "Authorization: Bearer $OC_TOKEN"

For inbound edges (who points at this row?), use the parallel /reverse endpoint with the same params.

3. BFS - multi-hop traversal.

what this does

Walk every node reachable within max_depth hops, breadth-first. Each result carries the hop distance.

GET /v1/tenants/:t/graph/:schema/bfs
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/social.follows/bfs?rel=followee&pk=u001&max_depth=3" \
  -H "Authorization: Bearer $OC_TOKEN"
common mistakes
  • Forgetting max_depth. On a connected graph, BFS without a depth cap can return millions of nodes. Set max_depth to the smallest value that gives you what you need.
  • Using BFS when you just want reachability. If you only need to know whether two nodes are connected, use /path - it short-circuits on first match.

4. Shortest path (Dijkstra).

what this does

Find the lowest-cost path from src to dst. You provide a map of edge weights; the engine returns the total cost (or null if unreachable).

GET /v1/tenants/:t/graph/:schema/dijkstra
# weights_json maps "from_pk|to_pk" → cost. Empty {} = every edge weight 1.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/social.follows/dijkstra?rel=followee&src=u001&dst=u042&weights_json=%7B%7D" \
  -H "Authorization: Bearer $OC_TOKEN"

For top-K paths instead of the single shortest, use /k-shortest (Yen's algorithm). For unweighted shortest path, use /path.

5. All 22 endpoints.

Every graph endpoint, its wire shape, and what it's good for. Centrality and community algorithms (betweenness, eigenvector, label-propagation, triangles, louvain) require same-schema relations - cross-schema is not yet supported.

operation signature use
neighbors GET /graph/:schema/neighbors?rel=&pk= One-hop forward - downstream of a node.
reverse GET /graph/:schema/reverse?rel=&pk= One-hop inbound - who points TO this node?
bfs GET /graph/:schema/bfs?rel=&pk=&max_depth= Breadth-first frontier up to a depth.
path GET /graph/:schema/path?rel=&src=&dst=&max_depth= Reachability check - short-circuits on first match.
all_simple_paths GET /graph/:schema/all_simple_paths?rel=&src=&dst=&max_depth=&max_paths= Every acyclic (simple) route between two nodes. Default cap 256 paths.
all_simple_paths_bidir GET /graph/:schema/all_simple_paths_bidir?rel=&src=&dst=&max_depth=&max_paths= Same, walking each hop in either direction. Needs bidirectional = true.
dijkstra GET /graph/:schema/dijkstra?rel=&src=&dst=&weights_json= Weighted shortest path.
k-shortest GET /graph/:schema/k-shortest?rel=&source=&target=&k= Top-K loop-free paths in increasing weight.
pagerank GET /graph/:schema/pagerank?rel=&nodes= Influence ranking via power iteration.
triangles GET /graph/:schema/triangles?rel= Per-node triangle count. Clustering signal.
components GET /graph/:schema/components?rel= Connected components via Union-Find.
louvain GET /graph/:schema/louvain?rel=&tolerance=&max_levels= Modularity-greedy community detection.
betweenness GET /graph/:schema/betweenness?rel=&max_nodes= Brandes' betweenness - bridge identification.
eigenvector_centrality GET /graph/:schema/eigenvector_centrality?rel=&max_iter=&tol= Influence by who-you're-connected-to.
label_propagation GET /graph/:schema/label_propagation?rel=&max_iter=&seed= Fast soft community detection.
random-walk GET /graph/:schema/random-walk?rel=&start=&steps=&seed=&p=&q= Uniform or Node2Vec-biased walks.
node2vec POST /graph/:schema/node2vec { rel, dim, ..., persist } Train graph embeddings.
node2vec/topk GET /graph/:schema/node2vec/:rel/topk?query=&k=&metric= Find similar nodes via Node2Vec embeddings.
graphsage POST /graph/:schema/graphsage { rel, dim, ..., feature_col, persist } Attribute-aware embeddings (Hamilton 2017).
graphsage/topk GET /graph/:schema/graphsage/:rel/topk?query=&k=&metric= Find similar nodes via GraphSAGE.
graphsage/health GET /graph/:schema/graphsage/:rel/health Is the persisted embedding set degenerate? Read-only.
graphsage/rebuild POST /graph/:schema/graphsage/:rel/rebuild { feature_col, force } Retrain a stuck index from the rows already stored.

Examples.

One hop.

/neighbors is the primitive everything else is built on. All four languages wrap it.

GET /v1/tenants/:t/graph/:schema/neighbors
# Who placed this order?
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.orders/neighbors?rel=placed_by&pk=01JTRX9KQ3YH8K2WMX0F5JZAB7" \
  -H "Authorization: Bearer $OC_TOKEN"
response
["01JTRX1H4Q9P0N2WMX0F5JZ001"]

Note what comes back: primary keys, not rows. The graph endpoints return identity, not content. If you want the customer's name you either fetch the row afterwards, or use Cypher / a plan query, which return full rows.

Going the other way

The relation is declared on shop.orders, and it stays declared there no matter which direction you walk. /reverse is still addressed as graph/shop.orders/…, but the pk you pass is a customer.

GET /v1/tenants/:t/graph/:schema/reverse
# Flip it: which orders did this customer place?
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.orders/reverse?rel=placed_by&pk=01JTRX1H4Q9P0N2WMX0F5JZ001" \
  -H "Authorization: Bearer $OC_TOKEN"
empty is not the same as wrong

/reverse returns [] - not an error - in three different situations: the node genuinely has no in-edges, the relation was declared bidirectional = false, or you typo'd the rel name. Reverse lookups do not validate that the relation exists. Check your spelling before concluding the graph is empty.

Many hops.

/bfs expands outward from a node and tags every result with its distance. This is the workhorse for "everything within N hops".

GET /v1/tenants/:t/graph/:schema/bfs
# Everyone within 3 referral hops of this customer.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/bfs?rel=referrer&pk=01JTRX1H4Q9P0N2WMX0F5JZ001&max_depth=3" \
  -H "Authorization: Bearer $OC_TOKEN"
response
[
  { "pk": "01JTRX1H4Q9P0N2WMX0F5JZ004", "depth": 1 },
  { "pk": "01JTRX1H4Q9P0N2WMX0F5JZ011", "depth": 2 },
  { "pk": "01JTRX1H4Q9P0N2WMX0F5JZ027", "depth": 3 }
]

Reachability and routes

/path answers one question - can I get there - and answers it cheaply.

GET /v1/tenants/:t/graph/:schema/path
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/path?rel=referrer&src=01JTRX1H4Q9P0N2WMX0F5JZ001&dst=01JTRX1H4Q9P0N2WMX0F5JZ027&max_depth=3" \
  -H "Authorization: Bearer $OC_TOKEN"
/path does not return a path

The response is { "reachable": true } and nothing else. The route is not materialised. If you need the actual nodes, use /k-shortest with k=1, which does return a node list and a cost.

# Want the actual route, with costs? Use k-shortest.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/k-shortest?rel=referrer&source=01JTRX1H4Q9P0N2WMX0F5JZ001&target=01JTRX1H4Q9P0N2WMX0F5JZ027&k=3" \
  -H "Authorization: Bearer $OC_TOKEN"
response
{
  "paths": [
    { "nodes": ["...001", "...004", "...027"],            "cost": 2.0 },
    { "nodes": ["...001", "...011", "...019", "...027"],  "cost": 3.0 }
  ]
}

Weighting differs between the two weighted endpoints, and it trips people up. /dijkstra takes a weights_json map keyed by "from_pk|to_pk" that you supply in the request - weights are not stored on edges. /k-shortest is usually what you want instead: pass weight_col and it reads the weight from a column on the destination row, defaulting to 1.0 per hop.

Traversal that returns rows: RelationHop.

Underneath the REST endpoints, a hop is a query-plan operator called RelationHop. It reads the forward edge index by prefix and then point-gets each destination row - so unlike /neighbors, it hands back complete rows.

You can post a plan directly to /v1/tenants/:t/query. The plan is plain JSON, tagged by op:

{
  "op":      "relation_hop",
  "schema":  "shop.orders",
  "rel":     "placed_by",
  "from_pk": ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
  "target":  "shop.customers"
}
POST /v1/tenants/:t/query
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/query" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "op":      "relation_hop",
    "schema":  "shop.orders",
    "rel":     "placed_by",
    "from_pk": ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
    "target":  "shop.customers"
  }'

Filtering mid-traversal

Chaining hops uses a sibling operator, RelationChain, and this is where plans earn their keep: each hop can carry its own predicate, applied to that hop's output before the next hop expands from it. That is push-down filtering, not post-filtering - a selective predicate early in the chain cuts the work everything after it does.

{
  "op":          "relation_chain",
  "from_schema": "shop.orders",
  "from_pk":     ["01JTRX9KQ3YH8K2WMX0F5JZAB7"],
  "hops": [
    { "rel": "placed_by", "target": "shop.customers" },
    {
      "rel":    "referrer",
      "target": "shop.customers",
      "where_predicate": { "op": "eq", "path": "country", "value": "NG" }
    }
  ]
}

Only the final hop's rows come back. And because these are ordinary plan nodes, the usual operators compose around them - filter, project, sort, limit, distinct, aggregate.

cycles fan out exponentially

RelationChain does not de-duplicate rows between hops and carries no built-in depth cap. On a graph with cycles, A → B → A → … expands combinatorially with every hop you add. Cap the depth yourself, keep chains short, and prefer /bfs - which does track visited nodes - when the shape is "everything within N hops".

Most people should reach for Cypher rather than hand-writing plans - MATCH (o:orders {id: "..."})-[:placed_by]->(c) RETURN c.name compiles to exactly the plan above. Raw plans are there for generated queries and for shapes Cypher does not express.

Graph algorithms.

These are real, callable endpoints - not patterns you assemble yourself. Twenty-two of them, each a single HTTP call against a declared relation. Two worked examples first, then the full catalogue.

PageRank

Ranks influence within a set of nodes you nominate. The nodes parameter is required - PageRank scores a subgraph you define, it does not rank an entire table on its own, and an empty list is a 400.

GET /v1/tenants/:t/graph/:schema/pagerank
# PageRank needs an explicit node universe - it does not rank a whole
# table for you. Pass the PKs you want scored.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/pagerank?rel=referrer&nodes=c001,c002,c003,c004,c005&damping=0.85" \
  -H "Authorization: Bearer $OC_TOKEN"
response
[
  { "pk": "c001", "score": 0.3120 },
  { "pk": "c004", "score": 0.2455 },
  { "pk": "c002", "score": 0.1810 }
]

Louvain communities

Partitions the graph into clusters by modularity. Unlike PageRank it takes the whole relation, and community ids are dense integers starting at zero.

GET /v1/tenants/:t/graph/:schema/louvain
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/graph/shop.customers/louvain?rel=referrer" \
  -H "Authorization: Bearer $OC_TOKEN"
response
{
  "communities": [
    { "pk": "c001", "community": 0 },
    { "pk": "c002", "community": 0 },
    { "pk": "c007", "community": 1 }
  ]
}

The full catalogue

All twenty-two live under /v1/tenants/:t/graph/:schema/ and take rel=. Everything is GET except the two embedding trainers and the GraphSAGE rebuild.

Endpoint Shape What it's for
neighbors GET ?rel=&pk= One hop forward. Returns a bare array of primary keys.
reverse GET ?rel=&pk= One hop backward. Needs bidirectional = true.
bfs GET ?rel=&pk=&max_depth= Breadth-first frontier with depths. Default depth 3.
path GET ?rel=&src=&dst=&max_depth= Reachability only - returns { reachable } and no route.
dijkstra GET ?rel=&src=&dst=&weights_json= Cheapest weighted route. Weights come from the request, not storage.
k-shortest GET ?rel=&source=&target=&k=&weight_col= Yen's k loop-free routes, with node lists. Max k = 50.
all_simple_paths GET ?rel=&src=&dst=&max_depth=&max_paths= Every acyclic route between two nodes. Default cap 256 paths.
all_simple_paths_bidir GET ?rel=&src=&dst=&max_depth=&max_paths= Same, searching from both ends. Needs bidirectional = true.
pagerank GET ?rel=&nodes=&damping=&max_iter=&tol= Influence ranking by power iteration. nodes= is required.
triangles GET ?rel= Triangle enumeration, each reported once in canonical order.
components GET ?rel= Connected components by union-find. Undirected interpretation.
betweenness GET ?rel=&max_nodes= Brandes' betweenness - finds bridges. Clamped at 100k nodes.
eigenvector_centrality GET ?rel=&max_iter=&tol= Influence weighted by neighbours' influence.
label_propagation GET ?rel=&max_iter=&seed= Fast community detection. Pass seed= or results are not reproducible.
louvain GET ?rel=&tolerance=&max_levels= Modularity-based communities. Up to 500k nodes.
random-walk GET ?rel=&start=&steps=&seed=&p=&q= Biased random walk sampling. Max 1,000 steps.
node2vec POST { rel, dim, walks_per_node, …, persist } Train structural embeddings. persist: true enables topk.
node2vec/:rel/topk GET ?query=&k=&metric= Nearest nodes by persisted Node2Vec embedding.
graphsage POST { rel, dim, layers, feature_col, persist } Attribute-aware embeddings that read a feature column.
graphsage/:rel/topk GET ?query=&k=&metric= Nearest nodes by persisted GraphSAGE embedding.
graphsage/:rel/health GET Health of a persisted GraphSAGE index. Read-only, no row data.
graphsage/:rel/rebuild POST { feature_col, force, dry_run } Retrain a degenerate index in place. 404 if none, 409 if healthy.
not implemented

Named here so you don't go looking:

  • Strongly connected components (Tarjan / Kosaraju) - components is undirected only
  • Topological sort
  • Minimum spanning tree
  • Clustering coefficient - triangles gives you the raw counts to compute it yourself

SDK coverage is uneven. TypeScript and Go wrap the five traversal endpoints - neighbors, reverse, bfs, path, dijkstra - and nothing else. Python additionally wraps k-shortest, shortest-path, random-walk, PageRank, Louvain, label propagation, betweenness, and the Node2Vec / GraphSAGE top-k calls. Everything else is a plain GET, as the tabs above show.

Limits and gotchas.

  • Graph is a capability you enable, not one you buy - it is included on every paid configuration at no extra charge. Every /graph/* route returns 402 with {"addon":"graph"} if the capability is not enabled on your instance. Traversal via Cypher goes through a different route and a different check.
  • Some caps clamp, some reject. k above 50 on k-shortest is a 400 - deliberately, so you budget rather than silently getting fewer paths. Betweenness above its node ceiling clamps instead. Know which one you are relying on.
  • Ceilings worth writing down: variable-length depth 64, k-shortest k 50, random walk 1,000 steps, betweenness 100,000 nodes, Louvain 500,000 nodes, Node2Vec and GraphSAGE 100,000 nodes and 1,024 dimensions, and an 8 MiB HTTP body cap.
  • Label propagation is non-deterministic unless you pass seed. Without one the server seeds from the clock, and the seed is not echoed back - so you cannot reproduce a run after the fact. Always pass your own.
  • Connected components is undirected. It unions both endpoints of every edge, so it will not give you strongly connected components on a directed graph.
  • Graph calls are admission-controlled. They are classed as heavy operations and can return 429 with a Retry-After under memory pressure, or 413 when a result exceeds the size budget. Both are protective - retry or narrow the query rather than looping hard.

6. Examples.

6.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 is a full namespace.table id that applies only to patterns carrying no label.

POST /v1/tenants/:t/cypher
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"
  }'
response
{
  "kind": "select",
  "rows": [
    { "status": "paid", "amount_cents": 12950 }
  ]
}
RETURN n gives you an empty object

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.

6.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.

WHERE with AND
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"
  }'

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.

6.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.

one-hop traversal
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"
  }'
response
{
  "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
every traversal needs an anchor

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.

6.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.

chained hops across two relations
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"
  }'

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.

variable-length path, 1 to 3 hops
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"
  }'
response
{
  "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.

6.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.

ORDER BY … SKIP … LIMIT
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"
  }'

SKIP and LIMIT take integer literals. A parameter there does not parse, so build the number into the query string when you paginate.

6.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.

named parameters
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" }
  }'

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.

6.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.

the four write verbs
# 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
})

Sent over the wire, an update looks like any other Cypher call:

SET over HTTP
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"
  }'

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
selectrows: [ … ]Any query ending in RETURN
insertrows_inserted: nCREATE, and FOREACH bodies
mergerows_inserted: nMERGE - counts only rows that were absent
updaterows_affected: nSET
deleterows_deleted: nDELETE

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 above.
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 :orders label is ambiguous, and default_schema does not disambiguate it - the resolver never consults it once a label is present.
  • Two MATCH clauses need a WITH between them. Back-to-back MATCHes are refused; project through WITH to chain stages.
  • Comma-separated patterns are a separate, stricter mode. MATCH (a)-[:r]->(b), (b)-[:r]->(c) RETURN a, b, c runs 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-ed a <> b distinctness 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 413 with the observed size and the cap. Add a LIMIT.
  • Under load you may get 429. Cypher counts as a heavy operation and is admission-controlled. Honour Retry-After.
  • The request body cap is 8 MiB, which matters if you generate long UNWIND literal lists.
  • Cypher calls are authorised by what they actually do - a pure read is checked like any other SELECT, and each write verb requires the matching privilege on its target schema. A read-only token can run a read-only Cypher query.