Language Reference
Clauses
Read Clauses
MATCH ✅
Matches graph patterns against stored nodes and edges.
MATCH (n:User)
MATCH (a:User)-[:KNOWS]->(b:User)
MATCH (a)-[:LIKES*1..3]->(b) -- variable-length: 1 to 3 hops
MATCH (a)-[:LIKES*]->(b) -- variable-length: 1 to max (capped at 10)
MATCH (a)-[r:LIKES|RATED]->(b) -- multiple rel types (any of)
MATCH (a:User {email: $email}) -- inline property filter
MATCH p = (a)-[:KNOWS]->(b) -- path variable (currently no path functions)OPTIONAL MATCH ✅
Like MATCH but produces null for unmatched parts instead of dropping the row.
MATCH (u:User {id: $id})
OPTIONAL MATCH (u)-[:CREATED]->(post:Post)
RETURN u.name, post.titleWHERE ✅
Filters rows. Supports all comparison, logical, and string operators. Can include pattern predicates.
WHERE n.age > 18 AND n.active = true
WHERE n.email IS NOT NULL
WHERE n.name STARTS WITH 'A'
WHERE n.name ENDS WITH 'son'
WHERE n.bio CONTAINS 'engineer'
WHERE n.role IN ['admin', 'editor']
WHERE (a)-[:KNOWS]->(b) -- pattern predicate (single-hop, bound endpoints)
WHERE NOT (a)-[:BLOCKED]->(b) -- negated pattern predicate
WHERE n.score > 0 XOR n.featured = truePattern-predicate scope. A pattern predicate is planned exactly like EXISTS { MATCH <pattern> }, so every shape MATCH accepts works here: multi-hop chains with an unbound intermediate (WHERE (a)-[:R]->()-[:R]->(b)), inline edge property filters (WHERE (a)-[:R {weight: 5}]->(b), whether or not the relationship is named) and variable-length relationships, where a property map must hold on every hop.
RETURN ✅
Projects the result set.
RETURN n.name, n.age
RETURN DISTINCT n.city
RETURN n.name AS name, n.age AS age
RETURN * -- all bound variables
RETURN n { .name, .age } -- map projection
RETURN n { .name, tags: collect(t.name) } -- map projection with expressionWITH ✅
Passes results between query parts, enabling filtering and transformation mid-query.
MATCH (u:User)-[:PURCHASED]->(p:Product)
WITH u, count(p) AS purchases
WHERE purchases > 5
RETURN u.name, purchases
ORDER BY purchases DESCUNWIND ✅
Expands a list into individual rows.
UNWIND [1, 2, 3] AS x RETURN x
UNWIND $ids AS id MATCH (n {id: id}) RETURN nORDER BY ✅
Sorts the result. Supports multiple sort keys, ASC (default) and DESC.
RETURN n.name ORDER BY n.name
RETURN n.name, n.age ORDER BY n.age DESC, n.name ASCSKIP and LIMIT ✅
Pagination. Both accept integer expressions or parameters.
RETURN n SKIP 20 LIMIT 10
RETURN n SKIP $offset LIMIT $page_sizeWrite Clauses
CREATE ✅
Creates nodes or relationships.
CREATE (n:User {name: 'Alice', email: 'alice@example.com'})
CREATE (a)-[:KNOWS {since: 2024}]->(b)
CREATE (a:User {name: 'Bob'})-[:FOLLOWS]->(b:User {name: 'Carol'})MERGE ✅
Matches a pattern; creates it if it doesn't exist. Supports ON MATCH and ON CREATE actions.
MERGE (u:User {email: $email})
ON CREATE SET u.created = now(), u.login_count = 1
ON MATCH SET u.login_count = u.login_count + 1
RETURN uUPSERT MATCH 🔷
CoordiNode extension. Atomic match-or-create with separate ON MATCH and ON CREATE branches. Avoids the TOCTOU race condition in MERGE.
UPSERT MATCH (u:User {email: $email})
ON MATCH SET u.login_count = u.login_count + 1
ON CREATE CREATE (u:User {email: $email, login_count: 1, created: now()})MERGE ALL 🔷
CoordiNode extension. Creates relationships between all matching src × tgt pairs (Cartesian product semantics).
MATCH (a:Tag {name: 'rust'}), (b:Article)
WHERE b.tags CONTAINS 'rust'
MERGE ALL (a)-[:TAGS]->(b)MERGE NODES ✅ 🔷
CoordiNode extension. Collapses two matched nodes into one in a single MVCC transaction — property merge, edge re-pointing, and source deletion together. Replaces Neo4j's apoc.refactor.mergeNodes() with a native, cluster-safe clause. See Cypher Extensions — MERGE NODES for the full strategy matrix.
MATCH (a:User {email: 'alice@example.com'}),
(b:User {email: 'alice@example.org'})
MERGE NODES (a, b) INTO a
ON CONFLICT COALESCE
TRANSFER EDGES FROM b TO a
ON DUPLICATE MERGE PROPERTIESCLONE NODE ✅ 🔷
CoordiNode extension. Deep-copies a matched node into a freshly-allocated node in a single MVCC transaction, optionally cloning its edges. Replaces Neo4j's apoc.refactor.cloneNodes() with a native, cluster-safe clause. See Cypher Extensions — CLONE NODE for the full modifier matrix.
MATCH (a:Template {name: 'default'})
CLONE NODE a AS b
WITH EDGES
SET b.name = 'Clone of ' + a.nameREDIRECT EDGES ✅ 🔷
CoordiNode extension. Moves a bound node's edges onto another bound node in a single MVCC transaction, via posting-list merge operators — a graph-refactoring building block. Optional type and direction filters. See Cypher Extensions — REDIRECT EDGES for details.
MATCH (old:Account {id: $deprecated}), (new:Account {id: $canonical})
REDIRECT EDGES FROM old TO new
WHERE type(r) IN ['KNOWS', 'FOLLOWS']
DIRECTION OUTGOINGDELETE / DETACH DELETE ✅
Deletes nodes or relationships. DETACH DELETE removes all connected edges before deleting the node.
MATCH (n:Temp) DELETE n -- fails if node has edges
MATCH (n:Temp) DETACH DELETE n -- removes edges first
MATCH (a)-[r:KNOWS]->(b) DELETE r -- delete relationship onlyDETACH DOCUMENT ✅ 🔷
CoordiNode extension. Promotes a nested DOCUMENT property to a separate graph node + edge atomically. See Cypher Extensions — DETACH DOCUMENT for semantics, TRANSFER EDGES, default edge-type derivation, and error cases.
MATCH (n:User)
DETACH DOCUMENT n.address AS (a:Address)-[:HAS_ADDRESS]->(n)ATTACH DOCUMENT ✅ 🔷
CoordiNode extension. The inverse of DETACH DOCUMENT: demote a graph node back into a nested DOCUMENT property on another node. See Cypher Extensions — ATTACH DOCUMENT for TRANSFER EDGES, ON CONFLICT REPLACE, ON REMAINING FAIL, and error cases.
ATTACH (a:Address)-[:HAS_ADDRESS]->(u:User) INTO u.addressSET ✅
Sets properties and labels.
SET n.name = 'Alice'
SET n.config.network.ssid = 'home' -- nested document property
SET n += {name: 'Alice', age: 30} -- merge (add/update, no delete)
SET n = {name: 'Alice'} -- replace all properties
SET n:Premium -- add label
SET n.updated = now() ON VIOLATION SKIP -- skip nodes that violate schemaREMOVE ✅
Removes properties and labels.
REMOVE n.temp_flag
REMOVE n.config.old_key -- nested document key
REMOVE n:Pending -- remove labelDDL Clauses
CREATE NODE TYPE / CREATE EDGE TYPE ✅ 🔷
CoordiNode extension. Declares a label or edge-type schema upfront so the engine knows the property contract (and, for TEMPORAL types, the bitemporal storage layout). Both forms accept an optional TEMPORAL modifier and an optional WITH (...) property declaration block.
-- Non-temporal: point-in-time label, current MVCC-only semantics.
CREATE NODE TYPE User WITH (name: STRING NOT NULL, email: STRING)
-- Bitemporal: per-version storage keyed on valid_from, full AS OF
-- queries on both valid-time and system-time axes.
CREATE NODE TYPE Person TEMPORAL WITH (
name: STRING NOT NULL,
valid_from: TIMESTAMP NOT NULL,
valid_to: TIMESTAMP
)
-- Edge counterpart: one stored version per valid_from.
CREATE EDGE TYPE WORKS_AT TEMPORAL WITH (
role: STRING,
valid_from: TIMESTAMP NOT NULL,
valid_to: TIMESTAMP
)
-- Edge identity: the declared property that, with the two endpoints,
-- identifies one edge, so one endpoint pair can hold several.
CREATE EDGE TYPE KNOWS WITH (
context: STRING NOT NULL,
intensity: FLOAT
) DISCRIMINATED BY (context)
-- Identity and temporality are independent: a temporal type may name its
-- own discriminator, before or after WITH (...).
CREATE EDGE TYPE ASSERTS TEMPORAL DISCRIMINATED BY (assertion_key) WITH (
assertion_key: STRING NOT NULL
)TEMPORALopts the type into bitemporal semantics. OnCREATE, every instance must supplyvalid_from. The engine auto-populates__ingestion_ts__(HLC commit-ts, user-readable, immutable). See Temporal Edges for the full bitemporal model.DISCRIMINATED BY (col)names the property that, with the endpoints, identifies an edge. The column must be declared inWITH (...)asNOT NULLwith typeINT,FLOAT,BOOL,TIMESTAMP,STRINGorBLOB; anything else is refused when the type is created, and nothing is written. Two edges are the same edge exactly when their discriminator values are equal:-0.0and0.0are one value,NaNis not accepted, and aBLOBis compared by its whole value.- A
TEMPORALtype that names no discriminator is identified byvalid_from(aTIMESTAMP, or anINTof Unix microseconds). The resolved discriminator is returned by the statement asdiscriminatorand by the schema API. - What identifies an edge is fixed when the type is created; migration is "create new type, copy data, drop old".
- Reading and writing edges of a type that names its own discriminator is not supported yet: a statement naming such a type is refused by name, and a statement ranging over every type (
MATCH (a)-[r]->(b)) passes it over. - Non-temporal and temporal labels coexist in the same database out of the box — non-temporal pays zero per-node storage overhead.
- The
TEMPORALflag is immutable: re-CREATEof an existing label/type is rejected. Migration path is "create new type, copy data, drop old". - Reserved engine-managed property names rejected at DDL time:
__ingestion_ts__,__src__,__tgt__,__type__.valid_fromandvalid_toare user-supplied bitemporal axis fields (declarable, not reserved). - Property types in
WITH (...):STRING,INT,FLOAT,BOOL,TIMESTAMP,BLOB,MAP,GEO,BINARY,DOCUMENT. ModifierNOT NULL.
Temporal-node mutation semantics (all routes through the close-current + open-new dance, see Temporal Edges → Writing temporal nodes):
SET n.<flat>/SET n.<nested.path>/SET n += {...}/SET n = {...}/SET doc_push(...)/doc_pull/doc_add_to_set/doc_inc— close+open. Multi-item SET in one clause = one new version.SET n.valid_to = …— in-place close, no new version.SET n.valid_from = …— rejected.REMOVE n.<flat>/REMOVE n.<nested.path>/REMOVE n:Label— close+open with the property/label dropped.REMOVE n.valid_from/REMOVE n.valid_to— rejected.DELETE n— positive bitemporal tombstone (__deleted__: trueatvalid_from = NOW); current open version closed; history preserved.DETACH DOCUMENT n.<path> AS (t:Target)-[:E]->(n)— close+open on the source, property promoted to a new node (temporal-aware CREATE).TRANSFER EDGESon a temporal source: rejected.ATTACH (s:Source)-[:E]->(t:Temp) INTO t.<path>— close+open on the target. Temporal source on ATTACH: rejected.- Reads (
MATCH (n:Temp), index lookups, traversalMATCH (a)-[:E]->(b:Temp), pattern predicateWHERE (a)-[:E]->(:Temp)) see each node's state valid at the statement'sNOW: at most one row per node, none for a deleted, ended or not-yet-valid node.WHERE temporal_active_at(n, t)reads the state attinstead. See Temporal Edges → Reading temporal nodes. - B-tree indexes on temporal labels hold an entry per version; a
UNIQUEindex there reserves a value for a node once any of its versions held it, so it is not uniqueness at each instant of valid time. - Not yet supported on temporal labels:
MERGE,UPSERT ON CREATE, enumerating every version in oneMATCH, theAS OF VALID_TIMEclause.
CREATE INDEX / DROP INDEX ✅
B-tree index on a single property. Optional: UNIQUE, SPARSE (skip nulls), WHERE predicate (partial index).
CREATE INDEX user_email ON :User(email)
CREATE UNIQUE INDEX user_handle ON :User(handle)
CREATE SPARSE INDEX article_deleted ON :Article(deleted_at)
CREATE INDEX active_users ON :User(name) WHERE n.active = true
DROP INDEX user_email
DROP CONSTRAINT user_handle -- a unique index goes with its constraintEntries commit with the data. An index entry is written by the same transaction as the node it indexes: a failed or rolled-back statement leaves none, a crash keeps both or neither, and every replica holds the same entries. A member that takes over as leader enforces the same unique indexes.
Unique. A write that gives a unique index's value a second node fails with
unique constraint violated, naming the node that holds the value (gRPCALREADY_EXISTSwith reasonDUPLICATE_KEY; SQLSTATE23505over the PostgreSQL wire). Two concurrent writers of one value: exactly one commits. A node without the property holds NULL, and NULL is a value, so a unique index allows one such node; declare itSPARSEto skip them.A unique index is a constraint.
CREATE UNIQUE INDEX name ...creates the uniqueness constraintname, owning the index of the same name, asCREATE CONSTRAINT name ... IS UNIQUEdoes, keeping theSPARSEand maintenance options the statement states. It is listed with the other constraints, aCREATE CONSTRAINT ... IF NOT EXISTSover the same property finds it instead of building a second index,DROP INDEXrefuses it andDROP CONSTRAINTremoves both. A partial unique index (WHERE) is the uniqueness among the nodes its predicate admits: the constraint carries the predicate as its scope (listed in thescopecolumn, gRPCConstraint.scope), only nodes in scope are compared, and a uniqueness over every node of the label is a different constraint. The predicate is one ofn.prop = <string | integer | boolean literal>orn.prop IS NOT NULL; any otherWHEREis refused, never widened to every node.Building.
CREATE INDEXpublishes the index and admits its build in one commit, then waits for the build, which indexes the nodes already stored, and returns once the index is ready. The build runs on the engine, not on the statement: a client that times out or disconnects leaves it running, a restart resumes it, and after a change of leader the new leader resumes it. Writes made meanwhile maintain the index themselves. The build first waits for transactions opened before the index existed to end, then indexes the stored nodes a page at a time; a page that read a node a writer changed is read again.Unique values while building. A unique index protects its values from the commit that publishes it, before the build reaches the stored nodes. A write taking a value that a stored node holds fails with
unique constraint violated, naming that node, even though the build has not indexed it yet. To tell, the commit reads the stored nodes the build has not reached; when that would read more thanindex_build_unique_read_limitnodes, the write fails withunique index ... is still being builtinstead (gRPCUNAVAILABLEwith reasonUNIQUENESS_UNRESOLVED, SQLSTATE53000): the value may well be free, and the same write succeeds once the build is done. The limit bounds what one commit reads per index, however many values it takes. Which nodes the build has reached is known to the member running it; after a change of leader the read starts from the first node again until the resumed build covers them, so on a large label writes of new values can be refused as unresolved for that while. A transaction that wrote to the label before a partial unique index existed is refused at its commit and retried under the new index.An entry found wrong. A lookup checks every node its index names against that node's record. An entry naming a node that does not hold the value shows the index wrong: the lookup answers from the records of the label instead, exactly as the index should have, and a check of the index starts in the background and repairs it. The answer from the records runs within the statement's memory limit, deadline and cancellation like every other read, so it is the exact answer or one of those stops, never a partial result. Every operator a statement can place over a lookup counts its work and memory; one that cannot (an extension operator) is refused before the records are read, with gRPC
UNAVAILABLE, reasonINDEX_UNRESOLVED(metadatalabel,property,operator) and retry advice: the same statement succeeds once the check has repaired the index. A write to the node the repair is fixing, committing while the repair does, is refused as a write conflict and succeeds when retried.Build failure and cancellation.
CREATE UNIQUE INDEXover data that already has a duplicate fails and leaves no index. A build of a new index that fails or is cancelled withdraws the index (and the constraint owning it); a rebuild of an existing one keeps it, marked failed.DROP INDEXduring a build ends the build without writing more entries. How many builds run at once and how long a build waits for older transactions are server settings (index_build_max_running,index_build_older_transactions_wait_secs).Builds are operations. A build belongs to the engine, not to the statement that admitted it.
CREATE INDEX,CREATE UNIQUE INDEXandCREATE CONSTRAINTreturn itsoperationand wait for it up toindex_build_statement_wait_secs(60 by default). A statement that outwaits it returns with the build still running: the indexBUILDING, a uniqueness or key constraintVALIDATINGand already enforced on every write. The build goes on, and its outcome makes the constraintACTIVEor withdraws it.CREATE CONSTRAINT ... IF NOT EXISTSwhile the constraint validates (as after a lost reply) returns the same constraint and operation, admitting no second build. The operation is inspected, awaited and cancelled with procedures:cypherCALL db.indexBuilds() // every build CALL db.indexBuild(operation, waitMs) // one build, after waiting up to waitMs CALL db.cancelIndexBuild(operation) // cancel; an outcome already reached staysEach build row has
operation,index,label,state(ACCEPTED,RUNNING,PUBLISHED,FAILED,CANCELLED),phase(AWAITING_SEAT,AWAITING_OLDER_TRANSACTIONS,INDEXINGwhile running on this member),indexedandfailure. Waiting cancels nothing. The embedded API has the same calls (Database::index_build_status,Database::index_build,Database::cancel_index_build), and gRPC hasSchemaService.ListIndexBuilds,GetIndexBuildandCancelIndexBuild; aCreateConstraintnames its own wait inwait.Repairing duplicates while building. Without a clause, a unique build over stored duplicates fails.
ON DUPLICATE RENAME plets the build end each duplicate it meets by appending_and eight random base-36 characters to the string propertypof the holder it reaches second:cypherCREATE UNIQUE INDEX user_email ON :User(email) ON DUPLICATE RENAME email CREATE CONSTRAINT user_email FOR (u:User) REQUIRE u.email IS UNIQUE ON DUPLICATE RENAME u.emailpmust be one of the properties the uniqueness covers, and a string: a property declared with another type, a computed one, a temporal label (its versions keep every value they held) and a COLUMNAR table are refused before anything is created. Each repair is an ordinary write of that node (every index, constraint and change stream sees it), conditioned on the node still holding the old value, committed on its own and visible at once. A suffix another node holds is redrawn. A repair that committed stays whatever the build's outcome: cancelling or failing the build undoes none. A duplicate value that is not a string fails the build. The clause belongs to the build: once the index is ready, a duplicate write is refused as always.CALL db.indexBuildRepairs(operation)lists the repairs (node, property,oldValue,newValue), anddb.indexBuildreportsrenamePropertyand how many wererepaired; the embedded API hasDatabase::index_build_repairsandConstraintDeclaration::on_duplicate_rename, gRPCCreateConstraint.on_duplicate_renameandListIndexBuildRepairs.Member-local indexes. A full-text index and a vector index are built by every member from the data it holds; their definitions replicate, their readiness is each member's own. A full-text index's backfill runs on the same engine builds as the others.
Lists. A list value is indexed by each element, so a unique index keeps an element to one node. An equality on the property still means the whole value:
n.tags = 'x'does not match a node whosetagsis['x', 'y'].Values with no key. A map, a vector or
NaNis not indexed; an equality on such a value is answered by scanning the label. An equality withNULLmatches nothing, as everywhere in Cypher.
Maintenance profile. How a write carries its index changes to the log that replication and crash recovery read. Both profiles hold the same entries and answer the same queries; they differ in what the log records.
| Profile | The log records | Suits |
|---|---|---|
RESOLVED (default) | the index entries themselves | members that should apply entries without reading the record |
DERIVED | the index change and its exact inputs; every member derives the entries when it applies the write | smaller log entries, wide or many indexes |
CREATE INDEX user_email ON :User(email) OPTIONS { maintenance: 'derived' }
ALTER INDEX user_email SET MAINTENANCE RESOLVED -- or DERIVED, or INHERIT
ALTER NAMESPACE SET INDEX MAINTENANCE DERIVED -- default for new indexes- An index without
OPTIONS { maintenance: ... }takes the namespace default when it is created and keeps it: changing the default later does not move existing indexes.ALTER INDEX ... SET MAINTENANCE INHERITbinds an index to the current default. ALTER INDEXchanges the profile under a new maintenance epoch without rewriting any entry. A transaction that staged index changes under the previous epoch is refused at commit as a write conflict (retryable, like any conflict), and succeeds when retried; a single statement outside a transaction is retried by the engine itself. The same holds for a write that meets a build publishing, a rebuild or a drop of an index it writes. Only a built B-tree index can be altered.CREATE INDEXandALTER INDEXreturnmaintenance(RESOLVEDorDERIVED),maintenance_source(OVERRIDE, orNAMESPACE@<revision>for an index bound to the namespace default) andmaintenance_epoch;ALTER INDEXalso returnsprevious_epoch.ALTER NAMESPACEreturnsindex_maintenance_defaultand itsrevision.- A
DERIVEDindex enforces uniqueness, reads its own writes inside a transaction and rolls back with it exactly as aRESOLVEDone does. Its entries are not change events: a change stream carries the node writes they follow. - A transaction whose
DERIVEDindex changes exceed 4,194,304 entries is refused at commit; nothing is written. - An unknown option, or a profile other than
resolved/derived, is refused.
REINDEX and index checks ✅
A B-tree index is derived from the records it covers. The engine checks it against them and repairs it; REINDEX rebuilds it outright.
REINDEX user_email -- rebuild into a fresh generation
REINDEX user_email ON :User -- the same, refused if not on :User
CALL db.checkIndex('user_email') -- check against the records, by name
CALL db.checkIndex(12) -- ... or by index id
CALL db.indexChecks() -- every generation's integrity
CALL db.indexCheck(operation, waitMs) -- one check, after waiting up to waitMs
CALL db.cancelIndexCheck(operation) -- stop a check; its repairs stay
CALL db.reindex('user_email', waitMs) -- REINDEX as a procedure, by name or id- What a check does. It reads every record of the label for the entries its values give it (each list element, each version of a temporal node, the sparse and partial rules), then every entry for a record holding its value. Each disagreement is repaired while the record and the entry are still as the check read them and the index still serves from the checked generation, so a write, a drop or a rebuild arriving meanwhile wins. A pass that finds nothing marks the generation
VERIFIED. More disagreements thanindex_check_max_repairs, or a third pass still finding some, rebuild the index instead (rebuiltIntonames the new generation). - What starts one. A read or a write that meets an entry its record does not back marks the generation
SUSPECTand starts a check at once; every B-tree index is also checked everyindex_check_interval_secs. A missing entry gives no sign on any read, so only a check finds it. The member that takes writes runs checks. - While an index is suspect its lookups answer from the records, so a query never returns a wrong or short result for a damaged entry, and a value taken in a unique index is proved free from the records when the write commits: the real holder is reported as the duplicate, and a proof that would read more than
index_build_unique_read_limitrecords is refused as unresolved, to be retried. An entry naming a node that does not hold its value never makes the value free nor counts as a duplicate. - Each member's own copy. Every member keeps its own copy of an index, and a finding is about that copy. A member uses a copy as evidence only after it has durably recorded that the copy is in use; it does this once per copy per run, and lookups after that write nothing. A clean stop clears these records. A member that stops without one (a crash, a kill, a power loss) may have found damage it never recorded, so when it opens again every copy it was using answers from the records until a check verifies it. Copies it was not using serve at once. A member that takes no writes checks its own copy against its own records and writes nothing shared. A copy that agrees serves again. A copy damaged on that member alone is replaced from a peer's copy of the same generation. If the peer sends the proof it keeps of its own copy, the replacement serves at once; otherwise it is checked before it serves. A standalone member answers from its records until its copy is checked or rebuilt. These records are written even when the disk is below its free-space reserve. A member that cannot write one keeps the copy out of use and answers from its records.
CALL db.indexCopies()shows the state of each copy on the member that runs it. - Copies a member receives. A checksum proves the bytes of a snapshot and its log position proves the entries applied, but neither proves an index. A member keeps the proof of each copy that a check or a completed build verified. A snapshot carries those proofs for the copies it holds, minus any copy the sender found wrong, including after the snapshot was taken. The receiver uses a proved copy at once and checks every other copy, an empty index's too, before using it. A copy of the index partition taken from a peer, and a restore from a backup, carry no proofs, so every copy they bring is checked first.
- Records breaking a unique index. Two records holding one value cannot be represented by any entry: a check reports them (
conflicts), changes neither, and the generation staysSUSPECTuntil the records are fixed. REINDEXreturns likeCREATE INDEX:state(READY, orBUILDINGwhen the statement's wait ran out),operation(the build, whichdb.indexBuildinspects) andnodes_indexed. Writers maintain the new generation at once; a constraint the index enforces stays enforced.db.indexChecks()anddb.indexCheckreturnoperation(the generation checked),indexId,integrity(UNCHECKED,SUSPECT,VERIFIED),state,phase(RECORDS,ENTRIES),passes,checked,mismatches,repaired,conflicts,rebuiltIntoandfailure. A string argument names an index, an integer is its id.- Over gRPC the same operations are
SchemaService.CheckIndex,ListIndexChecks,GetIndexCheck,CancelIndexCheckandReindex, selecting an index byindex_idorindex_name. A missing index or check answersNOT_FOUND; an index that is not a ready B-tree index answersFAILED_PRECONDITION; a member that takes no writes refusesCheckIndexas not the leader.
CREATE CONSTRAINT / DROP CONSTRAINT ✅
Named constraints on the nodes of a label.
CREATE CONSTRAINT user_email FOR (u:User) REQUIRE u.email IS UNIQUE
CREATE CONSTRAINT FOR (u:User) REQUIRE u.email IS NOT NULL
CREATE CONSTRAINT person_key FOR (p:Person) REQUIRE (p.first, p.last) IS NODE KEY
CREATE CONSTRAINT item_qty FOR (i:Item) REQUIRE i.qty IS :: INTEGER
CREATE CONSTRAINT item_name IF NOT EXISTS FOR (i:Item) REQUIRE i.name IS TYPED STRING
DROP CONSTRAINT user_email
DROP CONSTRAINT user_email IF EXISTS| Requirement | Properties | A node breaks it when |
|---|---|---|
IS UNIQUE | one or more | another node of the label holds the same values; a node missing any of them is not constrained |
IS NOT NULL | one | the property is missing or null |
IS NODE KEY | one or more | any property is missing or null, or another node holds the same values |
IS :: <type> / IS TYPED <type> | one | the property holds a value of another type; a missing property is allowed |
Type names: INTEGER / INT / BIGINT, FLOAT, STRING, BOOLEAN, TIMESTAMP, BLOB, BINARY, POINT / GEO, MAP, DOCUMENT. A value must have exactly that type: a float is not an INTEGER.
- Every write path.
CREATE,MERGE,SET,REMOVE,SET n = {...}, nested-pathSET n.a.b = ...,MERGE NODES, document operations, SQL and the gRPC write services are held to the same constraints, in every schema mode (STRICT,VALIDATED,FLEXIBLE) and on properties the schema never declared. Every version of a temporal node is held to them. - Judged at commit. A node is checked as the transaction leaves it, not at each step of building it:
CREATE (u:User) SET u.email = $epasses aNOT NULLonemail, and so does an interactive transaction that creates the node in one statement and sets the property in a later one. A node still breaking a constraint when the transaction commits fails the commit, and nothing of the transaction is written. - Violations. A missing, null or mistyped value fails the statement with
constraint ... violated, naming the constraint, the label, the property and the node; nothing of the statement is written (gRPCFAILED_PRECONDITIONwith reasonCONSTRAINT_VIOLATIONand metadataconstraint,kind,label,property,element_id; SQLSTATE23502, or23514for a type, over the PostgreSQL wire). A second holder of a unique or key value fails as a unique index does:unique constraint violated, gRPCDUPLICATE_KEY, SQLSTATE23505. - Creating over stored data.
CREATE CONSTRAINTchecks every stored node of the label before it returns. A node that breaks it fails the statement, naming that node, and nothing is created. A transaction that wrote a node of the label before the constraint existed and commits after it is refused at commit; retried, it runs under the constraint. - Index. A
UNIQUEorNODE KEYconstraint owns a unique index with the constraint's name. It answers lookups like any index;DROP INDEXrefuses it,DROP CONSTRAINTremoves it together with the constraint. - Validating. A
UNIQUEorNODE KEYconstraint is published as validating, and enforced on every write from then on, while its index is built over the stored nodes; it becomes active when the build finishes. Stored duplicates fail the statement and remove both. A build cut short (the server stopped mid-way) is finished when the database next opens, on the leader in a cluster: the constraint becomes active once its index holds every stored node, or, if the stored data breaks it, it is removed with its index and the event is logged. While a validation is in progress,CREATE CONSTRAINT ... IF NOT EXISTSnaming it fails saying the validation did not finish. While a presence or type constraint is created, the commit checks every stored node of the label with writes to that label held back for the duration of the scan. - Names. Constraint and index names share one namespace across all labels. A constraint created without a name is named
<Label>_<properties>_<unique|not_null|node_key|type>.IF NOT EXISTSmakes the statement a no-op (createdisfalse) when a constraint of that name, or one with the same requirement on the same properties of the label, exists.DROP CONSTRAINT ... IF EXISTSreturnsdropped: falsefor a missing constraint; without it, a missing one fails. - Results.
CREATE CONSTRAINTreturnsconstraint,label,properties,kindandcreated, plusnodes_indexedfor a unique or key constraint.DROP CONSTRAINTreturns the same columns withdropped. - Fits the definition. A constraint that could never hold under the label's definition is refused: a type other than the property's declared type, a property that is computed, or a presence requirement on a property a
STRICTlabel does not declare. Changing a definition later (its mode, or its properties through the schema API) is refused the same way while a constraint it would break exists. - Refusals. A name already taken fails with gRPC
ALREADY_EXISTS, reasonCATALOG_OBJECT_EXISTS(SQLSTATE42710); a missing constraint withNOT_FOUND, reasonCATALOG_OBJECT_NOT_FOUND(SQLSTATE42704); a constraint the definition contradicts, or one still validating, withFAILED_PRECONDITION, reasonCATALOG_CHANGE_REFUSED(SQLSTATE55000). The metadata of the first two carriesobjectandname. - Schema API.
coordinode.v2.graph.SchemaServicecreates, drops and lists the same constraints (CreateConstraint,DropConstraint,ListConstraints, or/v2/graph/schema/constraintsover REST); each one reports its state and the index it owns. A label definition there carries no uniqueness: a unique property is a constraint. - A
COLUMNARtable cannot take a constraint: its rows are written outside the transaction a constraint is enforced in. A table that has a constraint cannot be dropped until its constraints are.
CREATE VECTOR INDEX / DROP VECTOR INDEX ✅
HNSW approximate nearest-neighbor index.
CREATE VECTOR INDEX product_emb ON :Product(embedding)
OPTIONS { m: 16, ef_construction: 200, metric: "cosine", dimensions: 384 }
DROP VECTOR INDEX product_embSupported metrics: cosine (default), euclidean, dot.
CREATE TEXT INDEX / DROP TEXT INDEX ✅
Full-text BM25 index using tantivy. Single-property or multi-property.
-- Simple syntax
CREATE TEXT INDEX doc_body ON :Document(body) LANGUAGE "english"
-- Multi-property with per-field analyzers
CREATE TEXT INDEX article_idx ON :Article {
title: { analyzer: "english" },
body: { analyzer: "auto_detect" },
summary: { analyzer: "english" }
} DEFAULT LANGUAGE "english" LANGUAGE OVERRIDE "lang_field"
DROP TEXT INDEX doc_bodySupported language values: english, russian, french, german, spanish, chinese_jieba, japanese_lindera, none, and 30+ more.
CREATE ENCRYPTED INDEX / DROP ENCRYPTED INDEX ✅
Searchable symmetric encryption (SSE) index. Clients query via HMAC tokens without exposing plaintext.
CREATE ENCRYPTED INDEX patient_ssn ON :Patient(ssn)
DROP ENCRYPTED INDEX patient_ssnCREATE / DROP / SHOW / ALTER TRIGGER ✅ 🔷
CoordiNode extension. First-class trigger DDL — definitions live in the schema partition, replicate via Raft, and survive backups. Replaces Neo4j APOC triggers (which break in clusters) with cluster-safe native semantics.
-- Audit log every User mutation, retry transient errors, dead-letter on exhaustion
CREATE TRIGGER audit
ON :User CREATE | UPDATE | DELETE
AFTER COMMIT
EXECUTE
CREATE (e:AuditEntry {action: $event, ts: datetime()})
ON ERROR RETRY 3 WITH BACKOFF 1000
-- Validation trigger — aborts the originating transaction if it errors
CREATE TRIGGER validate_email
ON :User CREATE | UPDATE
BEFORE COMMIT
EXECUTE
MATCH (u:User {id: $after.id}) SET u.valid = true
ON ERROR PROPAGATE
CASCADE_LIMIT 3
SHOW TRIGGERS
ALTER TRIGGER audit DISABLE
ALTER TRIGGER audit ENABLE
ALTER TRIGGER audit SET EXECUTE CREATE (e:NewAuditEntry)
ALTER TRIGGER audit SET ON ERROR DEAD_LETTER
DROP TRIGGER audit| Clause / option | Effect |
|---|---|
ON :Label | Fire on node mutations of the given label |
ON [:EdgeType] | Fire on edge mutations of the given type |
CREATE | UPDATE | DELETE | Event filter — any subset, joined by | |
BEFORE COMMIT | Synchronous on Raft leader; failure can abort the transaction |
AFTER COMMIT | Asynchronous; dispatched on the Raft leader from a durable queue after the commit |
EXECUTE <clauses> | Cypher body (single or multi-clause); re-parsed on firing |
CASCADE_LIMIT n | Per-trigger override of L1 cascade depth (default 10) |
CASCADE_FANOUT n | Per-trigger override of L2 unique-trigger fanout (default 100) |
MAXDEPTH n | Deprecated alias for CASCADE_LIMIT n |
ON ERROR PROPAGATE | Error aborts the transaction (BEFORE) or dead-letters (AFTER) |
ON ERROR RETRY n [WITH BACKOFF ms] | Durable retry queue, exponential backoff |
ON ERROR DEAD_LETTER | First failure lands in trigger_failures partition |
Cycle protection layers:
- L1 cascade depth — cumulative across all triggers in one mutation root
- L2 unique-trigger fanout — per-trigger fire count within one root
- L3 static cycle detection at DDL time (planned)
- L4 auto-disable circuit breaker (planned)
ALTER LABEL ✅
Change the schema mode of a label.
ALTER LABEL Config SET SCHEMA FLEXIBLE
ALTER LABEL User SET SCHEMA STRICT
ALTER LABEL Event SET SCHEMA VALIDATED| Mode | Behavior |
|---|---|
STRICT | Only declared properties, all interned. Rejects unknown properties |
VALIDATED | Declared properties interned; unknown properties stored in overflow map |
FLEXIBLE | All properties accepted, all interned. No schema enforcement |
The new mode governs the nodes already stored, not only later writes. Every node whose primary (first) label is the altered one is checked against the new mode when the statement commits; if one breaks it, the statement fails, names the node and the property, and the label keeps its previous mode. Fix or move the offending nodes, then alter again. The same check applies to a schema published through the typed schema API (for example a property declared NOT NULL that a stored node lacks).
A write is validated against the schema it read. A transaction that wrote nodes of a label under one mode and commits after the label's schema changed is refused and can be retried under the new mode; a transaction that only read the label is unaffected.
Time-Travel Clause
AS OF TIMESTAMP 🔷
Read data as it was at a specific timestamp (microsecond precision). Retention window: 5 minutes by default (retention_window_secs).
MATCH (u:User {id: 42})
RETURN u.name, u.balance
AS OF TIMESTAMP '2026-03-15T10:00:00Z'The bound is inclusive: AS OF TIMESTAMP T sees every transaction with commit_ts <= T and nothing committed later. Every mutation of a transaction is applied at its single commit timestamp, so a read never observes a partially applied transaction. The commit_ts returned by an interactive transaction's commit receipt (embedded CommitReceipt, gRPC CommitTransactionResponse) is therefore the exact anchor for its own write: AS OF TIMESTAMP <commit_ts> sees it, <commit_ts> - 1 does not. An integer literal is the raw HLC value in microseconds; negative literals are rejected. A string literal must be an RFC 3339 timestamp with a zone offset ('2026-03-15T10:00:00Z', '2026-03-15T13:00:00+03:00'); anything else is refused.
Vector and full-text searches answer a time-travel query as the snapshot does: the nodes written after the timestamp are read from the snapshot instead of the current index. See Time-Travel Queries.
Query Advisor
EXPLAIN SUGGEST 🔷
Analyzes a query's logical plan and returns actionable optimization suggestions.
EXPLAIN SUGGEST
MATCH (u:User)
WHERE u.email = $email
RETURN uResponse — list of suggestions, each with:
| Field | Type | Description |
|---|---|---|
kind | string | CREATE INDEX, ADD DEPTH BOUND, ADD JOIN, CREATE VECTOR INDEX, ADD PRE-FILTER, BATCH REWRITE |
severity | string | CRITICAL, WARNING, INFO |
explanation | string | Human-readable description of the problem |
ddl | string? | Ready-to-run DDL statement (when applicable) |
rewritten_query | string? | Suggested query rewrite (when applicable) |
Built-in detectors:
| Detector | Severity | Trigger |
|---|---|---|
MissingIndex | CRITICAL | Full label scan on a filtered property with no index |
UnboundedTraversal | WARNING | Variable-length path *.. without upper bound |
CartesianProduct | WARNING | Two disconnected MATCH patterns (cross-join) |
KnnWithoutIndex | WARNING | ORDER BY vector_distance(...) LIMIT n without a vector index |
VectorWithoutPreFilter | INFO | Vector scan without graph narrowing — suggest pre-filtering with MATCH |
Example response output:
kind: CREATE INDEX
severity: CRITICAL
explanation: Full label scan on User.email — filtering User nodes by 'email'
without an index requires scanning all User nodes
ddl: CREATE INDEX user_email ON User(email)EXPLAIN (logical plan) ✅
Returns the logical plan for a query without executing it. Available via the CypherService.Explain RPC.
Counting the nodes of one label ✅
MATCH (n:Label) RETURN count(n) and count(*) over a single label, with no property filter, WHERE or grouping, are answered from a per-label node counter instead of scanning the label; EXPLAIN shows NodeCountFromCounter(:Label AS column). The counter changes in the same transaction as the nodes, so the answer is exact at the query's snapshot and includes the transaction's own uncommitted creates and deletes.
The query is counted by scan instead, with the same result, while any TEMPORAL node type exists (a temporal node keeps a row per version, which the counter counts), for a read at a past timestamp, and for a columnar label. Any other shape, such as count(n.prop) or a filtered match, counts by scan as before.
Scanning the nodes of a label ✅
Every database keeps a built-in label lookup: one entry per node and label it holds, written in the same transaction as the node. MATCH (n:Label) reads the label's members from it, so the scan's cost follows the number of nodes with the label, however much other data the database holds. With several labels (MATCH (n:A:B)), the label with the fewest nodes drives the scan and each node is checked for the others. Under a LIMIT (with or without SKIP and WHERE), the scan stops once it has the rows the LIMIT keeps, in node order, instead of reading the whole label. A transaction's label scans include its own uncommitted label changes, and server-side cursors page through the members. A columnar label is scanned from its own column store, as before.
A database written by an earlier version gets its lookup when it is opened, built in the background from the stored nodes. Until the lookup is ready, and for a read at a timestamp older than the moment it became ready, a label scan reads every node instead, with the same result. EXPLAIN ends with a Label scans: line saying which of the two a label scan does, and why, followed by a line per scan naming its driving label and the candidates it reads (that label's node count), for example :Wide:Narrow: driving :Narrow, ~2 candidates. While a query runs, each label scan logs at debug level under the coordinode::label_scan target the candidates it read, the rows it kept and the bytes of the records it read, or why it read every node instead.
CALL (Procedures) ✅
Calls a named procedure. Every procedure has a signature: typed arguments, some with defaults, and typed output columns. A call is checked against it before anything runs: an unknown procedure, a surplus or missing argument, an argument of the wrong type, or a YIELD of a column the procedure does not produce is refused. An integer passed where a float is declared is widened; null is accepted for any argument.
A CALL that is the whole query may omit YIELD (or write YIELD *) to return every output column. Inside a larger query the call runs once per incoming row, with its arguments evaluated against that row, and must name the columns it binds with YIELD; YIELD * is refused there. A yielded column can be renamed with AS and the yielded rows filtered with WHERE. A yielded name may not reuse a variable already in scope.
-- Standalone: every output column
CALL db.advisor.suggestions()
-- Inside a query: per-row arguments, renamed and filtered outputs
UNWIND ['0000000000000abc', '0000000000000def'] AS id
CALL db.advisor.dismiss(id) YIELD id AS fingerprint, dismissed WHERE dismissed
RETURN fingerprintBuilt-in procedures:
| Procedure | Mode | Outputs |
|---|---|---|
dbms.procedures() | DBMS | name, signature, description, mode |
dbms.functions() | DBMS | name, signature, category, description, aggregating |
db.advisor.suggestions() | READ | id, severity, kind, query, explanation, ddl, impact, sources |
db.advisor.queryStats() | READ | fingerprint, query, count, avgTime, p99Time, plan, shardsUsed, sources |
db.advisor.slowQueries(limit = 20, minTime = 100) | READ | query, p99Time, count, plan, sources |
db.advisor.dismiss(id) | DBMS | id, dismissed |
db.advisor.reset() | DBMS | status |
db.checkIndex(index) | SCHEMA | the check columns of REINDEX and index checks |
db.indexChecks() | READ | the check columns |
db.indexCheck(operation, waitMs = 0) | READ | the check columns |
db.cancelIndexCheck(operation) | SCHEMA | the check columns |
db.reindex(index, waitMs = 0) | SCHEMA | operation, state |
db.indexCopies() | READ | generation, indexId, builtin, installation, inUse, unfit, findingStored, checking: this member's own copy of each B-tree generation and of the built-in label lookup (builtin) |
dbms.procedures() lists every procedure the server answers, with its full signature, for example db.advisor.slowQueries(limit = 20 :: INTEGER, minTime = 100 :: INTEGER) :: (query :: STRING, ...). dbms.functions() lists every function an expression can call, the same table the evaluator dispatches from, so a listed function always exists.
The mode says what a procedure touches: READ reads graph data, WRITE and SCHEMA change it, DBMS reads or changes server state. A statement that calls a WRITE or SCHEMA procedure is a write, including for the causal-session write-concern check.
Over gRPC a refused call answers INVALID_ARGUMENT with reason UNKNOWN_PROCEDURE (metadata procedure) or PROCEDURE_CALL (metadata procedure, plus argument or column when one is at fault).
Operators
Comparison
| Operator | Description | Notes |
|---|---|---|
= | Equality | Three-valued: null = null → null |
<> | Inequality | |
<, <=, >, >= | Ordered comparison | Works on Int, Float, String, Bool, Timestamp |
Arithmetic
| Operator | Description |
|---|---|
+ | Addition (numbers), concatenation (strings) |
- | Subtraction |
* | Multiplication |
/ | Division. Integer ÷ integer = integer |
% | Modulo |
Logical
| Operator | Description |
|---|---|
AND | Logical and (three-valued) |
OR | Logical or (three-valued) |
NOT | Logical not |
XOR | Exclusive or |
String Predicates
| Operator | Example |
|---|---|
STARTS WITH | n.name STARTS WITH 'Al' |
ENDS WITH | n.name ENDS WITH 'son' |
CONTAINS | n.name CONTAINS 'ali' |
Case-sensitive. For case-insensitive search, use text_match with a text index.
List and Null Predicates
| Operator | Example |
|---|---|
IN | n.role IN ['admin', 'editor'] |
IS NULL | n.deleted_at IS NULL |
IS NOT NULL | n.email IS NOT NULL |
Data Types
| Type | Cypher literal | CoordiNode extension |
|---|---|---|
| Integer | 42, -7 | |
| Float | 3.14, -0.5e2 | |
| Boolean | true, false | |
| String | 'hello', "world" | |
| Null | null | |
| List | [1, 2, 3] | |
| Map | {key: value} | |
| Point (WGS84) | point({latitude: 0.0, longitude: 0.0}) | 🔷 |
| Timestamp | '2026-03-15T10:00:00Z' | Microsecond precision |
| Vector | [0.1, 0.2, ...] | 🔷 Up to 65536 dims |
| Blob | — | 🔷 Via BlobService |
Graph Patterns
Node Pattern
(variable:Label {prop: value})variable— optional. Binds the matched node for later use:Label— optional label filter. Multiple labels not yet supported{prop: value}— inline property equality filter
Relationship Pattern
-[variable:TYPE {prop: value}]->
<-[variable:TYPE]-
-[variable:TYPE|OTHER_TYPE]-
-[variable:TYPE*min..max]->- Direction:
->outgoing,<-incoming,-undirected - Multiple rel types:
-[:A|B|C]->— matches any of A, B, C - Variable-length:
*1..5— 1 to 5 hops.*— 1 to 10 (default max)
Pattern Predicates
Patterns in WHERE context evaluate to boolean:
WHERE (a)-[:KNOWS]->(b) -- true if at least one path exists
WHERE NOT (a)-[:BLOCKED]->(b) -- true if no path existsCASE WHEN ✅
Simple CASE (equality test) and searched CASE (boolean conditions):
-- Simple CASE
RETURN CASE n.role
WHEN 'admin' THEN 'Administrator'
WHEN 'editor' THEN 'Editor'
ELSE 'User'
END AS display_role
-- Searched CASE
RETURN CASE
WHEN n.score >= 90 THEN 'A'
WHEN n.score >= 80 THEN 'B'
ELSE 'C'
END AS gradeOptimizer Hints
Per-query optimizer hints in comment syntax:
/*+ vector_consistency('snapshot') */
MATCH (n:Product)
WHERE vector_distance(n.embedding, $q) < 0.3
RETURN n| Hint | Values | Default |
|---|---|---|
vector_consistency | 'current', 'snapshot', 'exact' | follows read_consistency |
read_consistency | 'current', 'snapshot', 'exact' | 'current' single-modality, auto-promoted to 'snapshot' for cross-modality |
vector_build_wait | a whole number with ms, s or m, e.g. '5s' | the session's SET vector_build_wait, else the server's vector_build_wait_ms (30 s) |
A hint key the server does not know is ignored, so a query stays portable; a known key with a value it cannot take is refused with a parse error rather than silently dropped.
vector_build_wait bounds how long the query waits for a vector index still being built under the block online-during-build policy; see Online-during-build policy.
Session settings: SET
SET vector_consistency = '...', SET vector_build_wait = '...' and SET query_memory_limit_mb = 512 change a setting of the session that sends them, below the query's own hint. An embedded database is one session. On a server the setting belongs to the client's Session stream and reaches only its statements; the Session's Configure frame changes the same settings and reports them. A unary ExecuteCypher call has no session, so it refuses a SET with FAILED_PRECONDITION; it names the settings for that one statement in its vector_consistency, vector_build_wait_ms and query_memory_limit_mb fields instead.
query_memory_limit_mb is the memory, in MiB, each later statement may hold at once; it starts at the server's query_memory_limit_mb (256). A value of 0, past the 4096 MiB ceiling, or not a whole number is refused with INVALID_ARGUMENT naming the setting, and the limit stays as it was. A statement that needs more fails with RESOURCE_EXHAUSTED, reason EXCEEDS_MEMORY_BUDGET, and returns no rows. The limit covers everything the statement holds: its operators' rows, the subqueries it runs per row (each holding its rows only while it runs), index searches (vector, full-text, encrypted) and the writes it stages. A statement also stops at the deadline of its call, with DEADLINE_EXCEEDED, reason QUERY_DEADLINE_EXCEEDED, and when cancelled, with CANCELLED, reason QUERY_CANCELLED; the deadline and cancellation are checked as its work proceeds, inside index searches too.
read_consistency
Governs whether graph, vector, full-text, document, and time-series reads inside a single query resolve against the same HLC timestamp T. Every modality on the serving shard waits until every write with commit_ts ≤ T has been applied to every index before the read dispatches. Orthogonal to read_concern (which governs replication durability).
| Mode | Behaviour |
|---|---|
current | Each modality reads its latest state independently. No watermark wait. Lowest latency. Default for single-modality reads. |
snapshot | All modalities align at a single HLC T via MaxAssignedWatermark::wait_for(T, read_timeout). HNSW post-filtered, tantivy segment-filtered by commit_ts ≤ T. Auto-selected when a query touches >1 modality. |
exact | As snapshot, plus HNSW is bypassed (brute-force scan with MVCC filter). 100% recall, 10–100× slower vector path; use for audit / correctness-critical reads, and for vector search at an AS OF TIMESTAMP, which the current-state index cannot answer. |
What counts as a "modality" for the auto-promotion rule:
| Query shape | Modalities | Promoted? |
|---|---|---|
MATCH (n:T) WHERE n.prop = ... RETURN n | graph | No — count = 1 |
MATCH (n:T) RETURN n ORDER BY vector_distance(n.emb, $q) LIMIT k | vector | No — count = 1 (pure vector KNN, NodeScan is the row source, not a distinct modality) |
MATCH (n:T) WHERE text_match(n.body, 'q') RETURN n | text | No — count = 1 |
MATCH (a)-[:R]->(b) WHERE vector_distance(b.emb, $q) < 0.3 RETURN a, b | graph + vector | Yes — multi-hop traversal is genuine graph work |
... WHERE text_match(...) AND vector_distance(...) < ... ... | text + vector | Yes |
... RETURN rrf_score([n.emb, n.body], {vector: $qv, text: $qt}) ... | vector + text (via RankFuse) | Yes |
A bare NodeScan/IndexScan is treated as a carrier, not a distinct modality — so pure vector KNN and pure text search stay on the fast current default. Multi-hop graph patterns (Traverse, ShortestPath) count as genuine graph modality. Use an explicit /*+ read_consistency('snapshot') */ hint to opt in to snapshot semantics on a single-modality query.
-- Cross-modality auto-promotion: this query touches graph + text + vector,
-- so the planner picks read_consistency='snapshot' automatically.
MATCH (c:Chunk)
WHERE text_match(c.body, 'rust')
AND vector_distance(c.embedding, $q) < 0.5
RETURN c
-- Explicit override of an auto-decision — e.g. accept potential
-- cross-modality skew to shave latency:
MATCH (c:Chunk)
WHERE text_match(c.body, 'rust')
AND vector_distance(c.embedding, $q) < 0.5
RETURN c /*+ read_consistency('current') */
-- Force snapshot on a single-modality query (rare; usually for causal reads):
MATCH (n:Product)
WHERE vector_distance(n.embedding, $q) < 0.3
RETURN n /*+ read_consistency('snapshot') */vector_consistency as narrower override
When read_consistency and vector_consistency are both set, vector_consistency wins for the vector modality only — every other modality still follows read_consistency. A session SET vector_consistency = '...' replaces what read_consistency implies, but a query's own vector_consistency hint wins over the session. This lets power users pin the vector path to exact (brute-force) while keeping FTS + graph aligned via snapshot:
MATCH (c:Chunk)
WHERE text_match(c.body, 'rust')
AND vector_distance(c.embedding, $q) < 0.5
RETURN c /*+ vector_consistency('exact') */
-- planner still auto-promotes read_consistency='snapshot'; vector goes exact,
-- text + graph align at the same HLC T.Timeout behaviour
Under snapshot or exact the executor blocks on the shard's applied-watermark. If the applier is stuck (replication lag, network partition), the wait times out after read_timeout (default 2s) and the query returns an error naming the mode, the target commit_ts, and a retry/fallback hint. Callers that prefer latency over cross-modality alignment can retry with /*+ read_consistency('current') */.
