InvariantDB uses openCypher syntax for reads and writes: MATCH, OPTIONAL MATCH, CREATE, MERGE, SET, REMOVE, DELETE, WITH, UNWIND, FOREACH, CALL, aggregates, list comprehensions, and quantifiers. Each node and relationship version also records when the fact was true (valid time) and when it was recorded (transaction time). MATCH accepts AT VALID, AT RECORDED, AS OF, and OVER ... BETWEEN to read at a point in time or across a window, and an AT VALID prefix sets the valid time of a write.
After you sign in, the Reference page in the console lists the built-in procedures with their signatures and output columns, and the Cypher functions. CALL db.listProcedures() lists the procedures on your server.
// Standard match
MATCH (p:Person {name: 'Alice'})-[:WORKS_AT]->(c:Company)
RETURN p.name, c.name
// At a point in time (real-world)
MATCH (p:Policy) AT VALID '2024-06-01'
WHERE p.holder = 'acme' RETURN p
// As the database knew it at a point in time (transaction)
MATCH (p:Policy) AT RECORDED '2024-06-01' RETURN p
// Both axes at once — "what was true on June 1 according to what we
// knew on June 1"
MATCH (p:Policy)
AT VALID '2024-06-01'
AT RECORDED '2024-06-01'
RETURN p
// One row per version whose valid time overlaps a window
MATCH (p:Policy {id: $id})
OVER VALID BETWEEN '2024-01-01' AND '2024-12-31'
RETURN p.premium, p._valid_from, p._valid_to
ORDER BY p._valid_from
// AS OF t is the same as AT RECORDED t; AT t is the same as AT VALID t
MATCH (p:Policy) AS OF '2024-06-01' RETURN p
// Versions whose transaction time overlaps a window
MATCH (p:Policy {id: $id}) OVER RECORDED BETWEEN '2024-01-01' AND '2024-12-31' RETURN p
// Only versions valid for the whole window
MATCH (p:Policy {id: $id}) OVER VALID COVERING BETWEEN '2024-01-01' AND '2024-12-31' RETURN p
timestamp() and reserved properties such as _valid_from return milliseconds. An integer time with a magnitude below 1015 (other than 0) is refused with subcode TEMPORAL_UNIT_AMBIGUOUS, so AT VALID timestamp() fails instead of reading January 1970. Use ISO strings, or parameters in nanoseconds. A time clause applies only to its own MATCH.AT VALID, AT RECORDED, AS OF, OVER or atTime reads the graph as it is valid now, including versions written with a future valid time: a value set with AT VALID '2027-01-01' … SET is returned from 2027-01-01 on, and a node created with AT VALID '<future>' CREATE is found from that time on. Through 1.4.2.3 such values were returned at once. While a label or relationship type holds such a version, a read of it without a time carries the info warning CURRENT_READ_VALID_NOW and does not use the count and index fast paths, so it is slower until that time has passed. The MATCH of a statement that writes binds the current record. For a credential that a graph’s access policy restricts, a read without a time still returns the current records, as through 1.4.2.3.AT VALID, AT RECORDED, AS OF, with or without LIMIT) that would have to look at more of the graph’s history than the server allows fails with HTTP 400, subcode TEMPORAL_READ_LIMIT, and returns nothing; details.limit is history_ids or time_ms. 1.4 answered 200 with a warning and could leave out nodes deleted after the cut. Name one label in the read (the cost of MATCH (n:L) AT RECORDED t follows the history of L, not of the whole graph), add a property filter, or use a later cut. From 1.4.1, MATCH (n:L) AT VALID t RETURN … LIMIT k returns nodes deleted or relabelled after t when the live nodes do not fill the LIMIT.CREATE (p:Person {name: 'Alice', email: 'alice@example.com'})
MERGE (p:Person {email: 'alice@example.com'})
ON CREATE SET p.created_at = datetime()
ON MATCH SET p.last_seen = datetime()
// Map-merge: keep existing keys, overwrite supplied
SET p += {a: 1, b: 2}
DELETE p
// Cascade across edges
DETACH DELETE p
// Backdate: the SET is valid from 2024-06-01; the transaction time is now
AT VALID '2024-06-01' MATCH (p:Policy {id: 'p1'}) SET p.premium = 1200
An AT VALID prefix accepts an open start time only; AT VALID ... TO ... and AT VALID BETWEEN ... on a write fail with CLOSED_INTERVAL_WRITE_UNSUPPORTED. DELETE fails on a node that still has relationships; use DETACH DELETE. DELETE ends the entity’s valid time at the time of the write, or at the AT VALID prefix time when the statement has one. Writing a reserved property such as _valid_from (from 1.4.2.5 also _seq) fails with subcode RESERVED_BITEMPORAL_PROPERTY_WRITE. From 1.4.2.5 a CREATE or MERGE that names more than 256 labels new to the graph (the default limit) is refused with 400 TOO_MANY_NEW_LABELS before it runs; labels that already exist do not count. A node in MERGE matches on its labels and every listed property; a node without a label is matched against every node in the graph (a full scan). (Example data is fictional.)
Every write is the entity’s value from its valid start on. The rules below decide what happens when a write starts before the version recorded last, or a delete would end a version before it starts.
// A correction: the amount was 12 from 2022, not 10
AT VALID '2022-01-01'
MATCH (o:Order {id: 'o1'}) AT VALID '2022-01-01'
SET o.amount = 12
// Withdraw a version written for a future date
AT VALID '2031-01-01' MATCH (o:Offer {id: 'x'}) DELETE o // its valid_from is 2031-01-01
SET, REMOVE, MERGE … ON MATCH SET, a relationship SET) that starts before the current version of its node or relationship would replace, from its start on, every version recorded for a later valid time. When that would change such a version (lose one of its property values or labels, or bring back a property or label a later write removed), the statement is refused with 400 WRITE_WOULD_REPLACE_LATER_VERSIONS and nothing is written; the message names the entity, the start of each later version and what it would lose. This covers a correction under a version recorded for a later time and a write without AT VALID after a scheduled change (AT VALID '<future>' … SET). A write that changes nothing of them is applied. A write without AT VALID is refused only because of a version that was dated in the future when it was written (a scheduled change); concurrent writers, retries and clock differences between writers never cause the refusal. To make the change, write it at or after the latest later version’s start. Through 1.4.2.4 such a write was applied and copied the latest version’s other values into the corrected interval. On the hosted service these writes are refused; a self-hosted operator can choose to apply them instead, in which case the write is based on the version valid at its start, replaces the later versions (also for reads without a time) and warns WRITE_REPLACES_LATER_VERSIONS. From 1.4.3.2, write such a correction with FOR PORTION OF, which keeps the later versions.AT VALID on a version valid now) is based on the current version and is never refused for this.AT VALID '<to>' … DELETE with <to> before the version’s start is refused with 400 VALID_END_BEFORE_VALID_FROM (from 1.4.2.4; RUNTIME_ERROR before). From 1.4.2.5 a DELETE or DETACH DELETE without AT VALID is refused the same way when the current version, or a relationship it would remove, starts after the time the statement runs; nothing is deleted. Before, it was accepted and the future-dated fact disappeared at every valid time. A statement that deletes many entities is refused as a whole when one of them is refused. To withdraw a future-dated version, delete it with AT VALID '<its valid_from>'; an entity with a later-dated change recorded can be ended at that time with AT VALID._valid_to at AT VALID and AT RECORDED reads, validTo in db.history, db.propertyHistory and db.propertyProvenance, the windows of OVER VALID) is the earliest valid start among the writes and deletes recorded after it (from 1.4.2.5; from 1.4.2.4 a version replaced by a write without AT VALID reports its end instead of null). A version that a later-recorded write covers from its own start holds no valid time: validTo equals validFrom, and OVER VALID leaves it out. For writes recorded in valid-time order the end is where the next version starts. Stored history is not rewritten; the ends are computed when read.AT VALID FROM x TO y, AT VALID BETWEEN x AND y) is refused with CLOSED_INTERVAL_WRITE_UNSUPPORTED (the subcode from 1.4.2.4; SYNTAX_ERROR before). To create an entity over a closed interval, write the start with AT VALID '<from>' and end it with AT VALID '<to>' … DELETE. To change existing nodes and relationships over a closed interval, use FOR PORTION OF (from 1.4.3.2, below).FOR PORTION OF VALID FROM <from> TO <to> before a statement that matches nodes or relationships and sets or removes properties or labels applies the change to the valid interval from <from> up to, not including, <to>, and keeps everything outside it (the SQL:2011 UPDATE … FOR PORTION OF).
AT VALID '2020-01-01T00:00:00Z' CREATE (o:Order {id: 'o1', amount: 10, status: 'open'})
AT VALID '2024-01-01T00:00:00Z' MATCH (o:Order {id: 'o1'}) SET o.status = 'paid'
// The amount was 12 from 2022 to the end of 2024, not 10
FOR PORTION OF VALID FROM '2022-01-01T00:00:00Z' TO '2025-01-01T00:00:00Z'
MATCH (o:Order {id: 'o1'})
SET o.amount = 12
| Valid time | amount | status |
|---|---|---|
| 2020–2021 | 10 | open |
| 2022–2023 | 12 | open |
| 2024 | 12 | paid |
| 2025 on, and a read without a time | 10 | paid |
status = 'paid' above); a version that runs into <from> or across <to> is split there, and its values outside the interval stay as they were. Without TO the change applies from <from> on. An entity whose first version starts after <from> is changed from that start; an entity whose versions all start at or after <to> is not changed.WRITE_WOULD_REPLACE_LATER_VERSIONS. It is the way to correct the past under a later-dated version.<from> and <to> take what AT VALID takes: an ISO 8601 string, integer nanoseconds or a parameter (FOR PORTION OF VALID FROM $from TO $to). TO must be after FROM.SET n.p = v, SET n += {…}, REMOVE n.p, SET n:Label, REMOVE n:Label and relationship properties (SET r.p = v). A SET value is written to every version in the interval, so it must be a literal, a parameter or a value computed from them (UNWIND $rows AS r MATCH (n {id: r.id}) SET n.p = r.v), not a value read from the graph (SET o.amount = o.amount + 1 is refused).AT VALID reads, _valid_from / _valid_to, OVER VALID, db.history, db.propertyHistory and db.propertyProvenance show the split; AT RECORDED before the statement reads the versions as they were. A RETURN in the statement shows each entity’s current record.TO in the future. The old value comes back at <to>, so the entity then has a scheduled change; a read without a time answers the corrected value until then.<from> on.Refused with 400 PORTION_WRITE_UNSUPPORTED (MCP -32006), with nothing written: a clause other than MATCH, OPTIONAL MATCH, WITH, UNWIND, SET, REMOVE and RETURN (so no CREATE, MERGE, DELETE, FOREACH or CALL), UNION, a statement without SET or REMOVE, a MATCH, UNWIND or WITH … WHERE after the first SET / REMOVE, SET n = {…}, a SET value read from the graph, TO at or before FROM, relationships of a type that a relationship constraint with a quote check covers, adding or removing a label that a relationship constraint names, an entity deleted for a while inside the restated interval, and an entity whose history cannot be read. A statement too large to commit at once is refused with BATCH_TOO_LARGE (it is never committed in parts), and a caller whose writes an access policy restricts gets ACCESS_POLICY_SURFACE_UNAVAILABLE. FOR PORTION OF RECORDED, and FOR PORTION OF together with an AT VALID prefix, are syntax errors. There is no DELETE … FOR PORTION OF: mark the interval with a property or a label (FOR PORTION OF VALID FROM … TO … MATCH (n …) SET n:Inactive). In db.propertyProvenance, a value the statement restated without changing it can show the statement’s provenance; the writes that set the value stay in db.history and in AT RECORDED reads.
| Aggregate | Notes |
|---|---|
count(*), count(x) | Empty input → 0 (Cypher spec). |
count(DISTINCT x) | Dedups before counting. |
sum(x), avg(x) | Empty input: sum=0, avg=null. |
min(x), max(x) | Empty input: null. |
collect(x), collect(DISTINCT x) | List collector. |
| WITH-stage aggregation | WITH x, count(*) AS c WHERE c > 5 RETURN x, c works as expected. |
// Variable-length paths
MATCH path = (a)-[*1..5]->(b) WHERE a.id = $start AND b.id = $end RETURN length(path)
// Named paths
MATCH p = (a)-[r]->(b)
RETURN length(p), nodes(p), relationships(p)
// shortestPath as an expression; both endpoints must be bound first
MATCH (a:Person {id:$a}), (b:Person {id:$b})
RETURN shortestPath((a)-[:KNOWS*]->(b)) AS p
// Node ids and hop count
MATCH (a:Person {id:$a}), (b:Person {id:$b})
CALL gds.shortestPath(id(a), id(b)) YIELD path, length
RETURN path, length
// Pattern comprehension
MATCH (a:Person {id:$id})
RETURN [(a)-[:KNOWS]->(friend) WHERE friend.active | friend.name] AS active_friends
// Quantifier predicates
MATCH (p:Project)
WHERE ANY(x IN p.tags WHERE x STARTS WITH 'urgent')
AND ALL(c IN p.checks WHERE c.passed)
RETURN p
// reduce()
RETURN reduce(s = 0, x IN [1, 2, 3] | s + x * x) AS sum_of_squares
shortestPath and allShortestPaths also work as a pattern: MATCH p = shortestPath((a)-[:KNOWS*]->(b)). The search uses the direction, relationship types, property map and length bounds of the pattern; a lower bound above 1 is refused. When no path exists, shortestPath(...) returns null, allShortestPaths(...) returns an empty list, and the MATCH form returns no row. length(p), nodes(p) and relationships(p) work on the returned path. In a MATCH pattern outside shortestPath, a variable-length pattern without an upper bound stops at 10 hops.
// Standard comparison WHERE n.score >= 0.5 AND n.label IN ['A', 'B'] // Regex WHERE n.name =~ '(?i)alice.*' // String predicates WHERE n.name STARTS WITH 'A' WHERE n.url ENDS WITH '.com' WHERE n.body CONTAINS 'pricing' // Not-equals WHERE a.x <> b.x // Null handling WHERE n.email IS NULL WHERE n.email IS NOT NULL
// COUNT { ... } — Cypher-5 count subquery
RETURN COUNT { MATCH (a:Person {name:'Alice'})-[:KNOWS]->() } AS friend_count
// CALL { ... } — sub-query with parameters
MATCH (a:Account {id:$id})
CALL {
WITH a
MATCH (a)-[:OWNS]->(p:Position)
RETURN sum(p.value) AS total
}
RETURN a.id, total
From 1.4.2.3 a CALL { } body sees an outer variable only through a WITH as its first clause, as above. A body without one that reads an outer variable, or names it in a pattern, is refused with 400 UNRESOLVED_VARIABLE before anything runs; through 1.4.2.2 such a body used the outer value. A body without an importing WITH that returns a column under the name of an outer variable (WITH 1 AS t CALL { UNWIND [5] AS t RETURN t } RETURN t) is refused with SEMANTIC_ERROR, as on Neo4j (from 1.4.2.5). A name the body declares itself (UNWIND … AS t, WITH x AS t, YIELD t, the variable of a list comprehension) is a new variable (from 1.4.2.5; 1.4.2.3 and 1.4.2.4 refused these). From 1.4.2.5 a body may hold a procedure call and further CALL { } bodies, nested up to 8 levels; subqueries of every kind nest at most 32 levels (400 EXPRESSION_TOO_DEEP). A body without RETURN keeps the outer rows as they are. Neo4j’s CALL (a, b) { … } import form and CALL { } IN TRANSACTIONS are not supported.
// Property index on current values
CREATE INDEX FOR (n:Person) ON (n.email)
DROP INDEX FOR (n:Person) ON (n.email)
// Vector index over vectors stored in a node property
CALL db.buildVectorIndex('Doc', 'embedding')
// List property indexes and vector indexes
CALL db.listIndexes()
CALL db.listVectorIndexes()
Declaring the vector property in the graph schema (type vector, dim N) is optional. With a declared dim, creating a node (CREATE or MERGE) with a vector of a different dimension is refused; SET on an existing node is not checked.
From 1.4.1, CREATE INDEX on a label that has no value for the property yet takes the type of the first value written and returns the warning INDEX_TYPE_PENDING; until that value is written, reads of the property scan the label. To set the type at once, add OPTIONS {value_type: 'string'|'int'|'float'|'bool'}. CREATE INDEX again on an index that holds no value because every stored value has another type rebuilds it with that type and returns the warning INDEX_RETYPED.
From 1.4.3.3, CREATE INDEX on a label with 100,000 nodes or more returns at once: the index is registered and built in the background, and the statement carries the info warning INDEX_POPULATING with details.build_id and details.value_type. CALL db.listIndexes() shows the index as populating until it is online; meanwhile reads of the label scan it, with the same answers. A CREATE INDEX sent while another build runs on the graph is accepted and its build queued. Smaller labels are indexed before the statement returns, as before. db.listIndexes() yields a new last column, value_type (int, string, float or bool; null on a relationship index until its type is chosen).
MATCH that combines a time clause (AT VALID, AT RECORDED, AS OF, OVER ... BETWEEN) with a property filter reads the label's history instead and returns warning TEMPORAL_EQ_FULL_SCAN. Results are correct; the query takes longer on large labels. When the label already has data, CREATE INDEX indexes the existing nodes before it returns (from 1.4.3.3, on a label with 100,000 nodes or more, in the background; see above). An index created shortly before a server crash can be lost; create it again after the restart.=, IN, a range, STARTS WITH, an inline map entry) keeps none of those nodes. From 1.4.3, for a caller that a rule on a property denies on any label, a statement does not use an index of that property to find the nodes of a pattern; the predicate is evaluated on the records. The rows were already the same; now EXPLAIN (the route chosen), EXPLAIN ANALYZE (rows_out, index_used) and stats.nodes_read no longer depend on values the caller may not read. For such a caller, a Cypher statement that calls vector.* gets no stats.vector_profile.eligibility_size_estimate from an equality or IN predicate on the property, and CALL db.listIndexes() returns entity_count null on that property’s rows. A statement that filters only on such a property reads every node of the label; a filter on a property the caller may read still uses its index. Callers the rule allows are unchanged. A credential that a graph’s access policy restricts can still learn from the ids of nodes it creates and from the write sequence in responses (stats.graph_seq) how many nodes and writes exist, hidden ones included, and, by writing again, when hidden writes happen; keep audiences whose write volume must stay private from each other in separate graphs.CREATE INDEX FOR ()-[r:HAS_MEMBER]-() ON (r.joinDate)
CREATE INDEX member_note FOR ()-[r:HAS_MEMBER]->() ON (r.note) OPTIONS {value_type: 'string'}
DROP INDEX FOR ()-[r:HAS_MEMBER]-() ON (r.joinDate)
// Answered from the index
MATCH (friend)<-[m:HAS_MEMBER]-(forum) WHERE m.joinDate > $minDate RETURN forum
An index covers one property of one relationship type; the direction of the arrow does not matter. CREATE INDEX builds the index before it returns: it rewrites the graph’s files once, so on a large graph it takes as long as a full maintenance pass. Repeating it, or dropping an index that does not exist, does nothing. Without value_type the index takes the type most stored values have. db.listIndexes() lists it with type relationship_range, name ()-[:TYPE]-().prop and state online, or populating while it is declared but not built.
A MATCH … WHERE r.prop <op> value on a relationship of exactly that type is answered from the index when <op> is =, <, <=, > or >= with an integer, or = with a string, and nothing else in the statement reads that relationship. The answers are the same as without the index: relationships written since the last maintenance pass are checked on their records. The index is not used when a stored value of the property has another type than the index, for a statement with a time clause, for a caller that a field read rule may deny the property, or in a statement the planned query engine does not run (statements that write, PROFILE, UNION, subqueries, credentials an access policy restricts). EXPLAIN shows RelIndexFilter when it is used.
// Unique value per label; the name is optional; IF NOT EXISTS before or after the name
CREATE CONSTRAINT person_email IF NOT EXISTS FOR (p:Person) REQUIRE p.email IS UNIQUE
// Unique combination, and node key (present, not null, unique combination)
CREATE CONSTRAINT FOR (o:Order) REQUIRE (o.region, o.number) IS UNIQUE
CREATE CONSTRAINT FOR (o:Order) REQUIRE (o.region, o.number) IS NODE KEY
// Property must be present and not null; property type
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.name IS NOT NULL
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.age IS :: INTEGER
// From 1.4.2: required relationship (every Claim has a SUPPORTED_BY relationship to a Passage),
// and the same with the quote check
CREATE CONSTRAINT claim_supported FOR (c:Claim) REQUIRE (c)-[:SUPPORTED_BY]->(:Passage)
CREATE CONSTRAINT claim_quoted FOR (c:Claim) REQUIRE (c)-[:SUPPORTED_BY]->(:Passage) OPTIONS { quote: true }
// List constraints; check existing data against them
CALL db.constraints()
CALL db.validateConstraints()
// Drop by name
DROP CONSTRAINT person_email
DROP CONSTRAINT notnull_Person_name IF EXISTS
Every write is checked against every constraint before anything is stored: CREATE, MERGE, SET, SET n:Label, REMOVE, writes inside FOREACH and CALL { }, the AT VALID prefix, string-id command batches, imports and MCP tools. Duplicates inside one statement are refused. Unique values are checked on the statement’s final state, so a statement that swaps values between nodes is accepted.
A violation applies nothing of the statement and answers HTTP 409, code ENG_SCHEMA_VIOLATION, subcode CONSTRAINT_VIOLATION (MCP: JSON-RPC -32009), with details naming the constraint, label, properties, reason and, for a duplicate, the string id of the node that already holds the value. In a string-id command batch, three refusals that are not declared constraints answer 400 with the same subcode: a MergeNode whose key matches more than one node (details.constraint merge_key_unique), a delete of a node that still has relationships (connected_edges), and a vector of the wrong dimension or type for a declared vector property (vector_schema). A Cypher MERGE whose pattern matches more than one node binds every match.
CREATE CONSTRAINT checks the existing data first and is refused with 409 CONSTRAINT_CREATION_FAILED (up to ten offending node ids) if any node violates it. A name that already exists answers 409 SCHEMA_RULE_ALREADY_EXISTS, and the same rule under another name 409 EQUIVALENT_SCHEMA_RULE_ALREADY_EXISTS; with IF NOT EXISTS both are no-ops. A unique constraint without an index on its property creates one; that index is dropped with the constraint. On a large label, create the index first. A constraint is durable when the statement returns.
STATEMENT_SUBCOMMITTED) is checked part by part, and import jobs check each chunk on its own. From 1.4.3.1 a concurrent read does not see the writes of a write statement before the statement has committed; on 1.4.3 and earlier, while a multi-clause write statement ran, a concurrent read could see an intermediate state. The legacy POST /write route is refused while a graph has a constraint (409 CONSTRAINTS_REQUIRE_CMD_PATH).Required relationships (from 1.4.2). REQUIRE (c)-[:T]->(:M) says that a node of the label cannot exist without a relationship of type T, in the stated direction, to a node with label M; (c)<-[:T]-(:M) requires an incoming one and (c)-[:T]->() accepts any node at the other end. The requirement is one relationship with one type and a direction; a variable length, several types, property maps and longer patterns are refused. The rule is checked at the end of every write, on the state the write leaves, so the order of clauses does not matter. Refused, with nothing applied: creating a node with the label or adding the label without the relationship; deleting the last such relationship of a node that keeps the label; deleting the node at the other end, or removing its label, when that leaves a node without the relationship. Create the node and its relationship in one statement or one batch: MATCH (p:Passage {id: $passage}) CREATE (c:Claim {id: $id})-[:SUPPORTED_BY]->(p). To retract a node, delete it with DETACH DELETE, or remove its label in the statement that removes its relationship. A refusal answers 409 CONSTRAINT_VIOLATION with details.constraint required_relationship, details.reason, details.relationship and details.node_id. db.constraints() lists the constraint with type REQUIRED_RELATIONSHIP and the columns relationshipType, direction, targetLabel and options. IF NOT EXISTS is a no-op only when the existing constraint has the same requirement and the same options; otherwise the statement is refused.
Quote check (from 1.4.2). With OPTIONS { quote: true } each required relationship carries quote (a string), start and end (integers; end is exclusive), and the quote must equal the other node’s text at those positions. Positions count Unicode code points from 0, as substring() does; both sides are put in Unicode NFC and compared byte for byte, so case, white space and punctuation must match. An empty quote is refused, and a span cannot split a letter from its combining mark. The server stores the SHA-256 of the quote in quote_sha256 on the relationship. A write that changes a quoted span of the text is refused; change the text and its quotes in one statement. The options text_property, quote_property, start_property, end_property and hash_property set other property names. details.reason is unsupported (no relationship), quote_missing, quote_mismatch or quote_hash; the refusal does not repeat the quote or the text. With the option on, a node satisfies the constraint when it has at least one such relationship and every one it has passes the check.
AT VALID time counts from the moment it is written. A node that carries both labels can satisfy the rule with a relationship to itself. An import job keeps a constrained node and the relationship commands right after it together, so list each node directly before its relationships; an import that lists all nodes first is refused for those nodes. There is no mode that records a violation instead of refusing the write.From 1.4.2 a graph can carry an access policy. The policy says which nodes and relationships each subject (the id of a credential: a user, an API key, an agent) may read and write, by label and by relationship type. A graph without a policy behaves as before, and upgrading does not change existing graphs. Field access rules still decide which properties of a visible node a caller reads.
// Deny by default; analysts read documents and the links between them
CALL db.access.setDefault('deny')
CALL db.access.grant('analyst', 'read', 'label', 'Document')
CALL db.access.grant('analyst', 'read', 'relationship_type', 'CITES')
// Hide one label from analysts, whatever else they are granted
CALL db.access.deny('analyst', 'read', 'label', 'Privileged')
// Give the role to an API key (the subject id that db.access.whoAmI() reports for it)
CALL db.access.assignRole('apikey:k_3f2a', 'analyst')
// Why can or can't a subject read a node?
CALL db.access.why('apikey:k_3f2a', 'doc-118', 'read', ['analyst'])
YIELD allowed, rule, role, target, restriction, policyVersion
RETURN allowed, rule, role, target, restriction, policyVersion
A role holds allow and deny rules for the privileges read, write and grant on the whole graph, one label or one relationship type. A subject holds the roles its credential carries plus the roles the policy assigns to its id. A decision is made in this order: a mandatory restriction that applies to the subject and names one of the node’s labels refuses; a deny rule of any of the subject’s roles refuses; an allow rule permits; otherwise the graph’s default (allow or deny) decides. A grant on one label never overrides a deny or a restriction on another label of the same node. A relationship is visible only when its type is allowed and both of its end nodes are visible.
Reads. For a subject the policy restricts, a node or relationship it may not read is absent: MATCH does not bind it, a path does not pass through it, count(*) and every aggregate leave it out, and ORDER BY ... LIMIT ranks only what the subject sees. The result carries no warnings and no plan statistics.
Writes. Changing or deleting a node needs write on the node, and the node must be visible. Creating a node with a label, adding a label and removing a label each need write on that label. A relationship write needs write on its type, with both end nodes visible. MERGE matches only nodes the subject can see. A refused write answers HTTP 403, subcode ACCESS_DENIED; the refusal names no label, relationship type or value that the caller did not supply.
Changing the policy. A credential with the role admin or tenant_admin can make any change. Any other subject needs grant on the target of each rule it adds or removes, and can grant only a privilege it holds. Removing a deny rule, editing restrictions and changing the default need admin or tenant_admin. Each accepted change is recorded with the subject that made it, the time and a SHA-256 link to the previous change, and applies to every statement that starts after the call returns. db.access.events() reads the recorded changes and db.access.verifyChain() checks their hash chain. A statement that changes the policy cannot also write data (FORBIDDEN_MIXED_SCHEMA_AND_DATA).
db.access.whoAmI() | The caller’s subject id, roles, the policy version, and whether the policy restricts its reads or writes: subject, roles, policyVersion, readsRestricted, writesRestricted. |
db.access.policy() | The policy: version, default, roles, members, restrictions, policy. No row when the graph has no policy. |
db.access.setPolicy(policy) | Replace the policy with a document (a map, or a string holding JSON). A document with an unknown key is refused with ACCESS_POLICY_INVALID. |
db.access.applyPreset(preset [, config]) | Replace the policy with a preset: open (default allow, no rules) or team-scoped (default deny; per team in config, a role that reads and writes the team’s labels and relationship types and an admin role that can also grant on them). |
db.access.setDefault('allow' | 'deny') | Set what the graph decides when no rule matches. |
db.access.grant(role, privilege, on [, name]) | Add an allow rule. on is 'graph', 'label' or 'relationship_type'. |
db.access.deny(role, privilege, on [, name]) | Add a deny rule. A deny wins over every allow. |
db.access.revoke(role, privilege, on [, name [, effect]]) | Remove a rule. effect is 'allow' (default) or 'deny'. |
db.access.assignRole(subject, role), db.access.unassignRole(subject, role) | Assign a role to a subject id, or remove it. |
db.access.dropRole(role) | Remove a role, its rules and its assignments. |
db.access.restrict(id, restriction), db.access.unrestrict(id) | Add, replace or remove a mandatory restriction: {labels, relationshipTypes, privileges, appliesTo, exceptRoles, reason}. It is evaluated before every rule, and no grant overrides it. |
db.access.events([sinceVersion [, limit]]) | The recorded policy changes, oldest first: version, ts, actor, actorRoles, action, change, prevHash, ownHash. |
db.access.verifyChain() | Check the hash chain of the recorded changes: status, events, headVersion, headHash, brokenAtLine, reason. |
db.access.why(subject, nodeStrId [, privilege [, roles]]) | Why a subject can or cannot read or write a node: allowed, rule, role, target, restriction, policyVersion, subjectRoles. rule is restriction, role_deny, grant, default_allow, default_deny or root. |
The procedures that change the policy return version, action and ownHash. db.access.policy, db.access.events, and db.access.why about another subject need admin, tenant_admin or a subject that holds grant.
-32007), subcode ACCESS_POLICY_SURFACE_UNAVAILABLE, and nothing run: procedures other than db.access.* (including full-text, vector and hybrid search), temporal clauses (AS OF, AT VALID, AT RECORDED, OVER), the EXPLAIN forms and DIFF GRAPH, index and constraint statements, and the HTTP routes other than the Cypher, cursor and MCP routes. A subject whose writes only are restricted keeps read-only procedures, temporal reads, EXPLAIN, the GET routes and the search routes. Rules are by label and relationship type; there are no rules on individual records. The decision uses a node’s current labels. GET /graphs/{name}/schema/definition stays available to a restricted subject and lists the declared label and property names, including labels it cannot read. A refused uniqueness check tells a restricted subject that a value is taken, and a refused DELETE that a hidden relationship exists. For a restricted subject, counts and LIMIT reads check each candidate record, so a count over a large label reads that label. The policy applies to requests that carry a credential: a server run with authentication off does not apply it, and the /admin routes are not decided by it. A credential with admin or tenant_admin is subject to the policy for data; under a deny-by-default policy it sees no data until a role grants it.This page lists common procedures. Other built-in procedures cover index and constraint listing (db.listIndexes, db.constraints, db.validateConstraints, db.listVectorIndexes), vector indexes (db.buildVectorIndex, db.dropVectorIndex, db.embedBackfill, gds.embedText), decisions (db.recordDecision, db.exportDecisionTrail), relabeling (db.relabel), counts (db.graphCounts; from 1.4.1 db.labelCounts), and, from 1.4.2, the access policy (db.access.*, see Access control). CALL db.listProcedures() YIELD name, signature, description, status lists each procedure on your server with its signature. After you sign in, the Reference page in the console shows the same list.
Name only the output columns a procedure produces in YIELD; an unknown column fails with subcode UNKNOWN_YIELD_COLUMN.
| Procedure | Purpose |
|---|---|
db.changes(from_ns, to_ns) | Every mutation in a window. |
db.delta(from_ns, to_ns) | Per-entity summary of changes in a window. |
db.changeRate(window_seconds) | Mutations-per-second over the recent window. |
db.propertyHistory(nodeId, prop) | Per-property bitemporal version history. |
db.history(nodeId) | Full per-version record dump: version, timestamp, properties, labels, validFrom, validTo and, from 1.4.2, kind (created, updated or deleted) and principal (the credential that wrote the version, or null). From 1.4.2 a call without YIELD binds eight columns instead of six. |
db.labelCounts() | From 1.4.1. One row per label that has a live node: label and its exact node count, the latest writes included. A node with several labels counts under each. From 1.4.3.1 it counts what is valid now. |
db.labelCountsAsOf(recordedAt) | From 1.4.1. One row per label that had a node at the recorded time: label, count. recordedAt is an ISO 8601 string or integer nanoseconds. It reads the whole graph’s history and is refused with TEMPORAL_READ_LIMIT on a graph whose history is longer than the server’s limit; count one label at a time there with MATCH (n:L) AT RECORDED t RETURN count(n). |
db.changes and db.delta take integer nanoseconds since the Unix epoch; ISO strings are not accepted. To select changes by ISO date, use DIFF GRAPH FROM '2025-06-01' TO '2025-07-01'.
db.propertyProvenance(nodeId, prop) | Version history of one property with the provenance of each change: actor, source, reason, requestId, confidence, and principal. From 1.4.2 principal is set for every write of an authenticated request; the other columns are null for writes without a provenance object. |
db.linkDerivedFrom(node, sources, kind?) | Create DERIVED_FROM edges from a node to its sources. The node and each source may be a string id, a value of the id property, or id(n). An unknown node is an error; sources that are not found are skipped, so check linkedCount. |
db.derivedFrom(node, maxDepth?) | Follow DERIVED_FROM edges from a node to its sources; yields sourceStrId, depth, kind. From 1.4.2.2 the node is resolved as db.linkDerivedFrom resolves it: a storage string id, then a value of the id property, or id(n). A reference that names no node is refused with LINEAGE_NOT_FOUND, and an id value that matches more than one node with LINEAGE_AMBIGUOUS; a node with no sources answers no rows. On 1.4.2 and earlier only a string id or id(n) was resolved, and an id that was not found returned no rows and no error. |
Send a provenance object with a write to POST /cypher, POST /graphs/{name}/cypher, or the MCP cypher_query tool. It applies to each property value the statement writes.
{
"query": "MATCH (p:Policy {id: 'p1'}) SET p.premium = 1200",
"provenance": {
"actor": "underwriter-17",
"source": "pricing-service",
"reason": "annual review",
"requestId": "req-8841",
"confidence": 0.9
}
}
All fields are optional. confidence is a number from 0 to 1 (a numeric string is also accepted); any other value fails with HTTP 400, subcode INVALID_PROVENANCE_CONFIDENCE (an invalid-params error on MCP), and nothing is written. Over HTTP you can send the same fields as X-Provenance-Actor, X-Provenance-Source, X-Provenance-Reason, X-Provenance-Request-Id, and X-Provenance-Confidence headers with ASCII values; a body field takes precedence over its header. On an authenticated request the server also records principal, the identity of the credential that made the write. Clients cannot set principal. Send provenance: {} to record only the principal. On 1.4.1 and earlier, requests without a provenance object or header record no provenance. From 1.4.2 every write of an authenticated request records principal, with or without a provenance object, on the Cypher routes, /execute and the MCP tools; a principal sent in the object, the body or a header is not used. principal stays null for unauthenticated writes, for writes made before 1.4.2 without a provenance object, for import and ingest jobs and other background jobs, and for the legacy POST /write route. After a host failure the principal of the last writes before it can be missing. Any caller that may run db.history or db.propertyProvenance on a graph sees the credential ids of its writers.
MATCH (p:Policy {id: 'p1'})
CALL db.propertyProvenance(id(p), 'premium')
YIELD value, txnAt, actor, source, reason, requestId, confidence, principal
RETURN value, txnAt, actor, source, reason, requestId, confidence, principal
Consecutive versions with the same value are returned as one row. txnAt, validFrom, and validTo are nanoseconds since the Unix epoch. (Example values are fictional.)
db.recordEpisode(sessionId, kind, text [, payload [, opts]]) | Append an event to a session. Yields eventId, episodeId (the same value) and ts. payload is a string; from 1.4.2 a map or a list is also accepted and stored as its JSON text (1.4.1 and earlier refuse them with ARGUMENT_TYPE_MISMATCH). From 1.4.1, opts is a map: validFrom (an ISO 8601 string or integer milliseconds since the Unix epoch) is when the event happened, by default the time of the call. From 1.4.2.4 a later time is accepted without facts (the event becomes current at that time) and refused with facts (FUTURE_VALID_FROM_WITH_FACTS); on 1.4.1 to 1.4.2.3 a time more than 5 minutes in the future is refused; facts is a list of up to 64 facts saved in the same transaction, each {subject, predicate, value or object, text?, validFrom?, single?, confidence?}. From 1.4.1 the call also yields validFrom (milliseconds), embedding (stored, pending or off) and, when facts were given, facts. |
db.replayEpisodes(sessionId [, limit]) | Return a session's events in time order; limit keeps the oldest events. |
db.consolidateSession(sessionId, summary [, sinceTs]) | Create a Summary node linked to the session's events. Events are kept. |
db.sessionSummaries(sessionId) | List summaries for a session. |
db.recordBelief(beliefId, claim, confidence [, evidenceIds]) | Record a belief with a confidence from 0 to 1 and links to evidence nodes. Fails if the belief id already exists. |
db.reviseBelief(beliefId, newClaim, newConfidence, reason) | Create a successor belief linked to the original. The original is kept. |
db.beliefLineage(beliefId [, hops]) | Revision chain of a belief. hops (1 to 3) adds the evidence of each version. |
db.applySalienceDecay(label, halfLifeSeconds [, floor]) | Decay the salience of nodes with the label based on time since last access. |
db.reinforceSalience(strId [, amount]) | Increase one node's salience and update its last-access time. From 1.4.2 the first argument may be the node itself: MATCH (n:Note {id: $id}) CALL db.reinforceSalience(n, 0.5). The strId it yields is the node’s stored string id. |
db.embeddingModelDrift() | List vector indexes whose embedding model or dimension differs from the graph's current default model (every index is listed with driftKind no_default when no default is set). |
db.aclEvents(sinceId?, principal?, limit?) | Read ACL audit chain. |
db.verifyAclChain() | Verify the hash chain. |
db.destroySubjectKey(subject) | Destroys a subject’s encryption key and records a receipt. Crypto-shred (per-subject encryption keys and key destruction) is in development: on the current release, declaring an encrypted property is refused by default, and the call is refused without recording a receipt. Contact us before relying on it. |
db.proveErasure(subjectId) | Erasure receipts for the subject, each anchored to the history log, and whether the receipt chain verifies. |
db.subjectExport(subject) | Export for one subject (GDPR Art. 20): the node, its relationships and its property versions. On a graph with no declared encrypted property it matches only the node whose string id equals the argument. A subject id held only in a property is not matched, and nodes created with Cypher CREATE or MERGE get a generated string id. An empty result does not show that the graph holds nothing about the subject. |
db.dpBudget(principal?) | ε consumed per principal by private-mode keys. |
db.dpEvents(principal?, from_ns?, to_ns?) | DP audit log replay. |
db.dpVerifyChain() | Verify the DP audit hash chain. |
gds.shortestPath(source, target [, config]) | Shortest path by hop count from source to target. Without a config map it follows relationships of every type in their stored direction; the map sets relationshipTypes, orientation, node selectors and maxDepth. Returns node ids and the hop count. |
gds.pageRank([damping]) | PageRank over all nodes. damping defaults to 0.85; up to 100 iterations. |
gds.connectedComponents() | Weakly connected components. |
gds.degreeCentrality() | In-degree and out-degree for each node. |
gds.fulltextSearch(label, prop, query [, k [, atValid [, atRecorded [, options]]]]) | BM25 full-text search with an index built on demand. k defaults to 10. From 1.4.3 options is a map with analyzer ('ascii-v1', the default: ASCII letters and digits; 'unicode-v2': every script; 'unicode-v2+fold', 'unicode-v2+stem', 'unicode-v2+fold+stem'). From 1.4.2 a node whose other label hides the searched property from the caller is not returned and is not counted in the scores of the other nodes (1.4.1 returned it). For phrases, Boolean connectors, proximity and labels of any size, see Full-text indexes. |
db.createFulltextIndex(label, prop [, options]), db.dropFulltextIndex(label, prop), db.listFulltextIndexes(), db.fulltext.query(label, prop, query [, options]) | From 1.4.3. Declared full-text indexes; see Full-text indexes. |
gds.vectorSearch(label, prop, queryVector [, k [, metric | config]]) | Top-k vector search. metric is cosine, euclidean, or dot. From 1.4.2.2 the 5th argument may be a config map {metric, filter, nodeIds, atValid, atRecorded, strict}: filter takes conditions on properties (equality, an IN list, gt / gte / lt / lte ranges, up to 8 properties, combined with AND), and nodeIds lists the nodes the search may return (node ids, string ids or nodes, for example the collected result of a MATCH). Both apply before the top k, so k rows come back whenever k nodes pass. An unknown key or a malformed value is refused with ARGUMENT_TYPE_MISMATCH naming the key. From 1.4.2 a node whose other label hides the searched property from the caller is left out before the ranking, so the call returns the best k nodes the caller may read (1.4.1 dropped it after the ranking and could return fewer than k). |
gds.semanticMatchExpand(label, prop, queryVector, k, hops [, edgeTypes [, threshold [, scopeFilter [, maxNodes]]]]) | Top-k vector matches, then expansion along relationships up to hops steps. From 1.4.2 a node whose vector the caller may not read is not a seed. |
db.hybridSearch(label, bm25Prop, bm25Query, vecProp, vecQuery [, k [, metric [, filter [, options]]]]) | BM25 and vector ranking combined with reciprocal rank fusion. From 1.4.2.2 the 9th argument {nodeIds: […]} restricts both sides to the listed nodes (with filter, to those that also pass it); it needs no property index. From 1.4.3 the same map also takes analyzer (the analyzer of the keyword side, as for gds.fulltextSearch) or index: 'declared' (the keyword side reads the declared full-text index on (label, bm25Prop) with the words syntax; under an AT VALID prefix it reads the index at that time when it was declared with history: 'all', and is refused with FULLTEXT_HISTORY_NOT_INDEXED otherwise); passing both is refused. From 1.4.2.2 a filter on a property that has only a temporal index is refused. From 1.4.2, with a filter the keyword scores are computed over the nodes the filter admits, so bm25_rank and the fused order can differ from 1.4.1; and a node whose other label hides a searched property from the caller is left out before the ranking on both sides and is not counted in eligibility_size. From 1.4.1, a filter that matches no node returns no rows with the warning HYBRID_FILTER_EMPTY_RESULT. From 1.4.1, under an AT VALID prefix on a read-only statement the call ranks the nodes valid at that time by their text and vector at that time; through 1.4.3.2 a filter argument is refused with the prefix, and the warning HYBRID_VECTOR_PARTIAL counts the nodes that have no vector and are ranked by keyword only. From 1.4.3.3 the options map also takes fusion, weights, normalize, at_valid and at_recorded, and a filter or nodeIds at a past time is answered; see Hybrid search options. |
A declared full-text index stores the words of one text property of one label with their positions and their sentence and paragraph boundaries. It answers phrases, AND / OR / NOT, prefix and wildcard words, words within n words of each other, and words in the same sentence or paragraph, in four query syntaxes. It is built in a fixed amount of memory, so labels of any size can be indexed, and after the build it follows the data: a write is in the answer of the next query. A query can return the matching passages of the stored text with their offsets. gds.fulltextSearch (any word, BM25, no index to declare) answers as before.
// Declare and build (in the call; use {build: 'background'} for a large label)
CALL db.createFulltextIndex('Opinion', 'text')
YIELD status, state, docs, bytes, buildMillis
// Query: legal terms and connectors, 20 hits per page
CALL db.fulltext.query('Opinion', 'text', 'negligen! /s duty', {syntax: 'legal', k: 20})
YIELD node, score, total, complete, cursor
RETURN node.title, score, total, complete, cursor
// Matching passages with offsets
CALL db.fulltext.query('Opinion', 'text', '"deliberate indifference" /p "serious medical need"',
{syntax: 'legal', snippets: {max: 2, words: 30}})
YIELD strId, score, snippets
CALL db.listFulltextIndexes() YIELD label, property, state, docs, reason
CALL db.dropFulltextIndex('Opinion', 'text') YIELD dropped
Declaring. Options of db.createFulltextIndex(label, property [, options]): analyzer ('unicode-v2', the default, or 'ascii-v1', the tokenizer of gds.fulltextSearch), fold_accents (true: e also matches é; default false), stem ('none', the default, or 'english'), sentence_abbreviations ('legal-en', the default, or a list of words after which a period does not end a sentence), paragraph_break ('blank_line', the default, or 'line'), history ('current', the default, or 'all'; see below), build ('now', the default, 'background' or 'none') and rebuild (true rebuilds an existing index). A second call with the same options keeps the index (status: 'exists'); one with other options is refused with FULLTEXT_INDEX_EXISTS (drop the index first; history is not changed in place). A build in the call is stopped by the statement time limit and then removes its declaration. The graph takes writes while an index is built. Refused: an unknown option (FULLTEXT_OPTION_INVALID), a property declared encrypted (FULLTEXT_PROPERTY_ENCRYPTED), a reserved property name, too little free disk (FULLTEXT_DISK_INSUFFICIENT), and a build while another heavy operation runs on the graph (HEAVY_OPERATION_IN_PROGRESS). Declaring or dropping an index needs a credential that may write to the graph. db.listFulltextIndexes() returns one row per index with state (ready, building or rebuild_required, with reason; the server rebuilds such an index in the background, except one declared with build: 'none' and never built; build it with another db.createFulltextIndex call), docs, bytes, progress while a build runs, and pendingChanges.
Querying. Options of db.fulltext.query(label, property, query [, options]): syntax ('words', the default, 'legal', 'lucene', 'fts5'), default_operator ('or', the default, or 'and': what a space means in legal and lucene), k (1 to 10,000; default 10), order ('score' or 'node'), cursor, max_expansions (default 256), snippets, total (default true; false returns the best k only, with total and cursor null, and is faster for words joined by OR; it cannot be combined with cursor), at_valid and at_recorded. Columns: node, nodeId, strId, score (BM25), matches, snippets, total (the exact number of matching documents), complete (no match follows this page), cursor (pass it back for the next page; null on the last page), parsed (the query as it was read) and stale. A cursor is valid while the documents of the index do not change; after a write to the label the next page is refused with FULLTEXT_CURSOR_STALE, so run the query again from the first page.
legal syntax | Meaning |
|---|---|
a b, a OR b | Either word (a space means OR unless default_operator: 'and'). |
a & b, a AND b | Both words. |
a % b, a BUT NOT b, a AND NOT b | a and not b. |
"a b c" | The words in this order, next to each other. |
negligen!, wom*n | Any word that starts with negligen; * is exactly one character. |
a /3 b, a w/3 b | Within 3 words, either order. a +3 b or a pre/3 b: a first. |
a /s b, a /p b | In the same sentence, in the same paragraph (+s, +p: a first). |
lucene takes AND, OR, NOT, +a, -a, phrases, "a b"~2, neglig* and wom?n; fields, boosts, fuzzy search and ranges are refused. fts5 is the SQLite FTS5 MATCH syntax (a space means AND; NEAR(a b, 5), ^a, neglig*). words matches a document that holds any of the words, with the scores of gds.fulltextSearch for the same analyzer.
Words. unicode-v2 splits text at Unicode word boundaries, applies NFKC normalization and case folding, and indexes the letters and digits of every script; Chinese, Japanese and Korean text is indexed as overlapping pairs of characters. A word with periods is also indexed without them (U.S.C. also as usc) and a possessive also without 's (defendant's also as defendant). A sentence does not end after a listed abbreviation (v., U.S., Inc., F.2d, …) or a one-letter initial. A query word that holds no indexed word (§) is dropped with the warning FULLTEXT_QUERY_TERM_EMPTY.
Snippets. snippets: true returns up to 3 passages of about 40 words per hit; {max: 1-10, words: 5-200, unit: 'words' | 'sentence'} sets the number, the length and whether whole sentences are returned. Each passage is an exact substring of the stored value, with its range in UTF-8 bytes, characters and UTF-16 code units (startByte, endByte, startChar, endChar, startUtf16, endUtf16; end exclusive), the sentence and paragraph it starts in, and highlights, the matches inside it with the same offsets. A phrase or a proximity match is one highlight. A snippet is made only from text the caller may read. The text analysed for one page of snippets is limited; past the limit the call is refused with FULLTEXT_QUERY_TOO_EXPENSIVE (ask for a smaller page).
Reads at a past time. An index declared with {history: 'all'} also answers reads at a time: the at_valid and at_recorded options (an ISO 8601 string or nanoseconds; together or alone), or an AT VALID '<t>' prefix on a read-only statement. The documents are the nodes that MATCH (n:Label) returns at that time, with the text, scores, total, node and snippets of that time: a node deleted since is found, one created later is not. The first read of a time can take longer on a label that changed much since then; later reads of the same time are fast until the graph takes a write. A read that needs too much text from the history is refused with FULLTEXT_QUERY_TOO_EXPENSIVE. An index without history: 'all' refuses such a read with FULLTEXT_HISTORY_NOT_INDEXED. A read without a time on a label that holds a version whose valid time starts later answers what is valid now: an index with history: 'all' is read at now, and one without answers from the current text with the info warning FULLTEXT_VALID_NOW_CURRENT_TEXT.
Field read rules. A caller that the index’s own label denies the property is refused, as for gds.fulltextSearch. A document whose node has another label whose field read rule hides the property from the caller is left out before ranking: it is in no hit, page, total, score or prefix expansion, and the answer is that of an index built from the documents the caller may read. A cursor belongs to the documents its query could see. At a past time the rule is applied to the labels each node had then.
Erasure. db.destroySubjectKey removes the contents of every declared full-text index of the graph; the indexes answer FULLTEXT_INDEX_REBUILD_REQUIRED until the server has rebuilt them in the background. A full-text index cannot be declared on a property declared encrypted (FULLTEXT_PROPERTY_ENCRYPTED). An index built before its property was declared encrypted still holds the text written before, and is refused with FULLTEXT_PROPERTY_ENCRYPTED until you drop it with db.dropFulltextIndex; a crypto-shred removes its contents. Text stored unencrypted is still stored after a crypto-shred, and a rebuild indexes it again.
Refusals, never a cut-short answer. FULLTEXT_INDEX_NOT_FOUND, FULLTEXT_INDEX_BUILDING (HTTP 503; retry), FULLTEXT_INDEX_REBUILD_REQUIRED, FULLTEXT_QUERY_SYNTAX (with the character position), FULLTEXT_QUERY_UNSUPPORTED, FULLTEXT_EXPANSION_TOO_BROAD (a prefix or wildcard word expands to more than max_expansions terms, or all of them together to more than 4,096 or max_expansions when larger), FULLTEXT_QUERY_TOO_EXPENSIVE, FULLTEXT_CURSOR_INVALID, FULLTEXT_CURSOR_STALE, FULLTEXT_HISTORY_NOT_INDEXED, FULLTEXT_OPTION_INVALID. FULLTEXT_INDEX_STALE is returned only by a read-only server that has not loaded the data the index covers.
Search routes, MCP, hybrid search and recall. POST /graphs/{name}/search/fulltext and the MCP tool search_fulltext take index ('auto', the default, 'declared' or 'adhoc') and the query options above. With 'auto', a declared index on the label and property answers, and the response adds index: "declared", total, complete, cursor, parsed and stale, and per hit str_id, matches and, when asked, snippets. This changes one answer: on a label and property with a declared index, a request with no new field is answered by the declared index; send index: 'adhoc' for the 1.4.2.5 answer. While the declared index is being built or must be rebuilt (for example after db.destroySubjectKey), these requests answer 503 FULLTEXT_INDEX_BUILDING or 400 FULLTEXT_INDEX_REBUILD_REQUIRED; index: 'adhoc' answers meanwhile. POST /graphs/{name}/search with mode: 'bm25' is also answered by a declared index on the label and property when one is declared (hits {node_id, str_id, score}); it takes no index field, so use /search/fulltext with index: 'adhoc' for the 1.4.2.5 answer. Graphs without a declared index answer as before. gds.fulltextSearch takes a 7th argument {analyzer} ('ascii-v1', the default, 'unicode-v2', 'unicode-v2+fold', 'unicode-v2+stem', 'unicode-v2+fold+stem'), and so do the search routes and the MCP tool; with a Unicode analyzer the warning FULLTEXT_TOKENIZER_LIMITED is not raised. The 9th argument of db.hybridSearch also takes analyzer, or index: 'declared' to read the declared index for the keyword side. Memory recall (POST /graphs/{name}/memory/recall, MCP recall) reads a ready declared index on the recalled label and property for its keyword side, so memories in every script are found by keyword: CALL db.createFulltextIndex('Event', 'text') for the MCP memory tools.
// Several fields with weights
CALL db.fulltext.query('Fact', {said: 1.0, who: 2.0, role: 0.5}, 'injury claim')
YIELD node, score, fieldScores
// Filter before ranking
CALL db.fulltext.query('DocumentPage', 'body', '"duty of care"',
{syntax: 'legal', filter: {case_id: $matter}, k: 20})
YIELD node, score, total
// Best page of each document, with an excerpt
CALL db.fulltext.query('DocumentPage', 'body', 'negligen! /s duty',
{syntax: 'legal', group_by: 'document_id', filter: {case_id: $matter}, excerpt: true, k: 20})
YIELD node, score, group, groupHits, excerpt
RETURN group AS document, node.page AS page, score, groupHits, excerpt
These options need a declared index. A query that uses none of them answers as before, with the same columns.
Several fields. The property argument may be a list of properties (['said', 'who'], weight 1 each) or a map of property to weight (a number above 0, at most 1,000,000). Each property needs a declared index on the label; at most 16 fields. A document is a hit when the query matches in at least one field. Its score is the sum, over the fields it matched in, of the weight times its BM25 score in that field, and fieldScores maps each field to that field’s score. Ties are ordered by node id; total counts the documents that match in any field. Every match of each field is ranked, so a query of several fields is not paged by cursor (refused with FULLTEXT_OPTION_INVALID); ask for a larger k (at most 10,000). A field with too many matches is refused with FULLTEXT_QUERY_TOO_EXPENSIVE. A node is left out when a field read rule hides any of the fields from the caller.
Filter before ranking. filter takes the grammar of the gds.vectorSearch filter: {prop: value}, {prop: [v1, v2]} (up to 1,000 values), {prop: {gt|gte|lt|lte: v}}, up to 8 properties joined by AND; a null, missing or incomparable value does not pass. nodeIds lists the nodes that may be hits (node ids, string ids or nodes, for example collect(n) of a MATCH); with a filter, a node must be listed and pass. A node that does not pass is not a hit: it takes no place on a page and is not counted in total. Pages by cursor walk the passing hits; a cursor of one filter is refused with another (FULLTEXT_CURSOR_STALE). With filter_statistics: 'collection' (the default) the scores are those of the search without the filter, and the page is exactly the first k passing hits of the whole ranking; with 'filtered' the scores are those of an index built from the passing documents alone. At a past time the filter is checked on each node’s version at that time, so a node moved to another matter later is found in its old matter; 'filtered' with a filter is refused at a past time (pass nodeIds from a MATCH at the same time instead). The caller must be able to read every filtered property on the label.
Matched words. matched_terms: true adds matchedWords, the words of the stored text that made the node a hit, as written and lowercased, and matchedTerms, the same words as the index holds them (after stemming and accent folding). A phrase gives each of its words.
Excerpts. excerpt: true or {start, end, ellipsis, words} (defaults [, ], … and 12) adds excerpt: the hit’s best passage of about words words with every match marked, and an ellipsis where the passage does not start or end the value. A phrase is one mark from its first word to its last. The text outside the marks is the stored text unchanged. With several fields the excerpt comes from the field with the highest weighted score. For offsets or several passages, use snippets.
Pages and documents. A declared index holds whole bodies of any length. To return the page a match is on, store pages as nodes of their own (:DocumentPage {document_id, page, body}), declare an index on the page body and group the hits by document. group_by: <property> returns one row per value of the property, the best hit of the group, with group (the value) and groupHits (the number of hits in the group); total counts groups. A hit without the property is a group of its own with group null. group_by ranks every match first, so it is not paged by cursor, and a query with too many matches is refused with FULLTEXT_QUERY_TOO_EXPENSIVE. A match that spans two pages is found by an index on the whole body, not by the page index; query both when that matters.
Search routes and MCP. POST /graphs/{name}/search/fulltext and the MCP tool search_fulltext take fields (the list or the map; it must include property), filter, node_ids, filter_statistics, matched_terms, excerpt and group_by. Each hit then has field_scores, matched_words, matched_terms, excerpt, group and group_hits as asked. Bad values are refused with FULLTEXT_OPTION_INVALID.
Reads at a past time after other changes. From 1.4.3.3, a read of an index declared with history: 'all' at a past time keeps the current document of a node whose text and labels at that time equal its current ones (because only another property changed since), instead of reading its text from the history again. The answers are the same; such a read after many changes to other properties no longer runs into FULLTEXT_QUERY_TOO_EXPENSIVE.
Not available: row-level access rules, the text of earlier versions stored in the index (a read at a past time reads it from the graph’s history), and one index over several labels. From 1.4.3.3 several properties are searched together by naming several declared indexes (above).
CALL db.hybridSearch('Fact', 'said', $q, 'text_vec', $qVec, 10, 'cosine', {case_id: $matter},
{fusion: 'score', weights: {bm25: 0.3, vector: 0.7}})
YIELD node, score, bm25_rank, vec_rank
CALL db.hybridSearch('Fact', 'said', $q, 'text_vec', $qVec, 10, 'cosine', {case_id: $matter},
{at_valid: '2026-06-01T00:00:00Z'})
YIELD node, score
| Option | Values |
|---|---|
fusion | 'rrf' (default): a node scores w_bm25 / (60 + bm25_rank) + w_vector / (60 + vec_rank). 'score': each side’s scores are put on 0 to 1 over that side’s candidates (normalize), and a node scores w_bm25 × bm25 + w_vector × vector, with nothing from a side that did not return it. |
weights | {bm25, vector}, numbers from 0 to 1000, not both 0; default 1 each. |
normalize | 'minmax' (default): (s - min) / (max - min). 'max': s / max when every score is at least 0, otherwise min-max. With fusion: 'score' only. |
at_valid, at_recorded | An ISO 8601 string or nanoseconds. Both sides rank only the nodes of the label at that time, by their text and vector then, and node is the version then. The statement’s AT VALID prefix is the default of at_valid. |
A call without these options fuses by RRF as before, with the same scores. A filter or nodeIds at a past time is checked on each node’s version at that time, in the grammar of the gds.vectorSearch filter (no property index is needed for it there), so a node moved to another matter later is found in its old matter; through 1.4.3.2 these were refused with UNSUPPORTED_FEATURE. Such a call reads each node of the label at that time. RRF looks at ranks only, so a weight changes an RRF ranking less than it suggests; with fusion: 'score' how far apart a side’s scores are counts. With index: 'declared', bm25Prop may be a list of properties or a map of property to weight, and the keyword side ranks by the weighted sum, as db.fulltext.query with the same fields. stats.vector_profile.hybrid_fusion_method says how a call fused: rrf, rrf_weighted, score_minmax or score_max. Bad option values are refused with FULLTEXT_OPTION_INVALID.
// In WHERE MATCH (d:Doc) WHERE vector.similarity(d.embedding, $query) > 0.75 RETURN d // In RETURN MATCH (d:Doc) RETURN d.title, vector.similarity(d.embedding, $query) AS sim ORDER BY sim DESC LIMIT 10 // Functions: vector.similarity, vector.distance, vector.dot, vector.norm, // vector.normalize, vector.dim, vector.equals, vector.zero, vector.slice // Older names cosine, euclidean, and dotProduct accept lists only // From 1.4.2.2: vector.similarity.cosine(a, b) = (1 + cos) / 2 and // vector.similarity.euclidean(a, b) = 1 / (1 + d^2), both in [0, 1] (the Neo4j 5 names); // vector.similarity is unchanged (the raw cosine)
These functions return an exact score for each row that MATCH and WHERE produce. A WHERE vector.similarity(...) > t filter never uses a vector index. For top-k search over a vector index, use gds.vectorSearch or db.hybridSearch. With ORDER BY vector.similarity(...) DESC LIMIT k and a literal k, only the top k rows are kept while scoring.
gds.vectorSearch on an exact route, filtered or not; ORDER BY vector.similarity(...) LIMIT k, including searches narrowed by a pattern; the vector side of db.hybridSearch) rank the rows from the codes and read a row’s full vector only when the codes cannot rule it out. The answers are the same, bit for bit: the same rows, scores, order and ties. Nothing changes in how you call them. A vector file written before 1.4.3 gets its codes when it is next rewritten and is searched from its full vectors until then; a filtered search whose eligible nodes cover a small part of the file is also searched from the full vectors.Vectors are stored as node properties, so a query can filter nodes with MATCH and WHERE and then rank the remaining rows by similarity.
// Only rank Documents this team owns and that are still active, // then order the survivors by similarity to the query vector. MATCH (n:Document) WHERE n.team = $team AND n.status = 'active' RETURN n ORDER BY vector.similarity(n.embedding, $q) DESC LIMIT 10
WHERE keeps the rows that match the filter, and ORDER BY vector.similarity(...) ranks only those rows. The filter can use labels, properties, and relationships.
This example filters by a relationship:
// Rank only documents authored by someone on the current project
MATCH (p:Person)-[:MEMBER_OF]->(:Project {id: $project})
MATCH (p)-[:AUTHORED]->(n:Document)
RETURN n.title, vector.similarity(n.embedding, $q) AS sim
ORDER BY sim DESC
LIMIT 10
AT VALID '<date>' to the MATCH to rank nodes by the property values, including stored vectors, that were valid on that date.WHERE d.category = $c, the filter of gds.vectorSearch or db.hybridSearch, a pattern or a node list that selects the same nodes) searches that value’s index when that is estimated to be cheaper. Such a search is approximate, with the recall target of other filtered approximate searches (about 0.99 recall@10 by default): a filtered search that 1.4.3 answered by scoring every eligible node exactly may now be approximate. Nodes written or changed since the per-value index was built are scored exactly. Searches with at most 5,000 eligible nodes in Cypher, exact plans and flat vector indexes stay exact.gds.vectorSearch options (from 1.4.3.2: rescore and exact)// Pre-filter on fields of the searched label: the exact top k among the nodes that pass
CREATE INDEX FOR (n:Article) ON (n.source)
CREATE INDEX FOR (n:Article) ON (n.published)
CALL gds.vectorSearch('Article', 'embedding', $q, 300,
{filter: {source: $sources, published: {gte: $from, lte: $to}}})
YIELD node, score
RETURN node.id AS id, score
// Only the best 300 rows get exact scores; the rest gather candidates
CALL gds.vectorSearch('Article', 'embedding', $q, 3000, {rescore: 300})
YIELD node, score, exact
RETURN node.id AS id, score, exact
The 5th argument of gds.vectorSearch(label, property, queryVector, k, config) is a map with the keys metric, filter, nodeIds, atValid, atRecorded, strict and, from 1.4.3.2, rescore. An unknown key is refused with ARGUMENT_TYPE_MISMATCH. The columns are nodeId, node, strId, score and, from 1.4.3.2, exact. A CALL without YIELD returns exact only when rescore is given. exact says how a row’s score was computed; it does not make an approximate (HNSW) search exact.
The filter grammar. filter selects the nodes searched before the top k, so no row is lost to filtering after the ranking and the answer is the exact top k among the nodes that pass.
| Form | Meaning |
|---|---|
{source: 'wire'} | Equality. |
{source: ['wire', 'press']} | IN list of 1 to 1,000 values. |
{published: {gte: 20240101, lt: 20250101}} | Range: at most one lower bound (gt or gte) and one upper bound (lt or lte); numbers or strings. |
{source: $sources, published: {gte: $from}} | Up to 8 properties, all of which must hold (AND). |
A missing or null value does not pass, nor does a value that cannot be compared with the condition. With atValid / atRecorded the filter is checked on each node’s version at that time. A condition on a property with an index on the searched label is answered from the index; a range is answered from it only when every stored value of the property has the index’s type (a node holding a float or a string under an integer property makes the search read the label instead, with the same answer). Without an index the condition is checked on each node’s record, which reads the label. Declare the indexes on each searched label. nodeIds lists the nodes the search may return (node ids, string ids or nodes, for example the collected result of a MATCH); with a filter, a node must be listed and pass it.
Partial rescoring. With rescore: r below k, over a label whose vectors are kept in a vector file:
r rows are the exact search’s first r rows, with the same nodes, exact scores and order;k - r rows are the next best rows by their 8-bit score from the vector file’s codes, each with that score and exact false, ordered by that score. They follow the exact rows whatever their scores, and they may differ from the rows the exact search ranks r + 1 to k;VECTOR_PARTIAL_RESCORE, with exact_rows and code_scored_rows in its details;filter, nodeIds and field read rules apply to every row.Without rescore, with rescore of at least k, with atValid / atRecorded, and on a label not kept in a vector file (vectors held in memory, or searched through an approximate HNSW index), every row has its exact score: exact is true and there is no warning. rescore must be a non-negative integer (ARGUMENT_TYPE_MISMATCH; null means no key). The pattern form (ORDER BY vector.similarity(…)) and db.hybridSearch do not take it. A search with rescore is refused by servers older than 1.4.3.2 as an unknown key.
What a search reads (from 1.4.3.2). An exact search with a large k over a vector file ranks every row from the codes, then reads the full vectors of a shortlist in batches, best rows first, and stops as soon as no other row can enter the answer; the answers are the same, bit for bit. A filter or nodeIds that keeps a small share of the label is ranked from the codes too. YIELD node reads from each hit’s record only what the statement uses: RETURN node.id, score reads id, while RETURN score, id(node) and elementId(node) read no record when the label index vouches for the hits, and RETURN node reads the whole record. YIELD nodeId, score never reads a record. Under PROFILE the plan shows VectorSearch with VectorCodeScan, VectorRescore and NodeRecordRead, and stats.vector_profile.search gives the rows and bytes read from codes and full vectors, the reads already in memory, the batches, the records read and the time of each phase; its record counts can include reads by statements running at the same moment.
During bulk writes (from 1.4.3.2). A label’s vector file is brought up to date in the background while writes come in. A search that cannot read the recent changes before its time limit waits for the running update when that ends sooner, or is refused at once with the retryable 503 VECTOR_FILE_REWRITE_PENDING (details.reason overlay_over_deadline, or overlay_no_memory_room; retry after Retry-After). It never returns a partial answer, and a statement that has already committed a write is never refused this way.
// The filter fields stay on the nodes and relationships they describe; // the node that holds the vector carries none of them MATCH (p:Project)<-[:PART_OF]-(d:Document)-[t:TAGGED]->(s:Section) WHERE p.region IN $regions AND p.started >= $from AND t.retracted IS NULL RETURN s.id, vector.similarity(s.embedding, $q) AS score ORDER BY score DESC NULLS LAST LIMIT 10
A statement of this shape scores only the nodes the pattern reaches, instead of walking the whole pattern and reading the vector of every node it reaches. The rows are the ones the full walk gives: one row per path (use RETURN DISTINCT for one per node), a node is returned when any path through the pattern passes, and the statement reads the current data. Rows with exactly equal scores can come in another order; add a second sort key when that order matters. Changing a filter field does not rewrite a record that holds a vector.
The shape: one MATCH of one path with 1 to 4 relationships, each with a type and one hop; not OPTIONAL, no AT VALID / AT RECORDED, no named path; a WHERE of comparisons, IN lists and IS NULL / IS NOT NULL on properties and constants, combined with AND, OR, XOR, NOT, without function calls; ORDER BY vector.similarity(r.p, $q) DESC as the first sort key, a LIMIT, no SKIP, no aggregate; a cosine vector property stored as 32-bit floats. Any other statement walks the whole pattern and gives the same rows. ORDER BY score DESC sorts a missing similarity first, so when a reached node has no vector the full walk answers; write NULLS LAST or add r.p IS NOT NULL. For a narrowing one MATCH cannot state, pass the nodes to gds.vectorSearch as nodeIds.
From 1.4.3 read statements run on a planned query engine where it plans them, and on the interpreter otherwise; the engine can run the first part of a read and the interpreter the rest. Nothing in a statement chooses the engine. Among the statements the engine does not plan, which run on the interpreter as on 1.4.2.5: statements that write, EXPLAIN ANALYZE and PROFILE, reads with a time clause, UNION, subqueries, and the statements of a credential that a graph’s access policy or a field read rule restricts.
ORDER BY come in no defined order and can come in another order than on 1.4.2.5; add ORDER BY when the order matters.BUDGET_EXCEEDED (row budget) or QUERY_MEMORY_BUDGET now answer.RETURN or WITH uses) reads them together, in id order. A pattern check between two nodes the row already holds, over one or two hops (WHERE (a)-[:KNOWS]->(b)), is answered from the two nodes’ relationships. The answers do not change. Neither applies to a credential that a graph’s access policy restricts.ORDER BY of large integers and floats. A sort key that mixes integers above 253 with floats compares the exact values, on both engines. On 1.4.2.5 the integer was rounded to a float, so the order of such rows could differ between runs or the statement could fail with an internal error.From 1.4.3.2 read statements are planned from statistics the server keeps for each graph: the node a pattern starts from and the index it uses, by estimated rows; the order of consecutive MATCH clauses; and from which end a relationship between two bound nodes is checked. The statistics are built in the background after a graph opens; no statement waits for them, and until they are ready the graph plans as on 1.4.3.1. Statements that write are planned as before.
ORDER BY: the order of rows; which rows a LIMIT without ORDER BY keeps, or which of the rows tied under the ORDER BY keys; the order of a collect() list and so head(), last(), [i] and slices of it; and which of two equal grouping keys (2 and 2.0) names a group.ORDER BY on a unique key, and a statement that collects in order sorts first (WITH n ORDER BY n.ts, n.id RETURN collect(n.id)).On a self-hosted server the operator can turn the planner off for the whole server or for one graph; see the release notes shipped with the bundle.
properties(), keys() and size()| Call | Answer |
|---|---|
properties(n), properties(r) | A map of the node’s or relationship’s stored properties. A property whose value is null is not a property, so it is not in the map. |
properties(m) for a map m | m, unchanged. |
keys(n), keys(r) | The keys of properties(n) / properties(r). |
size(s) for a string | The number of characters (Unicode code points, not bytes). |
size(l) for a list | The number of elements, nulls included. A stored float list (an embedding) and a variable-length relationship variable (-[r*1..3]->) are lists. |
properties(null), keys(null), size(null) | null (an OPTIONAL MATCH that matched nothing gives null). |
| Any other value | 400 ARGUMENT_TYPE_MISMATCH, with details.expected and details.got (INTEGER, STRING, MAP, NODE, …), for a value whose type is known only when the statement runs (a property, a list element, a parameter). A value whose type is known before the statement runs (a literal such as size(1), a map literal, or a node, relationship or path variable) is refused before it runs with 400 SEMANTIC_ERROR (details.op InvalidArgumentType), as on 1.4.3.1. |
size() of a value that is not a string, a list or null, and properties() or keys() of a value that is not a node, a relationship, a map or null, answered 400 UNSUPPORTED_FEATURE with a message saying the function was not in the standard library. From 1.4.3.2 they answer 400 ARGUMENT_TYPE_MISMATCH when the value’s type is known only when the statement runs (for example size(n.age) on an integer property). A value whose type is known before the statement runs (a literal such as size(1), a map literal, or a node, relationship or path variable) is refused before it runs with 400 SEMANTIC_ERROR (details.op InvalidArgumentType), as on 1.4.3.1. A pattern is not a list: size((a)-->()) is refused when the statement is parsed; count a pattern with COUNT { (a)-->() } or size([(a)-->(b) | b]).The reserved names (_valid_from, _valid_to, _txn_from, _txn_to, _version, _txn_nano_offset, _valid_nano_offset, _seq) are read from the version’s time span and are never in properties(x), keys(x) or x {.*}; read them by name (x._valid_from). From 1.4.3.2 this also holds for a record that stored a value under one of these names before the write guards, so properties(x)[k] equals x.k for every key k. A property a field read rule hides from the caller is left out, as in RETURN x.
MATCH (n:Case) WHERE n.court = $court RETURN n.id, n.title ORDER BY n.id SKIP $offset LIMIT 100
MATCH (n:L) RETURN … ORDER BY n.p [DESC] SKIP s LIMIT k, with or without WHERE, with literal or parameter SKIP / LIMIT, returns at every moment the rows that a sort of the whole label returns. Nodes created, deleted, given a new key, or given or stripped of the label are in their place on the page, before and after maintenance and restarts. Paging through a label on a unique key with no writes in between therefore returns every node exactly once. A read at a time cut (AT RECORDED t, AT VALID t, AS OF t) pages the label as it stood at the cut. Sort on a unique key, or add one as a second key (ORDER BY n.date, n.id); rows tied under the sort keys can come in any order, so a page boundary between tied rows is not fixed. Builds before 1.3.10.1 could leave out nodes written since the last maintenance pass from the form without WHERE.
| Count | Statement |
|---|---|
| Nodes of the whole graph | MATCH (n) RETURN count(*) |
| Nodes of one label | MATCH (n:L) RETURN count(n), or CALL db.labelCounts() for every label at once |
| Relationships, all types / one type | MATCH ()-[r]->() RETURN count(r) / MATCH ()-[r:T]->() RETURN count(r) |
| With a filter | MATCH (n:L) WHERE n.status = 'open' RETURN count(n) |
| Per group | MATCH (n:L) RETURN n.status AS status, count(*) AS c |
| At a time | Add AT RECORDED t, AT VALID t or AS OF t after the pattern: MATCH (n:L) AT RECORDED '2026-10-01T00:00:00Z' RETURN count(n) |
Exact. A count is the number of matches of the MATCH at its time cut after its WHERE: the number of rows the same statement returns with RETURN n in place of the count. count(x) leaves out the rows where x is null; count(DISTINCT x) counts distinct values. Under an access policy only what the caller may read is counted. A count may also be answered from the label and relationship counts or from an index without reading the matches (EXPLAIN shows which); the answer is the same.
Not refused at the row budget. A RETURN whose items are count, sum, avg, min and max (with or without DISTINCT) and grouping keys, after the statement’s last MATCH, is computed as that MATCH produces its rows when it starts from a scan of a label or of the whole graph, with or without WHERE, AT RECORDED, AT VALID or AS OF. The rows are not kept, so the statement is not refused with BUDGET_EXCEEDED however many rows it counts (through 1.4.3.2, such a count that was not answered from the label and relationship counts or an index was refused when it matched more rows than the server’s row budget). What the statement holds still counts against the budget: one row per group, and the rows of the start nodes it reads at once (a few thousand at most). A single start node whose own matches exceed the budget is still refused. A MATCH that starts from an index lookup, an id() lookup or a node bound by an earlier clause builds its rows first, as before, and so do the rows of earlier MATCH clauses.
Still limited by time. The statement’s time limit applies (HTTP 408 QUERY_TIMEOUT). At a time cut, a read that must resolve more of the graph’s history than the server allows is refused with TEMPORAL_READ_LIMIT; name a label where you can, so that only the nodes that ever carried it are read. Relationship counts at a time without a type (MATCH ()-[r]->() AT RECORDED t RETURN count(*)) are much faster from 1.4.3.3.
Counted row by row. collect(), stDev(), percentileCont(), percentileDisc(), an aggregate inside an expression (count(*) + 1, size(collect(x))), an aggregation in a WITH, an OPTIONAL MATCH as the last clause, a shortestPath pattern, EXPLAIN ANALYZE and private-mode keys may keep every row they aggregate, and those rows count against the row budget.
CALL db.createColumn('node', 'Message', 'creationDate') YIELD state, created
CALL db.createColumn('relationship', 'HAS_MEMBER', 'joinDate') YIELD state
CALL db.listColumns() YIELD kind, name, property, state, rows, skipped
CALL db.dropColumn('node', 'Message', 'creationDate') YIELD dropped
A declared column holds the values of one property of a label or relationship type for every record, in id order. The next full maintenance pass writes a column file for each declared label or type; until then db.listColumns() shows the column as populating, then online. A step that reads only declared properties of many records (a filter, an aggregate, a sort key, a label scan with an inline map) takes them from the file instead of reading each record; a record written since the pass is read from its record. The answers are the same with and without column files. The file stores null, booleans, integers, floats, strings and times; a record whose declared property holds a list, a map, bytes or a vector is read from its record (counted in skipped). created is false when the column was already declared, and dropped is false when none was. Reads stop using a column as soon as it is dropped.
Column files are read only by statements the planned query engine runs, and only for the present: a read at a time cut never uses them. A full maintenance pass with declared columns makes one more pass over the graph’s files. Refused: a kind other than node or relationship, the reserved names (_valid_from, _valid_to, _txn_from, _txn_to, _version, _seq), a property declared for encryption, and read-only servers. Declaring or dropping a column needs a credential that may write to the graph.
Every Cypher statement is applied all or nothing on its own (see Write statements are all-or-nothing). From 1.4.3.2 a server can also run several statements as one transaction. Transactions are off by default and are not enabled on the hosted service: there, and on any server without them, the routes below answer 404 TRANSACTIONS_DISABLED. A self-hosted operator turns them on with a server setting; see the release notes shipped with the bundle.
POST /graphs/{name}/tx begin -> 201 {"transactionId": "tx_...", ...}
POST /graphs/{name}/tx/{id}/cypher run a statement (body and answer as /graphs/{name}/cypher)
POST /graphs/{name}/tx/{id}/commit commit
DELETE /graphs/{name}/tx/{id} roll back
GET /graphs/{name}/tx/{id} status
POST /graphs/{name}/tx/commit run {"statements": [...]} as one transaction and commit
txnTimeMs in the commit answer).WRITE_LOCK_TIMEOUT). Each statement sees the writes of the statements before it; other clients see none of them before the commit. Keep transactions short.Transaction-Status: rolled_back, and later requests on it answer 404 TXN_NOT_FOUND. There are no savepoints.CONSTRAINT_VIOLATION, nothing applied).Idempotency-Key with the commit; a statement inside a transaction cannot carry one (400 IDEMPOTENCY_KEY_UNSUPPORTED).timeoutMs when it begins, up to 120 seconds), rolled back after 10 seconds without a statement. Not allowed inside a transaction (400 TXN_STATEMENT_NOT_ALLOWED): index and constraint statements, schema procedures, db.embedBackfill, db.destroySubjectKey and background graph-algorithm writes. One graph per transaction (TXN_CROSS_GRAPH). A write dated before a version the same transaction wrote is refused (TXN_BACKDATED_OWN_WRITE); history reads inside a transaction see its writes only as each entity’s current version. A restart rolls back open transactions.cypher_transaction, which runs a list of statements as one transaction and commits.// Values only, including SKIP and LIMIT MATCH (p:Person) WHERE p.role = $role RETURN p LIMIT $page_size
Parameters can stand for values: in WHERE, RETURN, WITH, ORDER BY, SKIP, LIMIT, pattern property values, a whole property map ((n:Person $props)), SET values, UNWIND and FOREACH lists, procedure arguments, and time values. They cannot stand for labels, relationship types, property keys, variable-length bounds such as *1..$n, or index and constraint statements.
EXPLAIN MATCH (n:Person) WHERE n.email = $e RETURN n
// → planner text, no execution
EXPLAIN ANALYZE MATCH (n:Person) WHERE n.email = $e RETURN n
// → runs the query, including any writes, and adds plan_stats (rows and microseconds per operator)
PROFILE MATCH (n:Person) WHERE n.email = $e RETURN n
// → from 1.4.1, the same as EXPLAIN ANALYZE
EXPLAIN CHANGE MATCH (n:Person {email: $e}) RETURN n
// → change history of the matched nodes: seq, txn_at, op_kind, entity_id
DIFF GRAPH FROM '2025-06-01' TO '2025-07-01'
// → history operations recorded in the range
From 1.4.1, PROFILE <statement> is accepted and is the same as EXPLAIN ANALYZE; 1.4 and earlier accept EXPLAIN ANALYZE only. From 1.4.1, EXPLAIN names the index a labelled read without a time clause starts from for WHERE equalities, IN lists and ranges; a read with no usable index prints label scan (or full scan without a label); tests that compare EXPLAIN text can change.
From 1.4.1, the stats of every response also carry rows_materialized (rows counted against the statement’s row budget; a row is counted at each step that builds it, so this can exceed rows_returned), row_budget (the row budget that applied; 0 means none) and nodes_read (node records the MATCH clauses read as start candidates, before WHERE; nodes reached through a relationship are not counted).
Order, Index, or Contains) is stored in upper case when written without backticks: (n:Order) creates and matches the label ORDER. Write (n:`Order`) to keep the case. Backticks also allow spaces: (n:`two words`).n.x - 1. Without it, -1 is read as a negative number, so n.x -1 and 2-1 fail to parse.//; block comments are /* ... */. From 1.4.2.5 a block comment without its closing */ is refused with SYNTAX_ERROR. -- is part of relationship pattern syntax, not a comment.+, -, *, /, abs(), sum()) and integer division by zero fail with subcode ARITHMETIC_ERROR (HTTP 400), as in Neo4j. On 1.3.9 an overflowing result wraps around.datetime() returns an ISO 8601 string, timestamp() returns epoch milliseconds, and duration({...}) returns milliseconds; date(), localdatetime() and duration.between() are not supported. Not supported either: label expressions (:A|B, :!A), quantified path patterns, CALL { } IN TRANSACTIONS, the CALL (a, b) { } import form, CREATE FULLTEXT INDEX (use CALL db.createFulltextIndex, from 1.4.3, or gds.fulltextSearch), valueType(), isNaN(), toIntegerOrNull() and the other ...OrNull functions, toIntegerList() and the other ...List conversions, normalize(), nullIf(), round(x, precision), btrim(s, chars) and the trim(BOTH x FROM s) form. 'a' + 1 is refused; convert with toString(). An unaliased computed column is named col_N; alias it with AS. From 1.4.2.5 an unaliased map projection of a variable is named after the variable, as on Neo4j (RETURN d{.id} has the column d; through 1.4.2.4 it was col_N), unless another item already has that name or the list holds *.CREATE with a pattern node bound to null is refused with ARGUMENT_TYPE_MISMATCH (it created an empty node; filter with WHERE b IS NOT NULL). Several ON CREATE SET / ON MATCH SET clauses on one MERGE all apply, in order. A map projection of null is null, so collect(b{.id}) after an OPTIONAL MATCH skips it. WITH … WHERE and ORDER BY read the new value of a renamed variable; WITH *, x AS y keeps x. Float equality is exact (0.1 + 0.2 = 0.3 is false; compare computed floats with a tolerance, abs(a - b) < 1e-9), and ORDER BY, min() and max() compare integers and floats exactly. l[i..] with a variable lower bound slices. upper(), lower(), isEmpty() and the one-argument btrim(s) are accepted. substring(), left() and right() refuse a negative start or length. An unterminated block comment is refused (before, the rest of the statement was dropped). Differences that remain: an integer and a float compare as exact numbers (Neo4j converts the integer to a float); MERGE (a)-[:S]->(b:B {id: 3}) with a bound binds an existing B {id: 3}; a node deleted earlier in a statement is not found by a later MATCH of it; SET n += null with n null is refused; toInteger(' 42 ') is 42 and toString(1e21) prints positional digits; a list property keeps mixed integers and floats; LIMIT n SKIP m, integer literals with underscores, ||, and SET through an expression (SET n[k] = v) are refused.WHERE n.p = null, n.p <> $v, n.p < null), matches no node. Use IS NULL and IS NOT NULL. 1.4 treated = null as IS NULL and <> null as IS NOT NULL on MATCH … WHERE … RETURN … LIMIT k.MERGE and in range reads (3 = 3.0). A float that is infinite or not a number is returned as the string "Infinity", "-Infinity" or "NaN" (1.4 returned null). On input the tagged form {"float": "Infinity"} is read as a float; a bare string parameter stays a string (use toFloat($p)).CREATE (n:A:A) stores the label once: labels(n) returns ["A"], and counts of A count the node once.POST /cypher, POST /graphs/{name}/cypher, the explain routes and the MCP tool cypher_query; from 1.4.3 also /graphs/{name}/sql, /graphs/{name}/graphql, /graphs/{name}/nlq and /graphs/{name}/investigate (both retired from 1.4.3.3) and the MCP tools that call a procedure, such as remember, record_episode, replay_episodes, property_provenance and list_indexes). From 1.4.3 the procedures that read in proportion to the data (db.changes, db.delta, db.history, db.propertyHistory, db.propertyProvenance, db.subjectExport, db.replayEpisodes, db.derivedFrom, db.exportDecisionTrail and others) stop while they read at a cancel, a closed connection or the statement time limit, not after. POST /cancel/{id} cancels a running statement of the caller, or any statement for an admin; the id is the numeric statement id that GET /admin/queries lists. That route needs the admin role or, from 1.4.2, the tenant_admin role (which lists and cancels the statements of its own tenant); other callers have no way to read the id of their own statement on this release. From 1.4.2 ids are not consecutive and carry no order. It answers 404 for an id that is not running or not the caller’s. A cancelled statement answers 499.ORDER BY expression that cannot be evaluated (an unknown function name, a wrong number of arguments, a type error) is refused with the error the same expression gives in a RETURN item (400 UNSUPPORTED_FEATURE, ARGUMENT_TYPE_MISMATCH, …). Through 1.4.2.1 it was treated as null and the statement answered 200 with its rows unsorted. Keys that evaluate, null keys included, sort as before.WRITE_LOCK_TIMEOUT (nothing ran; retry). Later clauses of a statement see its earlier writes, and SET items apply in order. One statement may not both change the schema and write data (400 FORBIDDEN_MIXED_SCHEMA_AND_DATA). Several statements run as one unit only where multi-statement transactions are on (not on the hosted service).X-Query-Timeout-Ms header (milliseconds, up to 290,000; a larger value is capped and the response carries the warning QUERY_TIMEOUT_CAPPED; a value that is not a whole number answers 400 INVALID_QUERY_TIMEOUT_HEADER). A statement that runs past its limit answers 408 with subcode QUERY_TIMEOUT, and nothing it would have written is kept. Import and ingest jobs run in the background and have no statement time limit.STATEMENT_TOO_LARGE (pass large values as parameters), subquery bodies nest at most 32 levels (400 EXPRESSION_TOO_DEEP), and a statement that cannot be parsed within its step or time budget is refused with 400 PARSE_BUDGET_EXCEEDED. Nothing is run in each case. range() over 1,000,000 entries is refused with BUDGET_EXCEEDED.BUDGET_EXCEEDED; narrow it or add LIMIT. When the server is close to its memory limit it stops the statement holding the most memory with 503 MEMORY_PRESSURE (retry after Retry-After). When free disk space is below the server’s floor, writes are refused with 503 DISK_SPACE_LOW and reads keep working. In each case nothing of the statement is written unless details.write_committed is true.MATCH (c:Counter) FOREACH (i IN range(1, 5) | SET c.n = c.n + 1) adds 5. Builds from 18 September 2026 through 1.3.10.1 kept only one element’s update in such a loop and returned no error; check data written by such loops.ORDER BY places rows whose sort value is missing or null last in ascending order and first in descending order, with or without LIMIT. Use NULLS FIRST or NULLS LAST to choose. Strings sort by byte order: upper-case ASCII letters come before lower-case ones, and non-ASCII letters come after both.RETURN with no rows. Returns one row with identity values (count→0, sum→0, avg→null). With grouping keys (RETURN n.stage, count(n)) and no input rows, the result has no rows.count, sum or avg may be returned; RETURN n → PRIVATE_MODE_NON_AGGREGATE_RETURN. Noise is added and each aggregate spends from the key’s ε budget. This release does not give a differential-privacy guarantee; do not rely on private mode alone to protect individual records. See private-mode details.