Skip to content

Language Reference ​

Clauses ​

Read Clauses ​

MATCH ✅ ​

Matches graph patterns against stored nodes and edges.

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

cypher
MATCH (u:User {id: $id})
OPTIONAL MATCH (u)-[:CREATED]->(post:Post)
RETURN u.name, post.title

WHERE ✅ ​

Filters rows. Supports all comparison, logical, and string operators. Can include pattern predicates.

cypher
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 = true

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

cypher
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 expression

WITH ✅ ​

Passes results between query parts, enabling filtering and transformation mid-query.

cypher
MATCH (u:User)-[:PURCHASED]->(p:Product)
WITH u, count(p) AS purchases
WHERE purchases > 5
RETURN u.name, purchases
ORDER BY purchases DESC

UNWIND ✅ ​

Expands a list into individual rows.

cypher
UNWIND [1, 2, 3] AS x RETURN x
UNWIND $ids AS id MATCH (n {id: id}) RETURN n

ORDER BY ✅ ​

Sorts the result. Supports multiple sort keys, ASC (default) and DESC.

cypher
RETURN n.name ORDER BY n.name
RETURN n.name, n.age ORDER BY n.age DESC, n.name ASC

SKIP and LIMIT ✅ ​

Pagination. Both accept integer expressions or parameters.

cypher
RETURN n SKIP 20 LIMIT 10
RETURN n SKIP $offset LIMIT $page_size

Write Clauses ​

CREATE ✅ ​

Creates nodes or relationships.

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

cypher
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 u

UPSERT MATCH 🔷 ​

CoordiNode extension. Atomic match-or-create with separate ON MATCH and ON CREATE branches. Avoids the TOCTOU race condition in MERGE.

cypher
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).

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

cypher
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 PROPERTIES

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

cypher
MATCH (a:Template {name: 'default'})
CLONE NODE a AS b
  WITH EDGES
  SET b.name = 'Clone of ' + a.name

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

cypher
MATCH (old:Account {id: $deprecated}), (new:Account {id: $canonical})
REDIRECT EDGES FROM old TO new
  WHERE type(r) IN ['KNOWS', 'FOLLOWS']
  DIRECTION OUTGOING

DELETE / DETACH DELETE ✅ ​

Deletes nodes or relationships. DETACH DELETE removes all connected edges before deleting the node.

cypher
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 only

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

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

cypher
ATTACH (a:Address)-[:HAS_ADDRESS]->(u:User) INTO u.address

SET ✅ ​

Sets properties and labels.

cypher
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 schema

REMOVE ✅ ​

Removes properties and labels.

cypher
REMOVE n.temp_flag
REMOVE n.config.old_key                   -- nested document key
REMOVE n:Pending                          -- remove label

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

cypher
-- 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
)
  • TEMPORAL opts the type into bitemporal semantics. On CREATE, every instance must supply valid_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 in WITH (...) as NOT NULL with type INT, FLOAT, BOOL, TIMESTAMP, STRING or BLOB; 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.0 and 0.0 are one value, NaN is not accepted, and a BLOB is compared by its whole value.
  • A TEMPORAL type that names no discriminator is identified by valid_from (a TIMESTAMP, or an INT of Unix microseconds). The resolved discriminator is returned by the statement as discriminator and 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 TEMPORAL flag is immutable: re-CREATE of 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_from and valid_to are user-supplied bitemporal axis fields (declarable, not reserved).
  • Property types in WITH (...): STRING, INT, FLOAT, BOOL, TIMESTAMP, BLOB, MAP, GEO, BINARY, DOCUMENT. Modifier NOT 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__: true at valid_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 EDGES on 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, traversal MATCH (a)-[:E]->(b:Temp), pattern predicate WHERE (a)-[:E]->(:Temp)) see each node's state valid at the statement's NOW: 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 at t instead. See Temporal Edges → Reading temporal nodes.
  • B-tree indexes on temporal labels hold an entry per version; a UNIQUE index 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 one MATCH, the AS OF VALID_TIME clause.

CREATE INDEX / DROP INDEX ✅ ​

B-tree index on a single property. Optional: UNIQUE, SPARSE (skip nulls), WHERE predicate (partial index).

cypher
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 constraint
  • Entries 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 (gRPC ALREADY_EXISTS with reason DUPLICATE_KEY; SQLSTATE 23505 over 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 it SPARSE to skip them.

  • A unique index is a constraint. CREATE UNIQUE INDEX name ... creates the uniqueness constraint name, owning the index of the same name, as CREATE CONSTRAINT name ... IS UNIQUE does, keeping the SPARSE and maintenance options the statement states. It is listed with the other constraints, a CREATE CONSTRAINT ... IF NOT EXISTS over the same property finds it instead of building a second index, DROP INDEX refuses it and DROP CONSTRAINT removes 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 the scope column, gRPC Constraint.scope), only nodes in scope are compared, and a uniqueness over every node of the label is a different constraint. The predicate is one of n.prop = <string | integer | boolean literal> or n.prop IS NOT NULL; any other WHERE is refused, never widened to every node.

  • Building. CREATE INDEX publishes 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 than index_build_unique_read_limit nodes, the write fails with unique index ... is still being built instead (gRPC UNAVAILABLE with reason UNIQUENESS_UNRESOLVED, SQLSTATE 53000): 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, reason INDEX_UNRESOLVED (metadata label, 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 INDEX over 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 INDEX during 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 INDEX and CREATE CONSTRAINT return its operation and wait for it up to index_build_statement_wait_secs (60 by default). A statement that outwaits it returns with the build still running: the index BUILDING, a uniqueness or key constraint VALIDATING and already enforced on every write. The build goes on, and its outcome makes the constraint ACTIVE or withdraws it. CREATE CONSTRAINT ... IF NOT EXISTS while 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:

    cypher
    CALL 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 stays

    Each build row has operation, index, label, state (ACCEPTED, RUNNING, PUBLISHED, FAILED, CANCELLED), phase (AWAITING_SEAT, AWAITING_OLDER_TRANSACTIONS, INDEXING while running on this member), indexed and failure. Waiting cancels nothing. The embedded API has the same calls (Database::index_build_status, Database::index_build, Database::cancel_index_build), and gRPC has SchemaService.ListIndexBuilds, GetIndexBuild and CancelIndexBuild; a CreateConstraint names its own wait in wait.

  • Repairing duplicates while building. Without a clause, a unique build over stored duplicates fails. ON DUPLICATE RENAME p lets the build end each duplicate it meets by appending _ and eight random base-36 characters to the string property p of the holder it reaches second:

    cypher
    CREATE 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.email

    p must 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), and db.indexBuild reports renameProperty and how many were repaired; the embedded API has Database::index_build_repairs and ConstraintDeclaration::on_duplicate_rename, gRPC CreateConstraint.on_duplicate_rename and ListIndexBuildRepairs.

  • 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 whose tags is ['x', 'y'].

  • Values with no key. A map, a vector or NaN is not indexed; an equality on such a value is answered by scanning the label. An equality with NULL matches 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.

ProfileThe log recordsSuits
RESOLVED (default)the index entries themselvesmembers that should apply entries without reading the record
DERIVEDthe index change and its exact inputs; every member derives the entries when it applies the writesmaller log entries, wide or many indexes
cypher
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 INHERIT binds an index to the current default.
  • ALTER INDEX changes 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 INDEX and ALTER INDEX return maintenance (RESOLVED or DERIVED), maintenance_source (OVERRIDE, or NAMESPACE@<revision> for an index bound to the namespace default) and maintenance_epoch; ALTER INDEX also returns previous_epoch. ALTER NAMESPACE returns index_maintenance_default and its revision.
  • A DERIVED index enforces uniqueness, reads its own writes inside a transaction and rolls back with it exactly as a RESOLVED one does. Its entries are not change events: a change stream carries the node writes they follow.
  • A transaction whose DERIVED index 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.

cypher
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 than index_check_max_repairs, or a third pass still finding some, rebuild the index instead (rebuiltInto names the new generation).
  • What starts one. A read or a write that meets an entry its record does not back marks the generation SUSPECT and starts a check at once; every B-tree index is also checked every index_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_limit records 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 stays SUSPECT until the records are fixed.
  • REINDEX returns like CREATE INDEX: state (READY, or BUILDING when the statement's wait ran out), operation (the build, which db.indexBuild inspects) and nodes_indexed. Writers maintain the new generation at once; a constraint the index enforces stays enforced.
  • db.indexChecks() and db.indexCheck return operation (the generation checked), indexId, integrity (UNCHECKED, SUSPECT, VERIFIED), state, phase (RECORDS, ENTRIES), passes, checked, mismatches, repaired, conflicts, rebuiltInto and failure. A string argument names an index, an integer is its id.
  • Over gRPC the same operations are SchemaService.CheckIndex, ListIndexChecks, GetIndexCheck, CancelIndexCheck and Reindex, selecting an index by index_id or index_name. A missing index or check answers NOT_FOUND; an index that is not a ready B-tree index answers FAILED_PRECONDITION; a member that takes no writes refuses CheckIndex as not the leader.

CREATE CONSTRAINT / DROP CONSTRAINT ✅ ​

Named constraints on the nodes of a label.

cypher
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
RequirementPropertiesA node breaks it when
IS UNIQUEone or moreanother node of the label holds the same values; a node missing any of them is not constrained
IS NOT NULLonethe property is missing or null
IS NODE KEYone or moreany property is missing or null, or another node holds the same values
IS :: <type> / IS TYPED <type>onethe 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-path SET 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 = $e passes a NOT NULL on email, 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 (gRPC FAILED_PRECONDITION with reason CONSTRAINT_VIOLATION and metadata constraint, kind, label, property, element_id; SQLSTATE 23502, or 23514 for a type, over the PostgreSQL wire). A second holder of a unique or key value fails as a unique index does: unique constraint violated, gRPC DUPLICATE_KEY, SQLSTATE 23505.
  • Creating over stored data. CREATE CONSTRAINT checks 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 UNIQUE or NODE KEY constraint owns a unique index with the constraint's name. It answers lookups like any index; DROP INDEX refuses it, DROP CONSTRAINT removes it together with the constraint.
  • Validating. A UNIQUE or NODE KEY constraint 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 EXISTS naming 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 EXISTS makes the statement a no-op (created is false) when a constraint of that name, or one with the same requirement on the same properties of the label, exists. DROP CONSTRAINT ... IF EXISTS returns dropped: false for a missing constraint; without it, a missing one fails.
  • Results. CREATE CONSTRAINT returns constraint, label, properties, kind and created, plus nodes_indexed for a unique or key constraint. DROP CONSTRAINT returns the same columns with dropped.
  • 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 STRICT label 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, reason CATALOG_OBJECT_EXISTS (SQLSTATE 42710); a missing constraint with NOT_FOUND, reason CATALOG_OBJECT_NOT_FOUND (SQLSTATE 42704); a constraint the definition contradicts, or one still validating, with FAILED_PRECONDITION, reason CATALOG_CHANGE_REFUSED (SQLSTATE 55000). The metadata of the first two carries object and name.
  • Schema API. coordinode.v2.graph.SchemaService creates, drops and lists the same constraints (CreateConstraint, DropConstraint, ListConstraints, or /v2/graph/schema/constraints over 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 COLUMNAR table 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.

cypher
CREATE VECTOR INDEX product_emb ON :Product(embedding)
  OPTIONS { m: 16, ef_construction: 200, metric: "cosine", dimensions: 384 }
DROP VECTOR INDEX product_emb

Supported metrics: cosine (default), euclidean, dot.

CREATE TEXT INDEX / DROP TEXT INDEX ✅ ​

Full-text BM25 index using tantivy. Single-property or multi-property.

cypher
-- 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_body

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

cypher
CREATE ENCRYPTED INDEX patient_ssn ON :Patient(ssn)
DROP ENCRYPTED INDEX patient_ssn

CREATE / 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.

cypher
-- 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 / optionEffect
ON :LabelFire on node mutations of the given label
ON [:EdgeType]Fire on edge mutations of the given type
CREATE | UPDATE | DELETEEvent filter — any subset, joined by |
BEFORE COMMITSynchronous on Raft leader; failure can abort the transaction
AFTER COMMITAsynchronous; 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 nPer-trigger override of L1 cascade depth (default 10)
CASCADE_FANOUT nPer-trigger override of L2 unique-trigger fanout (default 100)
MAXDEPTH nDeprecated alias for CASCADE_LIMIT n
ON ERROR PROPAGATEError aborts the transaction (BEFORE) or dead-letters (AFTER)
ON ERROR RETRY n [WITH BACKOFF ms]Durable retry queue, exponential backoff
ON ERROR DEAD_LETTERFirst 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.

cypher
ALTER LABEL Config SET SCHEMA FLEXIBLE
ALTER LABEL User   SET SCHEMA STRICT
ALTER LABEL Event  SET SCHEMA VALIDATED
ModeBehavior
STRICTOnly declared properties, all interned. Rejects unknown properties
VALIDATEDDeclared properties interned; unknown properties stored in overflow map
FLEXIBLEAll 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).

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

cypher
EXPLAIN SUGGEST
MATCH (u:User)
WHERE u.email = $email
RETURN u

Response — list of suggestions, each with:

FieldTypeDescription
kindstringCREATE INDEX, ADD DEPTH BOUND, ADD JOIN, CREATE VECTOR INDEX, ADD PRE-FILTER, BATCH REWRITE
severitystringCRITICAL, WARNING, INFO
explanationstringHuman-readable description of the problem
ddlstring?Ready-to-run DDL statement (when applicable)
rewritten_querystring?Suggested query rewrite (when applicable)

Built-in detectors:

DetectorSeverityTrigger
MissingIndexCRITICALFull label scan on a filtered property with no index
UnboundedTraversalWARNINGVariable-length path *.. without upper bound
CartesianProductWARNINGTwo disconnected MATCH patterns (cross-join)
KnnWithoutIndexWARNINGORDER BY vector_distance(...) LIMIT n without a vector index
VectorWithoutPreFilterINFOVector 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.

cypher
-- 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 fingerprint

Built-in procedures:

ProcedureModeOutputs
dbms.procedures()DBMSname, signature, description, mode
dbms.functions()DBMSname, signature, category, description, aggregating
db.advisor.suggestions()READid, severity, kind, query, explanation, ddl, impact, sources
db.advisor.queryStats()READfingerprint, query, count, avgTime, p99Time, plan, shardsUsed, sources
db.advisor.slowQueries(limit = 20, minTime = 100)READquery, p99Time, count, plan, sources
db.advisor.dismiss(id)DBMSid, dismissed
db.advisor.reset()DBMSstatus
db.checkIndex(index)SCHEMAthe check columns of REINDEX and index checks
db.indexChecks()READthe check columns
db.indexCheck(operation, waitMs = 0)READthe check columns
db.cancelIndexCheck(operation)SCHEMAthe check columns
db.reindex(index, waitMs = 0)SCHEMAoperation, state
db.indexCopies()READgeneration, 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 ​

OperatorDescriptionNotes
=EqualityThree-valued: null = null → null
<>Inequality
<, <=, >, >=Ordered comparisonWorks on Int, Float, String, Bool, Timestamp

Arithmetic ​

OperatorDescription
+Addition (numbers), concatenation (strings)
-Subtraction
*Multiplication
/Division. Integer ÷ integer = integer
%Modulo

Logical ​

OperatorDescription
ANDLogical and (three-valued)
ORLogical or (three-valued)
NOTLogical not
XORExclusive or

String Predicates ​

OperatorExample
STARTS WITHn.name STARTS WITH 'Al'
ENDS WITHn.name ENDS WITH 'son'
CONTAINSn.name CONTAINS 'ali'

Case-sensitive. For case-insensitive search, use text_match with a text index.

List and Null Predicates ​

OperatorExample
INn.role IN ['admin', 'editor']
IS NULLn.deleted_at IS NULL
IS NOT NULLn.email IS NOT NULL

Data Types ​

TypeCypher literalCoordiNode extension
Integer42, -7
Float3.14, -0.5e2
Booleantrue, false
String'hello', "world"
Nullnull
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:

cypher
WHERE (a)-[:KNOWS]->(b)           -- true if at least one path exists
WHERE NOT (a)-[:BLOCKED]->(b)     -- true if no path exists

CASE WHEN ✅ ​

Simple CASE (equality test) and searched CASE (boolean conditions):

cypher
-- 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 grade

Optimizer Hints ​

Per-query optimizer hints in comment syntax:

cypher
/*+ vector_consistency('snapshot') */
MATCH (n:Product)
WHERE vector_distance(n.embedding, $q) < 0.3
RETURN n
HintValuesDefault
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_waita 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).

ModeBehaviour
currentEach modality reads its latest state independently. No watermark wait. Lowest latency. Default for single-modality reads.
snapshotAll 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.
exactAs 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 shapeModalitiesPromoted?
MATCH (n:T) WHERE n.prop = ... RETURN ngraphNo — count = 1
MATCH (n:T) RETURN n ORDER BY vector_distance(n.emb, $q) LIMIT kvectorNo — count = 1 (pure vector KNN, NodeScan is the row source, not a distinct modality)
MATCH (n:T) WHERE text_match(n.body, 'q') RETURN ntextNo — count = 1
MATCH (a)-[:R]->(b) WHERE vector_distance(b.emb, $q) < 0.3 RETURN a, bgraph + vectorYes — multi-hop traversal is genuine graph work
... WHERE text_match(...) AND vector_distance(...) < ... ...text + vectorYes
... 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.

cypher
-- 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:

cypher
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') */.