Cypher
July 8, 2026 · View on GitHub
Fluree supports openCypher 9 queries. A Cypher
relationship-with-properties — (a)-[:WORKS_FOR {role: "..."}]->(b) — maps to
Fluree's edge-annotation primitive (concept,
internals), so property-graph edges and RDF
quoted-triple annotations are the same data read from two angles.
The same database is queryable through JSON-LD, SPARQL, and Cypher at once — one underlying store, no separate copy or sync step, so data written through any surface is immediately visible to the others. Because each surface is its own query language over that shared data, there are some subtle differences to be aware of — see Differences from Neo4j at the end for the model divergences and the handful of deferred forms.
Quick start
MATCH (p:Person {name: "Alice"})-[:WORKS_FOR {role: "Engineer"}]->(o:Organization)
RETURN p, o
Running Cypher
Cypher is read/write — reads go through the query path, writes through the
transaction path. Both require a target ledger (Cypher has no FROM/dataset
clause). Read and write are split into separate endpoints/methods for parity
with SPARQL, but the statement determines what runs.
Rust API
// read
let result = fluree.query_cypher(&db, "MATCH (n:Person) RETURN n.name").await?;
// write
let committed = fluree.transact_cypher(ledger, "CREATE (n:Person {name: \"Alice\"})").await?;
Parameterized forms ($param) are available via query_cypher_with_params /
transact_cypher_with_params.
CLI — Cypher is auto-detected from a .cypher/.cyp/.cql file extension
or a leading MATCH/CREATE/MERGE/…; force it with --cypher (query) or
--format cypher (update):
fluree query my/ledger -e 'MATCH (n:Person) RETURN n.name' --cypher
fluree update my/ledger -f create.cypher
Cypher results default to cypher-json (a Neo4j-compatible tabular envelope
with native scalars); pass --format jsonld for the RDF JSON-LD form.
HTTP — send the statement with Content-Type: application/cypher to the
ledger-scoped query/update endpoints:
curl -X POST http://localhost:8090/v1/fluree/query/my/ledger \
-H 'Content-Type: application/cypher' \
--data 'MATCH (n:Person) RETURN n.name'
curl -X POST http://localhost:8090/v1/fluree/update/my/ledger \
-H 'Content-Type: application/cypher' \
--data 'CREATE (n:Person {name: "Alice"})'
The body may be raw Cypher, or a JSON envelope {"cypher": "...", "params": {...}}
(the Neo4j-HTTP shape). Responses are cypher-json; request RDF JSON-LD with
Accept: application/ld+json.
Bulk loading — a .cypher dump of CREATE / MATCH … CREATE statements
(the Neo4j/Memgraph export idiom) should not be replayed statement-by-statement.
fluree create <ledger> --from dump.cypher converts it on the fly and loads it
through the chunked bulk-import pipeline; see
the create command.
Cardinality
Cypher's default is bag semantics; SPARQL's default is set semantics. The cardinality contract:
- Bare
(a)-[:T]->(b)returns one row per distinct(s, p, o)— matches SPARQL. - Binding
ror matching on relationship properties shifts to one row per occurrence — matches Cypher. RETURN DISTINCTalways falls back to set semantics.
Supported surface
Reads
MATCH / OPTIONAL MATCH / WHERE / RETURN [DISTINCT]
ORDER BY / SKIP / LIMIT
-
Node patterns with labels and/or inline properties.
-
Directed typed relationships and type alternatives (
[:T1|T2], lowered to aUnionof concrete-predicate triples). -
Inverse direction (
<-[:T]-). -
Untyped relationships (
[r]) — predicate is variable, system facts hidden via the existinginclude_system_facts = falsefilter. -
Variable-length paths
-[:T*]->,-[:T*m..n]->(unbounded reuses the transitivePropertyPathoperator; bounded ranges expand to aUnionof fixed-length chains with relationship-uniqueness filters). An unbounded range may name a type alternation (-[:A|B*]->,-[:A|B*0..]->): the closure follows an edge of any listed type per hop (LDBC IC12's[:HAS_TYPE|IS_SUBCLASS_OF*0..]). Bounded alternation (-[:A|B*1..3]->) is still deferred — use the unbounded form. -
Variable-length relationship / path binding. A var-length relationship may bind a variable:
-[r:T*…]->bindsrto the list of relationship values per match, andMATCH p = (a)-[:T*…]->(b)bindspto a path value. Two execution strategies, chosen automatically:- Bounded typed directed ranges expand to fixed-length chain branches, each constructing the value from its nodes.
- Everything else — unbounded (
-[r:T*]->,p = (a)-[:T*]->(b)), untyped/wildcard, undirected, zero lower bounds (*0..), and lower bounds above 1 — runs the path-enumeration search: a DFS from the (anchored) start emitting one row per path whose hop count is in range. The end node binds per path when free, or filters the enumeration when already bound. The search enforces Cypher relationship-uniqueness (trail semantics — no edge is traversed twice, but a node may be revisited via a different edge; e.g. the triangle closurea→b→c→ais a valid 3-hop path), matching Neo4j. It is guarded by visited/path caps that error rather than silently truncate — narrow dense patterns with hop bounds, a bound end, or a type.
A fixed single hop also takes a path variable (
MATCH p = (a)-[:T]->(b)is a*1..1path), as does a multi-hop chain of fixed single-typed directed hops (p = (a)-[:R1]->(b)<-[:R2]-(c)) — the path value is built from the bound nodes and per-hop relationship values. Deferred: a variable-length or undirected segment inside a multi-hop path value, binding over a type alternation, and property filters on a var-length relationship. -
Untyped variable-length paths
-[*]->,-[*m..n]->(no relationship type): a wildcard transitive path that follows any node→node edge per hop — excludingrdf:type(its object is a class, not a node) and thef:reifies*reifier bundle, and ignoring data properties (only node-valued edges are followed). Bounds become the path'smin_hops/max_hops; a bounded range runs a layered (node, depth) BFS, so a node reachable in range is found even when a shorter path to it also exists (-[*2..2]->finds the length-2 path past a 1-hop edge), and the bound-bound and bound-unbound forms agree. These use reachability semantics (each in-range node once, not path enumeration or trail semantics). A direction is required; undirected untyped (-[*]-) is deferred, as is an unbounded lower bound above 1 (-[*2..]->— give an upper bound or name a type). -
Undirected relationships
-[:T]-(forward ∪ reverseUnion). -
Path finding:
MATCH p = shortestPath((a)-[:T*]-(b))andallShortestPaths(...). Anchored (both endpoints bound by a preceding MATCH); unweighted bidirectional BFS over a single typed predicate, lowered toPattern::ShortestPathand executed byShortestPathOperator.Singlemode binds one shortest path per input row;Allmode emits one row per minimal-length path. The path binds to aBinding::Path(node sequence);length(p)is its hop count andp IS NULL(underOPTIONAL MATCH) detects "no path" — the IC13 shape.nodes(p)returns the node sequence,pathPairs(p)the consecutive node pairs, andrelationships(p)the per-hop relationship values (all list-valued, forUNWIND/ list functions). -
Relationship values. A relationship is a value carrying its start node, type, and end node (
Binding::Rel); it comes from a bound-[r:T]->(the reified edge),relationships(p), or a bound var-length relationship.type(r)is the relationship type string,startNode(r)/endNode(r)its endpoints,properties(r)/r.propits edge properties (present only for a reified/annotated edge — a plain path edge has none). Rendered as a{start, type, end}object. -
Scalar functions:
- Casts / general:
toString,toInteger,toFloat,coalesce. - String:
toUpper,toLower,substring(0-indexed; 2- and 3-arg),left,right,trim,ltrim,rtrim,replace(literal replace-all),split(→ list). - Math:
abs,round,floor,ceil/ceiling,rand,sqrt,sign,log(natural logarithm), and the^exponent operator (right-associative). - Identity:
id(n)/elementId(n)return the node/relationship's stable identity string (the stored name — a full IRI in@vocabmode; see names). Fluree has no integer element id (differs from Neo4j's integerid).
- Casts / general:
-
WHEREexpressions: comparison, AND/OR/XOR/NOT, arithmetic+ - * / %,^, STARTS WITH / ENDS WITH / CONTAINS, IS NULL / IS NOT NULL,expr IN [a, b, ...],CASE WHEN ... THEN ... END(simple and subject forms),EXISTS { pattern }and the subquery formEXISTS { MATCH pattern WHERE expr }(the innerWHEREis ANDed into the correlated existence test; outer-scope variables stay visible). -
Property accessors
n.propin expression position. Lowered by emitting an auxiliaryOptional((n, <prop IRI>, ?#__prop_n_prop))before the consuming Filter/Bind/aggregate. The Optional wrap preserves Cypher's nullable property-access semantics: whennhas no value for the key, the accessor evaluates to null instead of dropping the row. This makesWHERE n.missing IS NULLreturn nodes lacking the property,RETURN n.namereturn one row per matched node (with null where the property is absent), andavg(n.age)average across all matched nodes — skipping nulls — as Cypher users expect.WHERE n.age > 30continues to filter to age-bearing nodes above 30 (the>comparison on an unbound binding yields filter-context false). Bare-variable target only in v1; chained accessors (n.a.b) are rejected. -
Temporal component accessors —
d.year,d.month,d.day,d.hour,d.minute,d.secondextract a component of a date/dateTime-valued property as an integer (e.g.WHERE p.birthDate.year < 1990). This is the one property-accessor chain that is not rejected. -
Temporal constructors —
date('2024-01-15'),datetime('2024-01-15T10:00:00Z'),time('10:00:00'),duration('P1D')fold a constant lexical argument to a typed value, usable in comparisons (WHERE e.at > datetime('…')) and as write property values (SET n.created = datetime()). Zero-argdatetime()/date()are the current instant / date; in a write statement every zero-arg constructor sees the same instant. Component maps (date({year: 2024})), non-constant arguments, and duration arithmetic are deferred. -
ORDER BY (variable, property-accessor, or general expression keys — e.g.
ORDER BY toInteger(n.id)), SKIP, LIMIT. -
UNWIND [literals] AS x— inline list literal unwinding, andUNWIND <expr> AS xover a runtime list (UNWIND nodes(path) AS n,UNWIND range(1,5) AS i) — a correlated operator fans each input row out over the list elements; a property accessor on the element correlates (n.name). -
List functions over a
collect()list:size,head,last,tail,reverse(andsize/reverseover a string). Usable in the finalRETURNwrapping a collect, e.g.RETURN size(collect(f.name));collect()nested in arithmetic is rejected. -
List literals
[a, b, …]and structuredcollect([a, b])— collect per-row tuples into a list of lists (e.g.RETURN collect([n.id, n.name])). -
List indexing
list[i]— 0-based element access; a negative index counts from the end (list[-1]is the last element). Out-of-range, non-integer index, or non-list yields null. An indexed element that is itself a node ref correlates downstream (WITH pair[0] AS x ... x.name). -
List iteration — list comprehensions
[x IN list WHERE pred | expr],reduce(acc = init, x IN list | expr), and the list predicatesall/any/none/single(x IN list WHERE pred). The loop variable is scoped to the body and bound per element via a shared overlay; property access on it works ([x IN nodes(p) | x.name],[row IN $people | row.email],reduce(s = 0, x IN xs | s + x.score)) — a map element looks the key up, a node element scans the property at eval time. The list position may aggregate ([x IN collect(p) | x.name]). A null / non-list input yields null (not an empty list); empty-list identities areall/none= true,any/single= false. (EXISTS { … }inside a list-iteration body is rejected — it would need per-element async subquery evaluation; write-sideMATCH … WHEREdoesn't accept these forms either.) -
Pattern comprehension
[(a)-[:KNOWS]->(b) WHERE b.age > 30 | b.name]— a correlated subquery that collects a projection over each match into a list. The inner pattern's existing variables (e.g.a) correlate with the outer row; new ones (b) are introduced in the subquery. Resolved asynchronously per outer row on the same machinery asEXISTS, so it can appear as a value anywhere a projection expression can — including nested (size([(a)-->(b) | b])). Write-sideMATCH … WHEREdoesn't accept it. -
Metadata functions:
labels(n)returns the node's Cypher label strings (from liverdf:typeassertions, overlay-aware);type(r)returns the relationship type string for a named relationship variable (fromf:reifiesPredicateon the reifier). Unbound or non-node/non-rel arguments yield null. -
pathPairs(p)— the consecutive node pairs of a path value ([[a,b],[b,c],…], each pair a two-element list). WithUNWIND, this drives per-edge aggregation:UNWIND pathPairs(p) AS pairthenpair[0]/pair[1]as the edge endpoints. The building block for IC14-style weighted path scoring —reduceover per-edge interaction counts becomes unwind-pairs → OPTIONAL MATCH →count→sum, grouped by the carried path. -
Map values: a map literal
{k: expr, …}in expression position (RETURN {name: n.name, age: n.age} AS person),properties(n)(all of a node's data properties as a map — excluding labels, relationships, and the reifier sidecar; a multi-valued property becomes a list),keys(n)(the property names as a sorted list), and object$params($filter = {city: "NYC"}). A map carries in aBinding::Mapand renders as a JSON object — native ({"name": "Alice"}) in cypher-json. Map identity (DISTINCT / grouping) is key-order-insensitive; display preserves insertion order; duplicate literal keys resolve last-wins. Maps are projection/value constructs only — not RDF terms, so they can't be matched, indexed, or stored viaSET n.prop = {…}. A computed entry may itself be an async subquery ({ok: EXISTS { (p)-[:KNOWS]->(:Person) }}) — it is resolved per row on the same machinery as a bareEXISTS. -
Map projection
n{.name, .age, computed: n.age + 1}— build a map from a node variable:.keyselectors desugar tokey: n.key,key: expradds an explicit entry, andn{.*}projects every data property (equivalent toproperties(n)). Mixing.*with other selectors is deferred (useproperties(n)or list the keys). -
Aggregates:
count(*),count(x),count(DISTINCT x),sum(x),avg(x),min(x),max(x). Arguments may be a bare variable (count(n)), a property accessor (avg(n.age)), a list literal (collect([n.id, n.name])), or a scalar expression (sum(n.age * 2), lowered through a pre-aggregationBind). Mixed projections (RETURN n, count(*) AS c) implicitly group by the non-aggregate projections. -
WITH ... [WHERE/ORDER BY/SKIP/LIMIT/DISTINCT]andWITH *— subquery boundary. WHERE that references aggregate aliases lowers to HAVING rather than a pre-aggregation Filter. Nested WITHs nest Subqueries. Acollect()projected by aWITHcarries forward as a real list to the next stage (WITH p, collect(f) AS fs … RETURN size(fs)/UNWIND fs …); onlyORDER BYdirectly on a collected list is rejected (sorting a list value is unsupported in v1). -
CALL [(a, b) | (*)] { … }— a read-only subquery clause in the pipeline. The scope clause(a, b)imports those outer variables (the subquery is correlated on them),(*)imports the whole visible outer scope, andCALL { … }with no scope clause runs once and broadcasts its result. The body isMATCH/OPTIONAL MATCH/WITH/UNWIND/ nestedCALLending inRETURN(explicit columns, not*); outer rows flow in and the RETURN columns continue downstream. The body may be aUNION/UNION ALLof branches with a common column shape (UNIONdedups per correlation group; every branch references the same imports and projects the same columns). A correlated aggregating CALL (CALL (p) { … RETURN count(f) }) is grouped per import, so an import with zero inner matches yields no row — wrap the innerMATCHinOPTIONAL MATCHto retain it as a0. Scope is strict: every import must already be bound outside, a RETURN may not re-bind any outer name, and the body may not reuse an outer variable's name internally without importing it (rename it, or add it to the scope clause, or useCALL (*)). Deferred: writes insideCALL. -
RETURN n,RETURN n, m,RETURN *,RETURN DISTINCT ...,RETURN expr AS alias(lowered viaBind). -
UNIONandUNION ALLat the RETURN boundary. Every branch must project the same VarIds in the same order; mixingUNIONandUNION ALLin one chain is rejected (matches the openCypher spec).RETURN *is also rejected in UNION branches because its projected-vars list is opaque at lower time.
Procedures (introspection shims)
Graph tooling — Neo4j Browser, LangChain, driver smoke tests — introspects the database through built-in procedures before it issues real queries. Fluree answers the common ones directly from ledger statistics (novelty-merged, no scan), so they are instant even on large ledgers:
| Procedure | Answers |
|---|---|
CALL db.labels() | Distinct node labels (classes), sorted. |
CALL db.relationshipTypes() | Distinct relationship types (predicates whose objects are nodes; rdf:type excluded). |
CALL db.propertyKeys() | Distinct property keys (predicates with literal values). |
CALL db.schema.visualization() | One row: nodes / relationships summary lists (best effort). |
CALL dbms.components() | Compatibility identity (mirrors the Bolt handshake's Neo4j/<version> (compatible; Fluree/…)). |
CALL apoc.meta.data() | Per-(label, property) schema rows — node properties with meta types (STRING/INTEGER/…) and outgoing relationships (type: "RELATIONSHIP", other = end labels). Covers the LangChain Neo4jGraph schema queries verbatim. |
The full call form composes like any read — after the YIELD the statement
continues with ordinary read clauses
(CALL proc() [YIELD * | col [AS alias], … [WHERE …]] [WITH/UNWIND/MATCH …] [RETURN …]):
CALL db.labels() YIELD label WHERE label STARTS WITH "P" RETURN label ORDER BY label
CALL apoc.meta.data()
YIELD label, other, elementType, type, property
WHERE type = "RELATIONSHIP" AND elementType = "node"
UNWIND other AS other_node
RETURN {start: label, type: property, end: toString(other_node)} AS output
Names render through the ledger's default context (@vocab stripped, term
overrides reversed), so db.labels() returns the identifiers you would write
in a MATCH. Like Neo4j's own catalog procedures, answers are lenient about
tombstones: a label or key whose facts were all retracted may keep appearing
until a reindex. A procedure call stands alone as its own statement (it can't
follow a MATCH), and unsupported procedures (e.g. apoc.*) fail with an
error listing the supported set.
Writes
-
CREATE— nodes and relationships. Directed typed relationships emit a base triple plus a reifier bundle (LPG-mode default for Cypher); multiple parallel relationships in oneCREATEmint distinct annotation subjects automatically.CREATE (a:Person {name: "Alice"})-[:WORKS_FOR {role: "Engineer"}]->(b:Org {name: "Acme"}) -
SET— set/overwrite a property (SET n.age = 30), merge a map (SET n += {age: 30, city: "X"}), replace scalar node properties with a map (SET n = {name: "Alice"}), or add a label (SET n:Admin). Map replace removes prior scalar node properties while preserving labels, relationships, and relationship sidecar metadata. -
REMOVE— remove a property (REMOVE n.age) or a label (REMOVE n:Admin). -
DELETE/DETACH DELETE— delete nodes/relationships.DETACH DELETEremoves a node together with its relationships. -
FOREACH— unroll a write over a constant list (inline literal, constantrange(), or a$paramarray), running aCREATE/SET/REMOVEbody per element:FOREACH (n IN range(1, 3) | CREATE (:Ping {n: n}))Bodies unroll at parse time (≤ 10000 iterations; same-property
SETis last-wins). Deferred: runtime lists (e.g. a collected list) andMERGE/DELETE/ nestedFOREACHbodies. -
MERGE— find-or-create for a single node (MERGE (n:Person {name: "Alice"})) or a single relationship path, in two forms:- Standalone — the whole pattern is the match key, treated atomically:
MERGE (a:Person {name: "Alice"})-[:KNOWS]->(b:Person {name: "Bob"}). If no matching path exists, the entire path is created with fresh nodes for both endpoints — even if a node matching one endpoint already exists. (To reuse existing endpoints, bind them with a leadingMATCH— the per-row form below.) - Per-row (leading
MATCHbinds the endpoints) — find-or-create the edge for each matched pair, reusing the bound nodes:MATCH (a:Person), (b:Person) WHERE a.name <> b.name MERGE (a)-[:KNOWS]->(b). The edge is created only for pairs that don't already have it. An endpoint introduced by theMERGE(not bound by theMATCH) is created per row — e.g.MATCH (a:Person) MERGE (a)-[:HAS_PET]->(p:Pet {name: "Rex"})creates onePetper matcheda.
Cartesian-product warning: a per-row
MERGEover an unfiltered multi-nodeMATCH(MATCH (a:Person), (b:Person) MERGE (a)-[:KNOWS]->(b)) considers every ordered pair — O(n²) candidate edges. Add a selectiveWHERE(as above) unless a full cross-product is intended.A property-bearing relationship pattern (
MERGE (a)-[:IN {since: 2020}]->(b)) matches only an edge whose properties carry those values — a different value creates a parallel edge, per Cypher.ON CREATE SETis supported on both forms and may target endpoint node variables or the relationship variable (ON CREATE SET r.checks = 1).ON MATCH SETis supported on single-nodeMERGE, on standalone relationshipMERGE(resolved by probing the current writer state, then staging either branch), and on the per-row relationship form (leadingMATCH). The per-row form decomposes into two branches over the same leadingMATCH— anON MATCH SETover the rows whose edge already exists, then a create (ON CREATE SET) over the rows whose edge is absent — staged into a single atomic commit (either both branches publish, or an error returns with nothing committed).ON MATCH SETon a per-row node MERGE (leadingMATCHbefore a nodeMERGE) stays deferred — there is no relationship to partition the rows on.A single-node or standalone relationship
MERGEalso takes trailingSETclauses, which apply on both branches — the standard upsert idiom:MERGE (n:User {id: $id}) SET n += $propsThe map side of
SET n = …/SET n += …may be a whole-map parameter ($propsabove) or an inline{k: v}literal.Style note: write bound endpoints bare in the
MERGEpattern (MATCH (a:Person) MERGE (a)-[:T]->(b)). Repeating a label on a bound endpoint (MERGE (a:Person)-[:T]->(b)) re-asserts itsrdf:typetriple when the edge is inserted — idempotent in RDF, but redundant. - Standalone — the whole pattern is the match key, treated atomically:
-
MATCH … CREATE/SET/REMOVE/DELETE— pattern-driven write templates (find rows, then write per match). Write-sideMATCHsupports labels, inline property filters (on nodes and relationships —-[r:T {w: 3}]->filters on the relationship's properties), directed single-typed relationships, and scalarWHEREfilters over the same comparison/boolean/string/property- accessor expression surface used by reads.CASE/EXISTSinside write-sideWHEREare still deferred. -
MATCH … WITH … <write>— aWITHbetween the match and the write, limited to the horizon subset: pass-through variables (WITH a, b), renames (WITH a AS p), computed (non-aggregate) aliases carried into the write (WITH a, a.birthYear + 30 AS adultAt SET a.adultAt = adultAt), and a post-projectionWHEREthat gates which rows are written (WITH p, p.age AS age WHERE age >= 30 SET p.adult = true).WITHapplies Cypher scoping — only projected names are visible to the write. Works beforeCREATE/SET/REMOVE;WITHbeforeDELETEis rejected (delete resolution keys off the raw MATCH variables and can't honor a rename/horizon —DELETEdirectly off the MATCH variables). Aggregation,DISTINCT, andORDER BY/SKIP/LIMITon a write-sideWITHare deferred. -
… RETURN <created>— a trailingRETURNof created entities:CREATE (n:Person {name: "Alice"}) RETURN n,MATCH (a), (b) CREATE (a)-[e:KNOWS]->(b) RETURN e(one row per matched pair). Answered as the read path's Cypher-JSON tabular envelope; each entity serializes as its identifier string. v1 surface: bare variables (optionally aliased) naming a freshCREATEnode or relationship variable. Deferred: expressions,RETURNmodifiers,MATCH-bound variables, andRETURNwithMERGE(the matched branch's node isn't a created entity).
let committed = fluree.transact_cypher(ledger, cypher).await?;
// or, when the statement ends in RETURN:
let (committed, rows) = fluree.transact_cypher_returning(ledger, cypher, None).await?;
The transact API also accepts a semicolon-separated script of write
statements, executed sequentially with one commit per statement — later
statements see earlier ones' effects, and only the final statement may carry
a RETURN (cypher-shell autocommit semantics; a failure aborts the remainder
but keeps prior commits — use an explicit Bolt transaction for atomicity):
let committed = fluree
.transact_cypher(
ledger,
r#"CREATE (:Person {name: "Alice"});
CREATE (:Person {name: "Bob"});
MATCH (a:Person {name: "Alice"}), (b:Person {name: "Bob"})
CREATE (a)-[:KNOWS]->(b);"#,
)
.await?;
To bulk-load a CSV, use the CLI's fluree load — Fluree's
LOAD CSV analog. It reads the file client-side and streams it as batched
per-row upserts, one commit per batch. Each row binds as row inside
UNWIND $batch AS row … (--cypher), or the batch is injected as an update's
values clause (--jsonld):
fluree load people --from people.csv \
--cypher 'MERGE (n:Person {id: row.id}) SET n.name = row.name'
The same per-row upsert shape works directly on the transact API:
UNWIND $batch AS row MERGE (n:Person {id: row.id}) SET n.name = row.name, with
$batch a parameter array of row maps.
Writes default to LPG mode, where every relationship reifies (carries an annotation identity). See Edge annotations for the RDF vs. LPG modes and the retraction semantics that follow from them.
How Cypher maps to RDF
You don't need this to write Cypher — it's here for when you want to see how a statement lands in Fluree's store, or cross-reference data written through JSON-LD or SPARQL.
| Cypher concept | Fluree representation |
|---|---|
Node (n:Label) | Subject with rdf:type <Label>. |
Multiple labels (n:L1:L2) | Multiple rdf:type triples about n. |
Node properties (n {key: val}) | Ordinary triples about n. |
Relationship (a)-[:TYPE]->(b) | Base triple (a, <TYPE>, b). |
Relationship with var (a)-[r:TYPE]->(b) | Base triple + an f:reifies* reifier bundle; r binds the reifier subject. |
Relationship properties [:T {p:v}] | Reifier bundle plus an annotation-body triple (_:r, p, v). |
| Parallel relationships | Multiple reifier subjects attached to the same base edge. |
Relationship lowering — three shapes, three behaviors
How a relationship lowers depends on whether you bind it and whether you filter on its properties — which in turn decides the cardinality and whether plain (un-annotated) base edges are visible.
| Pattern | Lowers to | Cardinality | Sees plain RDF? |
|---|---|---|---|
(a)-[:T]->(b) | Plain triple (a, <T>, b) | Set | Yes |
(a)-[r:T]->(b), r value-only | Plain triple + OPTIONAL { EdgeAnnotation } + r = coalesce(annotation, MakeRel(a, T, b)) | Bag over annotations; one row for an unreified edge | Yes |
(a)-[r:T]->(b), r property-read | EdgeAnnotation { edge, annotation: ?r, body: [] } | Bag | No — only reifier-bundled edges |
(a)-[:T {p:v}]->(b) | EdgeAnnotation { edge, annotation: ?#__anon, body: [(?#__anon, p, v)] } | Bag | No |
A bound relationship variable is value-only when the statement never reads
its properties — no r.prop, properties(r), keys(r), or map projection
r{…} anywhere (a statement-wide scan decides this at lowering). Value-only
uses (RETURN r, type(r), startNode(r) / endNode(r), comparisons,
collect(r)) are satisfied by a relationship value synthesized from the base
triple, so plain (un-annotated) RDF edges match too; edges that do carry
reifier bundles still bind one row per annotation (parallel relationships stay
distinct).
Consequence. Only a property-reading relationship variable requires
reifier bundles. If your data was loaded via JSON-LD without @annotation
(or any other path that doesn't produce reifier bundles),
MATCH (a)-[r:T]->(b) RETURN r.since returns zero rows even though the base
triples exist — there is no annotation node to read since from:
-- value-only r: sees all base edges, plus per-annotation rows where reified
MATCH (a:Person)-[r:WORKS_FOR]->(o:Organization) RETURN a, type(r), o
-- property read on r: requires reifier bundles
MATCH (a:Person)-[r:WORKS_FOR]->(o:Organization) RETURN a, r.since, o
-- set semantics, sees all base edges
MATCH (a:Person)-[:WORKS_FOR]->(o:Organization) RETURN a, o
Names, and opting into IRIs
Cypher uses bare names like Person, WORKS_FOR, name. By
default they are just names — no IRI prefix is invented for them.
Internally they live under namespace code 0 (the empty prefix), so a
label written as Person reads back as Person on every surface:
labels(n), Bolt, and even JSON-LD/SPARQL queries against the same
ledger see the same bare (relative) name. A pure-Cypher user never
sees or configures a namespace.
To interoperate with RDF-style data (full IRIs), configure the
ledger's default @context — the same context that applies to JSON-LD
queries against that ledger:
@vocabsupplies the namespace prefix:Personthen resolves to<vocab>Person, matching data whose IRIs live under that vocab.- Full-term mappings (e.g.
"Person": "http://schema.org/Person") act as per-name overrides (they work with or without@vocab).
The mapping is case-preserving: WORKS_FOR becomes
<vocab>WORKS_FOR, not <vocab>worksFor. Put any case-normalizing
aliases in the context.
The placement rule for a name (without @vocab) is: no colon →
the whole name, verbatim. Backticked names containing /, #,
spaces, or @ (`a/b`, `my prop`, `user@host`) are
never split into namespaces — they round-trip intact. A backticked
name that does contain a colon is treated as an RDF identifier
(prefixed name or full IRI): `ex:code` or
`http://schema.org/name` registers its namespace and
interoperates with SPARQL/JSON-LD views of the same data.
Note the two modes address different data: bare names and
vocab-resolved IRIs are different identifiers. Adding @vocab to a
ledger whose data was written bare (or vice versa) changes what Cypher
statements match.
Identifiers and keywords
Keyword tokens (count, end, order, limit, all, …) are accepted
as binding names — both as aliases (RETURN n.name AS end,
WITH count(*) AS count, UNWIND xs AS end, YIELD col AS type) and
when referenced downstream as plain variables
(WITH count(*) AS count WHERE count > 5 RETURN count). This is a
deliberate leniency over strict openCypher, which reserves these words
and requires backticking (AS `count`) — backticked identifiers are
also accepted. The dedicated meanings win where a keyword is followed by
its delimiter: count(*), exists { … }, and all(x IN … ) still
parse as their constructs.
Differences from Neo4j
Fluree implements the openCypher 9 surface faithfully — the common clause, pattern, and expression set works as specified, and anything unsupported returns a clear error, never a silently wrong result. A few differences are worth knowing, and they fall into two kinds.
Divergent by design — inherent to running Cypher over an RDF store, and here to stay:
- Nodes are durable subjects, not opaque LPG nodes.
labels(n)arerdf:typeassertions; node identity is the subject's stored name (a plain name by default, a full IRI in@vocabmode — see Names, and opting into IRIs). - Relationships are edge annotations.
-[r:T]->reifies the base triple(s, p, o)into a reifier node (the edge identity) — the same RDF 1.2 model SPARQL exposes via the{| … |}annotation tail andrdf:reifies <<( s p o )>>triple terms. So an edge is a reifier over a triple, not a triple term stored as a value (triple terms are supported as the object ofrdf:reifies, not free-standing). See How Cypher maps to RDF and Edge annotations. id(n)/elementId(n)return the identity string, not an integer — RDF subjects have no integer element id. Over Bolt,xsd:decimalrenders as Float (Neo4j parity, precision loss); integer division yields decimals, so this shows on ordinarya / b.- No implicit per-statement transaction id. Immutability and time-travel
(
f:t, history queries) replace those semantics.
Deferred (fringe / on request) — rejected with a clear error until a use case pulls them in; each has a workaround:
- Bounded type-alternation var-length
-[:A|B*1..3]->— use the unbounded form-[:A|B*]->. - Spatial
point()/distance(), anddurationarithmetic (date + duration). - Chained property access
n.a.b(except temporal field chains likex.date.month) and mixing.*with named selectors in a map projection. ORDER BYover a list/map value, andneo4j://cluster routing (usebolt://direct).
Everything else — the full clause/pattern/expression surface, the write path, procedures, and Bolt driver support — works; when in doubt, try it and read the error, which names the unsupported form and its workaround.
See also
- Edge annotations (concept) — the storage primitive Cypher relationships sit on top of.
- Edge annotations (storage internals) — the
f:reifies*durable encoding. - SPARQL and JSON-LD Query — the parallel surfaces over the same IR.