MATCH
The MATCH clause is used to search for patterns in the graph. It is the primary read operation in Cypher.
Syntax:
MATCH pattern [, pattern ...]
[WHERE condition]
Pattern Syntax:
-
(variable)- Match any node -
(variable:Label)- Match nodes with specific label -
(variable:Label {prop: value})- Match nodes with properties -
(a)-[r]→(b)- Match directed relationship -
(a)-[r]-(b)- Match relationship in either direction -
(a)-[r:TYPE]→(b)- Match relationship with specific type -
(a)-[r:TYPE1|TYPE2]→(b)- Match relationship with multiple types -
(a)-[*min..max]→(b)- Match variable-length path
Examples:
// Match all Person nodes
MATCH (p:Person)
RETURN p
// Match with property filter
MATCH (p:Person {name: 'Alice', age: 30})
RETURN p
// Match relationships
MATCH (p:Person)-[r:KNOWS]->(friend:Person)
RETURN p.name, friend.name
// Match with relationship properties
MATCH (p:Person)-[r:WORKS_AT {since: 2020}]->(c:Company)
RETURN p.name, c.name
// Variable-length paths
MATCH (a:Person)-[:KNOWS*1..3]->(b:Person)
WHERE a.name = 'Alice'
RETURN b.name
// Named paths
MATCH path = (a:Person)-[:KNOWS*]->(b:Person)
WHERE a.name = 'Alice'
RETURN path
// Comma-separated patterns (Cartesian product)
MATCH (a:Person), (b:Company)
RETURN a.name, b.name
// Comma-separated patterns related in WHERE: joined, not crossed
MATCH (a:Person), (b:Company)
WHERE b.id = a.companyId AND b.country = 'IT'
RETURN a.name, b.name
// Multiple MATCH clauses
MATCH (a:Person {name: 'Alice'})
MATCH (b:Person {name: 'Bob'})
RETURN a, b
Path Mode (WALK, TRAIL, ACYCLIC)
Path modes control how variable-length traversals handle cycles. They are placed after the optional path variable assignment and before the pattern.
Syntax:
MATCH [variable =] [WALK | TRAIL | ACYCLIC] (a)-[*min..max]->(b)
Modes:
| Mode | Behavior | Default? |
|---|---|---|
|
Edges must be unique per path. Vertices can repeat. |
Yes (default) |
|
No vertex repeated per path (strongest restriction). Implies edge uniqueness. |
|
|
No restrictions — vertices and edges can be revisited. Requires an explicit max hop bound. |
|
|
Examples:
// TRAIL (default): edges unique, vertices can repeat.
// On a cycle A→B→C→D→A, this can traverse the full cycle.
MATCH (a:Person {name: 'Alice'})-[:KNOWS*1..5]->(b)
RETURN b.name
// ACYCLIC: no vertex revisited — stops before completing a cycle.
MATCH ACYCLIC (a:Person {name: 'Alice'})-[:KNOWS*1..5]->(b)
RETURN b.name
// WALK: no restrictions. Edges and vertices can be reused.
// Requires explicit max hops to prevent infinite traversal.
MATCH WALK (a:Person {name: 'Alice'})-[:KNOWS*1..6]->(b)
RETURN b.name
// Path mode with named path variable
MATCH p = TRAIL (a:Person)-[:KNOWS*1..4]->(b)
RETURN p, length(p)
Comparison on a cyclic graph (A→B→C→D→A):
| Mode | Reachable from A with *1..5 hops |
|---|---|
|
B, C, D, A (vertex A revisited after full cycle — edges stay unique) |
|
B, C, D (stops — cannot revisit A) |
|
B, C, D, A, B, C, … (repeats edges and vertices up to max hops) |
|
|
The path mode applies to a variable-length pattern predicate too: WHERE (a)-[]→(a) is true when a cycle leads back to a, as MATCH (a)-[]→(a) finds it, and WHERE (a)-[*0..]→(a) is true through the zero-length path.
Matching a Bound Relationship List
When the relationship variable of a variable-length pattern is already bound to a list of relationships by an earlier clause, the pattern matches exactly that list: it does not expand the graph, it checks that the relationships, in list order, form a path leaving the left-hand node in the pattern’s direction.
MATCH (:A)-[r1]->(:B)-[r2]->(:C)
WITH [r1, r2] AS rs
MATCH (first)-[rs*]->(second)
RETURN first, second // one row: (:A), (:C)
-
The list is read in written order: its first relationship touches the left-hand node of the pattern. With the list above,
(second)←[rs*]-(first)matches nothing, while[r2, r1]would match it. -
Every other constraint of the pattern still applies: the hop range (
-[rs*1..1]→refuses a two-element list), the relationship types, the inline property map, the inlineWHEREand the path mode (under the defaultTRAIL, a list naming the same relationship twice matches nothing). -
A
nulllist, or a list containingnull, matches nothing. A value that is not a list of relationships raises aTypeError. -
The same rule applies to a pattern predicate (
WHERE (a)-[rs*]→(b)), a pattern comprehension and anEXISTS { }orCOUNT { }subquery.
Inside a pattern comprehension, the relationship variable of a variable-length hop is the list of relationships walked, as in MATCH: [(a)-[r*]→(x) | size(r)] returns the length of each path, and a zero-length hop binds the empty list.
A variable-length hop reaches a vertex only through a relationship whose record exists. When the record is gone but the edge lists of its two endpoints still name it (a dangling entry, left behind by an incomplete replication or a partially rolled-back transaction), the entry is skipped and reported in the log, whatever the spelling of the hop (, *2..4, undirected) and the path mode. Another, live, relationship to the same vertex still reaches it. The check asks the bucket whether the record exists, once per vertex reached, and reads no record. Before 26.11.1 such an entry was followed and returned a vertex that no relationship reached. An undirected hop over an edge type declared unidirectional skips it too (since v26.11.1; before, the outgoing list of such a hop was followed, and an undirected walk that read the relationships failed with a RecordNotFoundException instead of skipping the entry). A fixed-length hop (-[:R]→), out() and in() still follow a dangling entry, as does everything that counts the entries of a vertex (its degree, count() over a hop): they answer from the edge lists without reading an edge, and probing each entry would make a count a walk of the edge pages. The way to remove the anomaly is CHECK DATABASE FIX, which prunes the dangling entries; after it every spelling of a hop agrees.
Distinct End Vertices of a Variable-Length Pattern
A variable-length hop enumerates paths: a vertex that many paths lead to is reached once per path. When nothing in the query can tell two paths between the same pair of vertices apart, ArcadeDB does not enumerate them. It walks the graph breadth first with a visited set and reports each end vertex once per start vertex, so the time grows with the number of vertices reached and not with the number of paths. That is the "everything this depends on" or "everything that depends on this" question over a graph with many paths onto few vertices:
MATCH (a:Package)<-[:DEPENDS_ON*1..12]-(b:Package)
WHERE a.name = 'core'
RETURN count(DISTINCT b)
The answer holds the same rows and values as the one path enumeration gives, in every direction and every path mode; only their order can differ, see below. The shortcut applies when the statement is nothing but one non-optional MATCH with one hop, followed by RETURN, and:
-
the lower bound of the hop is 0 or 1 (
,..n,*0..nand*1..n); -
there is no named path and the relationship variable is not used anywhere (not in
WHERE,RETURN,ORDER BYor an inlineWHERE); -
the
RETURNis eitherDISTINCTover the start or end vertices or their properties, or it groups by such values and aggregates them withcount(DISTINCT …),collect(DISTINCT …),sum(DISTINCT …),avg(DISTINCT …),min(…)ormax(…).ORDER BYmay use those values or the output names.
Anything else keeps enumerating paths: a plain RETURN b, count(b), size(r), a second MATCH, a WITH, OPTIONAL MATCH, and a lower bound of 2 or more. Use EXPLAIN to see which one a query gets: the hop is shown as [DISTINCT ENDPOINTS]. The traversal is lazy, so LIMIT stops it early. The order of the rows, and of the list built by collect(DISTINCT …), is the breadth-first order of the walk, while path enumeration meets the vertices depth first: the same data can come back in another order once a statement stops being eligible (a named path or a second MATCH is enough). As in every MATCH without ORDER BY, no order is promised. A statement that reads the order of the collected list, such as head(collect(DISTINCT b.name)) or collect(DISTINCT b.name)[0], is not one of the shapes above and keeps the order of the path enumeration.
Named Paths
Assigning a variable to a pattern with = binds the whole matched path to that variable, which can then be
passed to length(), nodes() and relationships() (see Expressions and Functions).
Syntax:
MATCH variable = pattern
A pattern with no relationship binds a zero-length path: one node, no relationships.
Examples:
// A one-hop path: length 1, two nodes, one relationship
MATCH p = (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person)
RETURN length(p), nodes(p), relationships(p)
// A zero-length path: length 0, nodes(p) is [Alice], relationships(p) is []
MATCH p = (n:Person {name: 'Alice'})
RETURN length(p), nodes(p), relationships(p)
A named path can be one part of a comma-separated pattern, in any position, and the node it covers may already be bound by an earlier part or an earlier clause:
MATCH (x:Person)
OPTIONAL MATCH p = (x), (x)-[:KNOWS]->(z)
RETURN x.name, p, z.name
Inside an OPTIONAL MATCH, a path variable follows the same rule as every other variable the clause
introduces: it is bound when the whole pattern matches and NULL when it does not. In the example above p
is a zero-length path on the rows where x has an outgoing KNOWS, and NULL on the rows where it has none -
even though (x) on its own would always match.
|
Returning a path. A path returned as it is reaches the client as one path value, whether a MATCH pattern,
shortestPath(), allShortestPaths(), a path procedure or apoc.path.create/slice/combine built it. A Bolt driver
receives a Path. Over HTTP, gRPC and the PostgreSQL protocol, which have no path type, it is the object
{"type": "path", "nodes": […], "relationships": […], "length": n}, with every node and relationship written whole,
and Studio’s graph view draws its nodes and relationships. _(Since v26.10.2) In 26.10.1 and earlier a MATCH path was
sent over HTTP, gRPC and PostgreSQL as its text form. See reference/graph-algorithms/path-finding.adoc#graph-algo-path-value.
OPTIONAL MATCH
OPTIONAL MATCH works like MATCH, but returns NULL values for missing parts of the pattern instead of filtering out rows. It behaves like a LEFT OUTER JOIN in SQL.
Syntax:
MATCH pattern
OPTIONAL MATCH optional_pattern
[WHERE condition]
RETURN ...
Examples:
// Find all people and their optional movies
MATCH (p:Person)
OPTIONAL MATCH (p)-[r:ACTED_IN]->(m:Movie)
RETURN p.name, m.title
// With WHERE clause scoped to OPTIONAL MATCH
MATCH (p:Person)
OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person)
WHERE friend.age > 30
RETURN p.name, friend.name
| The WHERE clause after OPTIONAL MATCH filters the optional match results, but preserves rows where the match failed (with NULL values). |
Testing for absence. An OPTIONAL MATCH whose new variable is then tested for IS NULL keeps exactly the rows for which the pattern does not exist, which is what WHERE NOT pattern says. Since v26.10.2 it is planned that way: when the variables the OPTIONAL MATCH introduces are read by nothing but that test (a conjunct of the WHERE of the WITH, or FILTER, right after it), the nodes it shares with the earlier MATCH clauses are never null, and that WITH neither aggregates nor orders nor pages, the two spellings below run the same plan, including the anti-join count push-down (issue #9598):
MATCH (t1:Tag)<-[:HAS_TAG]-(m:Message)<-[:REPLY_OF]-(c:Comment)-[:HAS_TAG]->(t2:Tag)
OPTIONAL MATCH (c)-[h:HAS_TAG]->(t1)
WITH t1, t2, h WHERE t1 <> t2 AND h IS NULL
RETURN count(*)
MATCH (t1:Tag)<-[:HAS_TAG]-(m:Message)<-[:REPLY_OF]-(c:Comment)-[:HAS_TAG]->(t2:Tag)
WHERE NOT (c)-[:HAS_TAG]->(t1) AND t1 <> t2
RETURN count(*)
A variable read anywhere else (returned, counted, renamed, carried to a RETURN *), an OPTIONAL MATCH with its own WHERE or starting from a node an earlier OPTIONAL MATCH may have left null, and a statement that writes keep the OPTIONAL MATCH as written.
WHERE
The WHERE clause filters results based on conditions.
Syntax:
WHERE condition
Operators:
| Type | Operators | Example |
|---|---|---|
Comparison |
|
|
Logical |
|
|
Null check |
|
|
List |
|
|
String |
|
|
Regex |
|
|
Examples:
// Simple comparison
MATCH (p:Person)
WHERE p.age > 30
RETURN p.name
// Multiple conditions
MATCH (p:Person)
WHERE p.age >= 25 AND p.age <= 40 AND p.city = 'NYC'
RETURN p
// NULL checks
MATCH (p:Person)
WHERE p.email IS NOT NULL
RETURN p.name, p.email
// IN operator
MATCH (p:Person)
WHERE p.name IN ['Alice', 'Bob', 'Charlie']
RETURN p
// String matching
MATCH (p:Person)
WHERE p.name STARTS WITH 'A'
RETURN p
// Regular expression
MATCH (p:Person)
WHERE p.email =~ '.*@example.com'
RETURN p
// Parenthesized expressions
MATCH (p:Person)
WHERE (p.age < 25 OR p.age > 60) AND p.status = 'active'
RETURN p
// Pattern predicates
MATCH (p:Person)
WHERE (p)-[:KNOWS]->()
RETURN p.name AS hasConnections
RETURN
The RETURN clause specifies what to include in the query result.
Syntax:
RETURN expression [AS alias] [, expression [AS alias] ...]
Examples:
// Return nodes
MATCH (p:Person)
RETURN p
// Return properties
MATCH (p:Person)
RETURN p.name, p.age
// Return with aliases
MATCH (p:Person)
RETURN p.name AS personName, p.age AS personAge
// Return all: *
MATCH (a:Person)-[r:KNOWS]->(b:Person)
RETURN *
// Return expressions
MATCH (p:Person)
RETURN p.name, toUpper(p.city) AS upperCity
// Aggregations
MATCH (p:Person)
RETURN count(p) AS total, avg(p.age) AS avgAge
// RETURN DISTINCT - removes duplicate rows
MATCH (p:Person)
RETURN DISTINCT p.city AS city
// DISTINCT with multiple columns
UNWIND [1, 2, 2, 3, 3, 3] AS n
RETURN DISTINCT n
// Aggregations with DISTINCT
UNWIND [1, 1, 2, 3] AS n
RETURN count(DISTINCT n) AS uniqueCount, sum(DISTINCT n) AS uniqueSum
// Standalone expressions (no MATCH)
RETURN abs(-42), sqrt(16), range(1, 5)
In a read-only query with RETURN DISTINCT, the clauses after the last one that binds a returned variable
(MATCH, OPTIONAL MATCH, UNWIND, a WITH that only passes variables on) can only keep or drop a row. ArcadeDB
runs them as a filter: it stops at the first match for each row and skips rows whose returned values it already
returned, instead of expanding every combination. PROFILE shows this as a SEMI JOIN step. The result is the same.
A query keeps the full expansion when it aggregates, uses RETURN *, or returns a value that can change from call to
call, such as rand(), randomUUID() or a user function. (Since v26.11.1)
|
CREATE
The CREATE clause creates new nodes and relationships.
Syntax:
CREATE pattern [, pattern ...]
Examples:
// Create a node
CREATE (p:Person {name: 'Alice', age: 30})
// Create multiple nodes
CREATE (a:Person {name: 'Bob'}), (b:Person {name: 'Charlie'})
// Create a relationship between new nodes
CREATE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'})
// Create a relationship with properties
CREATE (a:Person {name: 'Alice'})-[:WORKS_AT {since: 2020}]->(c:Company {name: 'ArcadeDB'})
// Create relationship between existing nodes
MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
CREATE (a)-[:KNOWS {since: 2024}]->(b)
// Create chained paths
CREATE (a:Person {name: 'A'})-[:KNOWS]->(b:Person {name: 'B'})-[:KNOWS]->(c:Person {name: 'C'})
A property value follows the same rule as SET: it must be a scalar, a list of
scalars, or a point() value.
|
A relationship type name cannot contain ~, since that character is reserved internally for
multi-label vertex types.
|
A relationship endpoint bound by an earlier clause must be a node. A variable an OPTIONAL MATCH left null, or a
value that is not a node, fails the statement with Expected the value NULL to be of type NODE, as on Neo4j (since
v26.11.1; before, OPTIONAL MATCH (a:A {k: 99}) CREATE (a)-[:R]→(b) created an anonymous node in place of a). A
null property, on the other hand, is simply not set: CREATE (n:B {k: null}) creates a node without k.
|
MERGE
The MERGE clause creates nodes or relationships if they don’t exist, or matches them if they do (upsert operation).
Syntax:
[path_variable =] MERGE pattern
[ON CREATE SET ...]
[ON MATCH SET ...]
ON CREATE SET and ON MATCH SET accept every form the SET clause does.
A property of the MERGE pattern cannot be null, since nothing matches a null and the MERGE would create the element
again on every run: MERGE (n:A {k: null}), UNWIND [null] AS x MERGE (n:A {k: x}), MERGE (n:A {k: $p}) with $p
null and MATCH (a) MERGE (n:B {k: a.missing}) all fail with 'MERGE' cannot be used with a graph element property
value that is null, on nodes and relationships alike. A relationship endpoint bound to null, by an OPTIONAL MATCH
that found nothing, fails the statement as in CREATE. Both match Neo4j (since v26.11.1; before,
only the literal null was refused, a null from a variable created a node without the property, and a null endpoint
silently dropped the row).
Like MATCH, MERGE can name the path it matched or created. The path spans the whole pattern, so
length(), nodes() and relationships() report what they would for the equivalent MATCH, whether or
not the individual nodes and relationships were given names of their own:
// length(p) is 2, nodes(p) has 3 entries and relationships(p) has 2,
// even though only the first node is named
MERGE p = (a:Person {name: 'Alice'})-[:KNOWS]->(:Person {name: 'Bob'})-[:KNOWS]->(:Person {name: 'Carol'})
RETURN length(p), nodes(p), relationships(p)
Examples:
// Find or create a node
MERGE (p:Person {name: 'Alice'})
// With ON CREATE action
MERGE (p:Person {name: 'Bob'})
ON CREATE SET p.created = true, p.timestamp = timestamp()
// With ON MATCH action
MERGE (p:Person {name: 'Alice'})
ON MATCH SET p.lastSeen = timestamp(), p.visits = p.visits + 1
// Both ON CREATE and ON MATCH
MERGE (p:Person {name: 'Charlie'})
ON CREATE SET p.created = true, p.count = 1
ON MATCH SET p.count = p.count + 1, p.updated = true
// Merge relationship between existing nodes
MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
MERGE (a)-[r:KNOWS]->(b)
ON CREATE SET r.since = 2024
ON MATCH SET r.lastContact = timestamp()
// Copy the defaults onto a node that was just created
MATCH (defaults:Settings {scope: 'person'})
MERGE (p:Person {name: 'Dave'})
ON CREATE SET p += defaults
A property value in the pattern follows the same rule as SET: it must be a scalar, a
list of scalars, or a point() value.
|
(Since v26.10.2) MERGE finds exactly the records the identical MATCH pattern finds, with the same comparison,
including for a declared property (see Comparing a property declared in the schema).
In v26.10.1 and earlier MERGE used a comparison of its own: it missed a stored FLOAT such as 0.1 that MATCH found,
creating a duplicate, and matched a temporal against the text of a property declared STRING, which MATCH did not.
|
SET
The SET clause updates properties and labels on nodes and relationships.
Syntax:
SET variable.property = value [, ...] // one property
SET variable[keyExpression] = value // property name computed at runtime
SET variable = source // replace all properties
SET variable += source // add to the existing properties
SET variable:Label [, ...] // add a label
For the = and += forms, source is a map, a node or a relationship. Copying from a node or a relationship
takes all of its properties, which is the usual way to clone one.
Examples:
// Update a single property
MATCH (p:Person {name: 'Alice'})
SET p.age = 31
// Update multiple properties
MATCH (p:Person {name: 'Alice'})
SET p.age = 31, p.city = 'Boston', p.updated = true
// Update relationship property
MATCH (a:Person)-[r:KNOWS]->(b:Person)
WHERE a.name = 'Alice' AND b.name = 'Bob'
SET r.strength = 10
// Replace every property with the ones in the map: 'age' and 'city' are dropped
MATCH (p:Person {name: 'Alice'})
SET p = {name: 'Alice', role: 'admin'}
// Add to the existing properties, keeping the ones not mentioned
MATCH (p:Person {name: 'Alice'})
SET p += {role: 'admin'}
// Copy every property of one node onto another
MATCH (source:Person {name: 'Alice'}), (target:Person {name: 'Alice2'})
SET target = source
// Add a label
MATCH (p:Person {name: 'Alice'})
SET p:Employee
SET is a simultaneous assignment: every value is read as it was before the clause ran, so
SET p.a = p.b, p.b = p.a swaps the two rather than copying one onto the other.
|
A SET updates each matched node once, even when it writes the indexed property the MATCH reads its nodes by:
MATCH (n:A) WHERE n.k > 0 SET n.k = n.k + 1000 with an index on A.k reads the range of the index first, then
updates. Only the record addresses are held, a few bytes per node. Before v26.11.1 such a statement met the nodes it had
just updated again further on in the index and ran out of memory. (Since v26.11.1)
A property value must be a scalar, a list of scalars, or a point() value. A map value (SET p.address =
{city: 'Rome'}), or a right-hand side of = / += that is neither a map nor an entity (SET p = 5), is
rejected with a TypeError and no part of the clause is written.
Concurrent updates. A SET (stand-alone, or in MERGE … ON CREATE SET / ON MATCH SET) computes the value it
writes from the latest committed version of the record, even under the default READ_COMMITTED isolation. So
SET n.count = n.count + 1 run by concurrent transactions never silently loses an increment: a change committed by
another transaction after that point makes the commit fail with a retryable conflict. Two things follow:
-
under contention the right-hand side can be evaluated more than once, so keep it free of side effects;
-
a
MERGE … ON MATCH SETthat assigns a value the matched record already holds writes nothing, so read-mostlyMERGEworkloads do not conflict. It keeps a different value committed concurrently after the match.
DELETE
The DELETE clause removes nodes and relationships from the graph.
Syntax:
DELETE variable [, variable ...]
DETACH DELETE variable [, variable ...]
A node cannot be deleted if it has relationships. Use DETACH DELETE to delete a node and all its relationships.
|
The check runs once the whole statement is processed, so MATCH (n)-[r]-() DELETE r, n deletes a node that has several relationships. A node that still has a relationship at the end fails the statement and nothing is deleted (since v26.10.1).
|
This includes the incoming edges of a unidirectional edge type, which the node itself does not store: DELETE refuses a node they still point to, and DETACH DELETE removes them (since v26.10.1). Deleting a vertex through the Java API or SQL DELETE VERTEX removes them too. The first vertex deleted in a transaction scans the unidirectional edge types to find them, so on a very large unidirectional type prefer deleting many vertices in one transaction.
|
Examples:
// Delete a relationship
MATCH (a:Person)-[r:KNOWS]->(b:Person)
WHERE a.name = 'Alice' AND b.name = 'Bob'
DELETE r
// Delete a node (must have no relationships)
MATCH (p:Person {name: 'TestNode'})
DELETE p
// Delete node with all its relationships
MATCH (p:Person {name: 'Alice'})
DETACH DELETE p
// Delete multiple elements
MATCH (a:Person)-[r:TEMP]->(b:Person)
DELETE a, r, b
Reads and Writes in the Same Query
A query never sees the vertices and edges it creates itself. Everything the pattern matched before the query wrote anything is what it returns - the reading clauses are evaluated against the graph as it was, not against the rows the writes behind them add.
This matters whenever a write can produce something an earlier pattern would match:
// [r] has no type, so it would match the SCORED edges this query creates.
// It does not: the row count is the number of edges that existed beforehand.
MATCH (a:Person)-[r]->(b:Person)
MERGE (a)-[:SCORED]->(b)
RETURN count(*)
To make that hold, ArcadeDB reads the whole result of the pattern into memory before applying the first
write. You can see where this happens in EXPLAIN, as an EAGER (read/write barrier) step. Two things
follow from it, and they only apply to the queries that actually get one:
-
the matched rows are held in memory until the last write is applied, so a query over a very large match needs the memory for it;
-
a
LIMITno longer stops the scan early - the write runs for every matched row.
Queries whose writes cannot feed a preceding pattern are left alone and keep streaming, which covers the usual bulk shapes:
// Nothing is read, so nothing can be re-read.
UNWIND range(1, 1000000) AS i CREATE (:LogEntry {seq: i})
// KNOWS is read, SCORED is written: no overlap.
MATCH (a:Person)-[:KNOWS]->(b:Person) CREATE (a)-[:SCORED]->(b)
A call to a procedure that writes (merge.node, merge.relationship, the refactor.* procedures) always
counts as a conflicting write, because what it creates is only known once it runs.
A MERGE is a read too: it looks its pattern up once per row. A SET or REMOVE after it (directly, behind a
WITH, or inside a FOREACH) that writes a property key or a label the MERGE pattern matches on therefore runs
only after the MERGE has run for every row, exactly as if the two clauses ran one after the other:
// One node: both rows MERGE first (row 2 finds the node row 1 created), then both rows remove key.
UNWIND [1, 2] AS x
MERGE (n:Item {id: 1, key: 'value'})
SET n.key = null
A SET n = map or SET n += map whose keys are only known when the query runs (SET n += row, SET n = $props)
counts as writing every key, so UNWIND $rows AS row MERGE (n:Person {id: row.id}) SET n += row gets the barrier
too. Writes of other keys keep streaming: MERGE (n:Person {id: row.id}) SET n.name = row.name has no barrier, nor
does a MERGE that starts the query, which runs its lookup only once.
|
Up to 26.10.2 the |
WITH
The WITH clause allows query chaining by passing results from one part of a query to another. It can project, filter, aggregate, and paginate intermediate results.
Syntax:
WITH expression [AS alias] [, expression [AS alias] ...]
[WHERE condition]
[ORDER BY expression [ASC|DESC]]
[SKIP n]
[LIMIT n]
Examples:
// Basic projection
MATCH (p:Person)
WITH p.name AS name, p.age AS age
RETURN name, age ORDER BY name
// Filter after projection
MATCH (p:Person)
WITH p.name AS name, p.age AS age
WHERE age > 30
RETURN name
// Aggregation with WITH
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
WITH m.title AS movie, count(p) AS actorCount
WHERE actorCount > 2
RETURN movie, actorCount
// WITH DISTINCT
MATCH (p:Person)
WITH DISTINCT p.city AS city
RETURN city ORDER BY city
// Pagination with WITH
MATCH (p:Person)
WITH p.name AS name
ORDER BY name
SKIP 5
LIMIT 10
RETURN name
// Multiple WITH clauses
MATCH (p:Person)
WITH p.name AS name, p.age AS age
WHERE age > 25
WITH name, age
WHERE age < 50
RETURN name ORDER BY name
// WITH * (pass all variables)
MATCH (p:Person)
WITH *
WHERE p.age > 30
RETURN p.name
LET
LET (ISO GQL) adds new variables to every row without grouping or dropping the others. It is the same as WITH *, expression AS variable. Since v26.10.2.
MATCH (p:Person)
LET age = 2026 - p.birthYear, adult = age >= 18
RETURN p.name, age, adult
-
A later item can read an earlier one (
adultreadsageabove). -
LETdeclares a new variable: naming a variable already in scope is refused (Variable 'p' already declared). To replace a value, useWITH. -
LETcannot aggregate: useWITHforcount(),collect()and the other aggregations.
UNWIND
The UNWIND clause expands a list into individual rows.
Syntax:
UNWIND list AS variable
Examples:
// Unwind a literal list
UNWIND [1, 2, 3] AS x
RETURN x
// Unwind with range
UNWIND range(1, 10) AS num
RETURN num
// Unwind property array
MATCH (p:Person {name: 'Alice'})
UNWIND p.hobbies AS hobby
RETURN p.name, hobby
// Create from list
UNWIND ['Alice', 'Bob', 'Charlie'] AS name
CREATE (p:Person {name: name})
// Nested unwind
UNWIND [[1, 2], [3, 4]] AS inner
UNWIND inner AS num
RETURN num
// Combine with MATCH
MATCH (p:Person)
UNWIND p.skills AS skill
RETURN p.name, skill
ORDER BY
The ORDER BY clause sorts the results.
Syntax:
ORDER BY expression [ASC|DESC] [, expression [ASC|DESC] ...]
Examples:
// Ascending order (default)
MATCH (p:Person)
RETURN p.name
ORDER BY p.name
// Descending order
MATCH (p:Person)
RETURN p.name, p.age
ORDER BY p.age DESC
// Multiple sort keys
MATCH (p:Person)
RETURN p.name, p.age, p.city
ORDER BY p.city ASC, p.age DESC, p.name ASC
Strings are sorted by Unicode code point, the same order an index keeps them in (and the order Neo4j uses). The same
order applies to string comparisons (<, >, …) and to min()/max() (since v26.10.1).
BINARY values are sorted by their bytes, compared unsigned and one by one, a shorter value before a longer one it is
the start of: the order SQL’s ORDER BY and an index give them. Before v26.11.1 the order of two binary values changed
from one run of the query to the next. (Since v26.11.1)
The arrays of numbers (ARRAY_OF_SHORTS, ARRAY_OF_INTEGERS, ARRAY_OF_LONGS, ARRAY_OF_FLOATS, ARRAY_OF_DOUBLES) are sorted
element by element, as a list is. A BINARY or array value is one value wherever Cypher compares values: two records holding the
same bytes or elements are equal under =, one row under DISTINCT, UNION and collect(DISTINCT …), one group under an
aggregation, and min() / max() (and coll.min() / coll.max()) order them like ORDER BY does. A BINARY value is not a list: it compares with another BINARY value by its unsigned bytes, and is never equal to a list. A FLOAT is keyed by its decimal form, as = reads it, so 0.1 stored as a FLOAT and as a DOUBLE are one group. Before v26.11.1 (issue #10380) they were distinct and min() / max() answered the first row met. (Since v26.11.1)
SKIP and LIMIT
SKIP skips a number of results, and LIMIT restricts the maximum number of results returned. Used together for pagination.
Syntax:
SKIP number
LIMIT number
Examples:
// Limit results
MATCH (p:Person)
RETURN p.name
LIMIT 10
// Skip results
MATCH (p:Person)
RETURN p.name
ORDER BY p.name
SKIP 20
// Pagination (page 3, 10 items per page)
MATCH (p:Person)
RETURN p.name
ORDER BY p.name
SKIP 20
LIMIT 10
LIMIT after a write: a LIMIT that follows a write clause (CREATE, MERGE, SET, REMOVE, DELETE, FOREACH,
a write procedure or a subquery that writes) never cuts the write short. The write runs for every matched row first and
only then does LIMIT trim the rows returned, as in Neo4j. MATCH (n:Person) SET n.seen = true RETURN n LIMIT 10
therefore updates every Person and returns 10 of them. On a large input this means the first row comes back only after
all the writes are done.
Keyset pagination with an index: when the ORDER BY sorts by the indexed property the MATCH reads its range from,
and the query has a LIMIT, the rows are read directly from the index in the requested order: no sort is needed and
the scan stops as soon as the page is full, however many nodes match the range. This makes keyset (cursor)
pagination fast on large types (since v26.10.1):
// Next page: pass the last id of the previous page as $cursor
MATCH (p:Person) WHERE p.id > $cursor
RETURN p.id, p.name ORDER BY p.id LIMIT 10
// Previous page: the index is read backwards
MATCH (p:Person) WHERE p.id < $cursor
RETURN p.id, p.name ORDER BY p.id DESC LIMIT 10
// First page, with no WHERE: nodes without an id still come last
MATCH (p:Person)
RETURN p.id, p.name ORDER BY p.id LIMIT 10
It applies when the query only matches and returns (no WITH, UNWIND, aggregation or write clause in between), and
also works with SKIP, DISTINCT and relationship expansions from the ordered node. EXPLAIN and PROFILE show
[index order] on the NodeIndexRangeScan when it is used.
EXPLAIN
The EXPLAIN clause shows the query execution plan without actually executing the query. This is useful for understanding how the query optimizer processes your query and for performance tuning.
Syntax:
EXPLAIN query
Examples:
// Show execution plan for a simple query
EXPLAIN MATCH (p:Person) RETURN p
// Show plan for a relationship traversal
EXPLAIN MATCH (a:Person)-[:KNOWS]->(b:Person) WHERE a.name = 'Alice' RETURN b
// Show plan for a query with aggregation
EXPLAIN MATCH (p:Person) RETURN p.city, count(p)
Output:
The EXPLAIN output includes:
-
Whether the cost-based optimizer is being used
-
Physical operators in the execution plan (for optimized queries)
-
Estimated costs and row counts
-
Index usage information
-
An
EAGER (read/write barrier)step where the query reads its match into memory before writing - see Reads and Writes in the Same Query
OpenCypher Native Execution Plan
=================================
Using Cost-Based Query Optimizer
Physical Plan:
NodeIndexSeek (Person.name = 'Alice')
├── Estimated Cost: 10.00
└── Estimated Rows: 1
Total Estimated Cost: 10.00
Total Estimated Rows: 1
For queries that don’t use the optimizer, EXPLAIN shows:
OpenCypher Native Execution Plan
=================================
Using Traditional Execution (Non-Optimized)
Reason: Query pattern not yet supported by optimizer
Execution will use step-by-step interpretation
PROFILE
The PROFILE clause executes the query and returns performance metrics along with the actual results. Unlike EXPLAIN which only shows the plan, PROFILE runs the query and provides real execution statistics.
Because it runs the query, a PROFILE of a write (CREATE, MERGE, SET, DELETE, …) is refused on the read-only path (query(), /api/v1/query) exactly like the bare write; profile it through command() or POST /api/v1/command. EXPLAIN never runs the query and is accepted on either path. (Since v26.10.2)
A query that fails fails under PROFILE too, with the same error, and a write that fails is rolled back exactly as it is without PROFILE. (Since v26.11.1)
Before v26.11.1 a failing query under PROFILE answered no rows and no error: the error only appeared as an Error: line of the profile text, and the writes the query made before failing were committed. A client that relied on that line to detect a failure now receives the error itself.
|
Syntax:
PROFILE query
Examples:
// Profile a simple query
PROFILE MATCH (p:Person) RETURN p.name
// Profile a relationship traversal
PROFILE MATCH (a:Person)-[:KNOWS]->(b:Person) WHERE a.name = 'Alice' RETURN b.name
// Profile a query with aggregation
PROFILE MATCH (p:Person) RETURN p.city, count(p) AS count
Output:
The PROFILE output includes:
-
Execution time in milliseconds
-
Number of rows returned
-
Query execution plan details (when using optimizer)
OpenCypher Query Profile
=========================
Execution Time: 15 ms
Rows Returned: 42
Physical Plan:
NodeTypeScan (Person)
└── Estimated Rows: 50
Actual Results Follow...
| The profile information is returned as the first row in the result set, followed by the actual query results. |
UNION and UNION ALL
The UNION clause combines the results of two or more queries into a single result set. UNION removes duplicate rows, while UNION ALL keeps all rows including duplicates.
Syntax:
query1
UNION [ALL]
query2
[UNION [ALL]
query3 ...]
| All queries in a UNION must return the same column names. |
Examples:
// UNION - removes duplicate results
MATCH (p:Person) RETURN p.name AS name
UNION
MATCH (c:Company) RETURN c.name AS name
// UNION ALL - keeps all results including duplicates
MATCH (p:Person) RETURN p.name AS name
UNION ALL
MATCH (c:Company) RETURN c.name AS name
// Multiple UNIONNs
MATCH (p:Person) RETURN p.name AS name
UNION
MATCH (c:Company) RETURN c.name AS name
UNION
MATCH (l:Location) RETURN l.name AS name
// UNION with WHERE clauses
MATCH (p:Person) WHERE p.age > 30 RETURN p.name AS name, 'person' AS type
UNION ALL
MATCH (c:Company) WHERE c.employees > 100 RETURN c.name AS name, 'company' AS type
Behavior:
-
UNIONremoves duplicate rows from the combined result (likeSELECT DISTINCTin SQL) -
UNION ALLpreserves all rows, including duplicates (more efficient when duplicates are acceptable) -
Results are returned in the order they are produced by each query
Conditional query: WHEN … THEN … ELSE
A conditional query (Cypher 25) runs the first branch whose condition is true, the ELSE branch when none is, and returns no rows when none is true and there is no ELSE. Since v26.10.2.
WHEN $mode = 'all' THEN MATCH (p:Person) RETURN p.name AS name
WHEN $mode = 'adults' THEN MATCH (p:Person) WHERE p.age >= 18 RETURN p.name AS name
ELSE RETURN null AS name
-
A conditional query is a whole statement: it cannot be nested in a subquery.
-
Conditions read parameters only, not variables. A null condition is not taken; a condition that is not a boolean is an error.
-
Every branch must return the same columns in the same order.
-
Only the branch taken runs, so the writes of the other branches never happen. A query with any writing branch is still a write statement: run it as a command, not as a read-only query.
-
Every parameter the query names must be bound, also the parameters of branches that are not taken.
CALL
The CALL clause invokes procedures and functions. This includes built-in database procedures as well as custom functions defined via SQL’s DEFINE FUNCTION.
Syntax:
[OPTIONAL] CALL procedureName([arguments])
[YIELD field [AS alias] [, field [AS alias] ...]]
[WHERE condition]
Built-in Procedures:
| Procedure | Description |
|---|---|
|
Returns all vertex type names (labels) |
|
Returns all edge type names |
|
Returns all property keys defined in the schema |
|
Returns schema visualization data |
|
Returns schema visualization data (alias) |
These procedures only return the types the current user is allowed to read, like SELECT FROM schema:types in SQL. (Since v26.10.1)
The procedure arguments and the WHERE after YIELD can read every variable bound before the CALL, as in Neo4j:
MATCH (a)-[r:KNOWS]→(b) CALL my.proc(r) YIELD value WHERE value.weight > r.weight RETURN value. (Since v26.11.1:
before, the WHERE saw only the yielded fields, and a relationship used only in the CALL was null there.)
Examples:
// List all vertex types (labels)
CALL db.labels()
// List all relationship types
CALL db.relationshipTypes()
// List all property keys
CALL db.propertyKeys()
// Get schema information
CALL db.schema()
Vector Search Procedures:
ArcadeDB provides built-in procedures for performing approximate nearest neighbor (ANN) vector search using HNSW indexes. These are accessible from Cypher via CALL and use the same high-performance index as SQL’s vector.neighbors().
| Procedure | Description |
|---|---|
|
Neo4j-compatible vector search. Returns |
|
ArcadeDB-native vector search. Returns document properties and |
The indexSpec parameter uses ArcadeDB’s 'Type[property]' format (e.g., 'Document[embedding]').
|
A WHERE after the YIELD runs on the k rows the search already returned. When most of the nearest neighbors fail the condition (the rows of another tenant, for example), fewer than k rows come back, or none. To filter during the search, pass the where or filter option: as the fourth argument of db.index.vector.queryNodes(), or in the options map of vector.neighbors() (see the examples below). The search then returns k matching rows whenever the index holds that many. Before 26.11.1, db.index.vector.queryNodes() takes no options and vector.neighbors() has filter but not where.
|
Examples:
// Neo4j-compatible syntax — returns node and score (similarity)
CALL db.index.vector.queryNodes('Document[embedding]', 10, $queryVector)
YIELD node, score
RETURN node.title AS title, score
ORDER BY score DESC
// ArcadeDB-native syntax — returns properties and distance
CALL vector.neighbors('Document[embedding]', $queryVector, 10)
YIELD name, distance
RETURN name, distance
ORDER BY distance
// Filter during the search: the 10 nearest rows of one tenant, however many rows the other tenants hold
CALL db.index.vector.queryNodes('Document[embedding]', 10, $queryVector, { where: { tenantId: 'acme' } })
YIELD node, score
RETURN node.title AS title, score
ORDER BY score DESC
// The same filter with the ArcadeDB-native procedure
CALL vector.neighbors('Document[embedding]', $queryVector, 10, { where: { tenantId: 'acme' } })
YIELD name, distance
RETURN name, distance
ORDER BY distance
// With parameters for k and vector
CALL db.index.vector.queryNodes('Document[embedding]', $k, $vec)
YIELD node, score
RETURN node.title AS title, score
// Search specific child type in an inheritance hierarchy
CALL db.index.vector.queryNodes('EMBEDDING_IMAGE[vector]', 5, $vec)
YIELD node, score
RETURN node.name, score
These procedures use the HNSW vector index for fast approximate search (O(log n)). Do not use vector.cosinesimilarity() inside a MATCH loop — that performs a brute-force O(n) scan over all documents. Always use CALL with a vector index for production workloads.
|
Full-Text Search Procedures:
ArcadeDB provides built-in procedures for searching a FULL_TEXT index (BM25-ranked) directly from Cypher, so text search and graph pattern matching can be combined in one statement. These are Neo4j-compatible equivalents of SQL’s SEARCH_INDEX(); see the "Full-Text Index" how-to guide for how to create and configure one.
| Procedure | Description |
|---|---|
|
Searches a full-text index on a vertex type. Returns |
|
Searches a full-text index on an edge type. Returns |
The indexName parameter uses ArcadeDB’s 'Type[property]' format (e.g., 'Article[content]'), and query accepts Lucene query syntax.
|
Rows come back ordered by descending score, with ties broken by record id. The optional options map (since v26.10.1)
pages over that order with two keys, both non-negative integers: skip (rows to drop from the top, default 0) and limit
(rows to return, default unlimited). Neo4j also accepts an analyzer key; ArcadeDB refuses it by name, because the analyzer
is fixed by the index metadata when the index is created and cannot be overridden per query. Any other key is refused too.
Examples:
// Search and rank by relevance
CALL db.index.fulltext.queryNodes('Article[content]', 'java AND database')
YIELD node, score
RETURN node.title AS title, score
ORDER BY score DESC
// Second page of ten results
CALL db.index.fulltext.queryNodes('Article[content]', 'java AND database', {skip: 10, limit: 10})
YIELD node, score
RETURN node.title AS title, score
// Search relationships and continue graph pattern matching
CALL db.index.fulltext.queryRelationships('Cites[note]', 'java')
YIELD relationship, score
MATCH (a:Article)-[c:Cites]->(b:Article) WHERE c = relationship
RETURN a.title AS from, b.title AS to, score
ORDER BY score DESC
A MATCH may follow CALL … YIELD directly, as above: the rows the procedure yields feed
straight into the pattern, and an intervening WITH is not required.
Calling User Functions:
User functions defined via SQL’s DEFINE FUNCTION statement can be called using the CALL clause with their namespace. Functions can be defined in SQL, JavaScript, OpenCypher, or Java and are callable from any query language.
// Define user functions in different languages
DEFINE FUNCTION math.add "SELECT :a + :b AS result" PARAMETERS [a,b] LANGUAGE sql
DEFINE FUNCTION js.multiply "return x * y" PARAMETERS [x,y] LANGUAGE js
DEFINE FUNCTION cypher.double "RETURN $n * 2" PARAMETERS [n] LANGUAGE opencypher
// Call user functions from Cypher using CALL clause
CALL math.add(3, 5)
// Returns: {value: 8}
CALL js.multiply(4, 2)
// Returns: {value: 8}
CALL cypher.double(5)
// Returns: {value: 10}
// Or call directly in expressions
RETURN math.add(3, 5) AS sum,
js.multiply(4, 2) AS product,
cypher.double(5) AS doubled
// Returns: {sum: 8, product: 8, doubled: 10}
See User Functions for complete documentation on defining and using functions.
Using YIELD:
The YIELD clause filters and renames the columns returned by a procedure:
// Get only the label column
CALL db.labels() YIELD label
// Rename the output column
CALL db.labels() YIELD label AS vertexType
// Filter results with WHERE
CALL db.labels() YIELD label
WHERE label STARTS WITH 'P'
RETURN label
// Yield all columns
CALL db.propertyKeys() YIELD *
When calling a custom DEFINE FUNCTION (SQL, JavaScript, OpenCypher, or Java), the result is wrapped in a value property, regardless of the language it was defined in. Built-in procedures like db.labels() return results with specific property names like label, relationshipType, or propertyKey.
|
OPTIONAL CALL:
OPTIONAL CALL is to CALL what OPTIONAL MATCH is to MATCH: when the procedure yields no row for an incoming row - none at all, or none that passes the YIELD … WHERE filter - the incoming row is kept and every yielded name is bound to null, instead of the row being dropped.
// Isolated nodes are returned too, with reached = null
MATCH (s:Person)
OPTIONAL CALL algo.bfs(s) YIELD node
RETURN s.name, node.name AS reached
Before v26.10.2 an OPTIONAL CALL whose procedure ran but yielded no rows dropped the incoming row, like a plain CALL; only a procedure that failed produced the null row. Queries such as MATCH (s) OPTIONAL CALL algo.bfs(s) YIELD node RETURN count(*) now also count the nodes that reach nothing.
|
Subqueries (CALL { … }):
Cypher supports subqueries using the CALL { … } syntax. Subqueries are enclosed in curly braces and can import variables from the outer scope using WITH.
// Basic subquery with imported variable
UNWIND [1, 2, 3] AS x
CALL {
WITH x
RETURN x * 10 AS y
}
RETURN x, y
// Subquery with MATCH
MATCH (n:Item)
CALL {
WITH n
RETURN n.value * 2 AS doubled
}
RETURN n.name, doubled
// Multiple imported variables
UNWIND [1, 2] AS a
UNWIND [10, 20] AS b
CALL {
WITH a, b
RETURN a + b AS sum
}
RETURN a, b, sum
Subqueries allow you to isolate complex query logic, perform computations on imported variables, and return results back to the outer query.
Subqueries IN TRANSACTIONS:
When importing large datasets (e.g., from LOAD CSV), you can use CALL { … } IN TRANSACTIONS to commit changes in batches rather than accumulating all changes in a single transaction. This prevents running out of memory and allows progress to be committed incrementally.
// Commit every 1000 rows (default batch size)
LOAD CSV WITH HEADERS FROM 'file:///data.csv' AS row
CALL {
WITH row
CREATE (n:Person {name: row.name, age: toInteger(row.age)})
} IN TRANSACTIONS
// Custom batch size
LOAD CSV WITH HEADERS FROM 'file:///data.csv' AS row
CALL {
WITH row
CREATE (n:Person {name: row.name})
} IN TRANSACTIONS OF 500 ROWS
IN TRANSACTIONS should not be used inside an already open explicit transaction. The subquery manages its own transaction boundaries.
|
FOREACH
The FOREACH clause iterates over a list and executes write operations (CREATE, SET, DELETE, MERGE) for each element. Unlike other clauses, FOREACH does not modify the result set - it passes through the input rows unchanged.
Syntax:
FOREACH (variable IN list |
update_clause [update_clause ...]
)
Examples:
// Create multiple nodes from a list
FOREACH (name IN ['Alice', 'Bob', 'Charlie'] |
CREATE (:Person {name: name})
)
// Create relationships in a loop
CREATE (root:Root {name: 'Main'})
FOREACH (i IN [1, 2, 3] |
CREATE (root)-[:HAS_ITEM]->(:Item {id: i})
)
// Use with MATCH context
MATCH (p:Person)
FOREACH (tag IN ['developer', 'tester'] |
CREATE (p)-[:HAS_TAG]->(:Tag {value: tag})
)
// Update properties with SET
MATCH (item:Item)
WITH collect(item) AS items
FOREACH (item IN items |
SET item.status = 'processed', item.timestamp = timestamp()
)
// FOREACH passes through input rows
CREATE (root:Root {name: 'test'})
FOREACH (i IN [1, 2] |
CREATE (:Child {id: i})
)
RETURN root.name AS name
// Returns: {name: 'test'}
Important Notes:
-
FOREACH can only contain write operations (CREATE, SET, DELETE, MERGE)
-
FOREACH does not modify the result set - it passes input rows through unchanged, and an empty list therefore leaves the query’s rows exactly as they were
-
Variables defined inside FOREACH are not visible outside the clause
-
FOREACH is useful for batch operations and creating multiple elements from lists
-
What a FOREACH body writes is subject to Reads and Writes in the Same Query like any other write
LOAD CSV
The LOAD CSV clause reads data from a CSV file (local or remote) and makes each row available as a variable for further query processing. This is the primary mechanism for bulk-importing data into ArcadeDB from CSV files.
Syntax:
LOAD CSV FROM <url> AS <variable>
LOAD CSV WITH HEADERS FROM <url> AS <variable>
LOAD CSV WITH HEADERS FROM <url> AS <variable> FIELDTERMINATOR <delimiter>
-
Without
WITH HEADERS: Each row is bound as a list of strings. Access fields by index:row[0],row[1], etc. -
With
WITH HEADERS: The first row is treated as column names. Each subsequent row is bound as a map. Access fields by name:row.name,row['age'], etc. -
FIELDTERMINATOR: Specify a custom field delimiter (default is
,). Example:FIELDTERMINATOR ';'for semicolon-separated files.
Basic Examples:
// Load CSV without headers (list access)
LOAD CSV FROM 'file:///people.csv' AS row
CREATE (:Person {name: row[0], age: toInteger(row[1])})
// Load CSV with headers (map access)
LOAD CSV WITH HEADERS FROM 'file:///people.csv' AS row
CREATE (:Person {name: row.name, age: toInteger(row.age)})
// Custom field delimiter
LOAD CSV WITH HEADERS FROM 'file:///data.tsv' AS row FIELDTERMINATOR '\t'
RETURN row.name, row.value
// Load from HTTP URL
LOAD CSV WITH HEADERS FROM 'https://example.com/data.csv' AS row
CREATE (:Item {id: row.id, label: row.label})
Bulk Import with CALL IN TRANSACTIONS:
For large files, combine LOAD CSV with CALL { … } IN TRANSACTIONS to commit in batches and avoid memory issues:
LOAD CSV WITH HEADERS FROM 'file:///large-dataset.csv' AS row
CALL {
WITH row
CREATE (p:Person {name: row.name, email: row.email})
} IN TRANSACTIONS OF 1000 ROWS
Compressed Files:
LOAD CSV transparently decompresses .gz (gzip) and .zip files:
// Gzip compressed
LOAD CSV WITH HEADERS FROM 'file:///data.csv.gz' AS row
RETURN row.name
// ZIP compressed (reads the first entry in the archive)
LOAD CSV WITH HEADERS FROM 'file:///data.csv.zip' AS row
RETURN row.name
CSV Quoting:
Quoted fields follow RFC 4180 and also support backslash escaping:
-
Fields containing the delimiter, newlines, or quotes must be enclosed in double quotes
-
Embedded quotes can be escaped as
""(RFC 4180) or\"(backslash escaping) -
Quoted fields can span multiple lines
Security Configuration:
LOAD CSV file access is controlled by two configuration settings:
| Setting | Default | Description |
|---|---|---|
|
|
Allow access to local files via |
|
(empty) |
Root directory for file URLs. When set, file paths are resolved relative to this directory and path traversal ( |
HTTP/HTTPS URLs are always allowed regardless of these settings.
Example — Restrict to import directory:
// In server configuration
arcadedb.opencypher.loadCsv.importDirectory=/var/arcadedb/import
// This works (resolved to /var/arcadedb/import/data.csv)
LOAD CSV FROM 'file:///data.csv' AS row RETURN row
// This is blocked (path traversal)
LOAD CSV FROM 'file:///../etc/passwd' AS row RETURN row
Built-in Helper Functions:
Two special functions are available within LOAD CSV queries:
-
file()— Returns the URL of the currently loaded CSV file -
linenumber()— Returns the current line number being processed (1-based)
Both functions work anywhere in the rest of the query following LOAD CSV, including inside a WHERE predicate — not only in RETURN/WITH projections — and they remain valid even after a WITH that does not re-project the CSV row variable, and after a WITH … ORDER BY. This makes WITH row WHERE linenumber() > 1 …, the common idiom for skipping a header row, work as expected.
Both functions describe the CSV row currently being processed, so an aggregation ends them: after a clause such as WITH collect(row) AS rows or WITH count(*) AS total, many rows have been folded into one and there is no single line for linenumber() to report, so file() and linenumber() return null for the rest of the query — including after an UNWIND that expands the collected list back into rows. This matches Neo4j. If you need the file or line further downstream, capture it into a variable before the aggregation, for example WITH row, file() AS csvFile, linenumber() AS csvLine.
|
CREATE CONSTRAINT
The CREATE CONSTRAINT clause defines schema constraints on node or relationship properties. Constraints enforce data integrity rules such as uniqueness, mandatory (NOT NULL), and node keys.
ArcadeDB maps Cypher constraints to its native schema features:
-
IS UNIQUE → Creates a unique LSM-Tree index on the specified properties
-
IS NOT NULL → Sets the property as mandatory in the schema
-
IS NODE KEY / IS KEY → Creates a unique index and sets the property as mandatory
-
IS TYPED
<type>→ Declares the property’s data type in the schema (see IS TYPED data types)
Syntax:
CREATE CONSTRAINT [name] [IF NOT EXISTS]
FOR (variable:Label)
REQUIRE variable.property IS UNIQUE
CREATE CONSTRAINT [name] [IF NOT EXISTS]
FOR (variable:Label)
REQUIRE variable.property IS NOT NULL
CREATE CONSTRAINT [name] [IF NOT EXISTS]
FOR (variable:Label)
REQUIRE variable.property IS NODE KEY
CREATE CONSTRAINT [name] [IF NOT EXISTS]
FOR (variable:Label)
REQUIRE variable.property IS TYPED <type>
- `IS
-
<type>` is a synonym of
IS TYPED <type>.
Examples:
// Unique constraint on a single property
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.email IS UNIQUE
// Named constraint with IF NOT EXISTS (idempotent)
CREATE CONSTRAINT unique_person_email IF NOT EXISTS
FOR (p:Person) REQUIRE p.email IS UNIQUE
// Composite unique constraint (multiple properties)
CREATE CONSTRAINT FOR (p:Person) REQUIRE (p.firstName, p.lastName) IS UNIQUE
// NOT NULL constraint (mandatory property)
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.name IS NOT NULL
// Node key constraint (unique + mandatory)
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.id IS NODE KEY
// Relationship constraint
CREATE CONSTRAINT FOR ()-[r:KNOWS]-() REQUIRE r.since IS NOT NULL
// Typed constraint: declare the property's data type
CREATE CONSTRAINT FOR (m:Measure) REQUIRE m.temperature IS TYPED FLOAT32
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.age IS TYPED INT16
// Typed constraint on a list: declares the property as LIST OF STRING
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.tags IS :: LIST<STRING NOT NULL>
If the property does not already exist in the schema, ArcadeDB will automatically create it as a STRING type. For other types, define the property first using SQL (ALTER TYPE Person CREATE PROPERTY email STRING).
|
A constraint name is unique across the whole database. (Since v26.10.2) CREATE CONSTRAINT <name> with a name already in use raises an error before creating anything; with IF NOT EXISTS it is a no-op. Running the same statement with IF NOT EXISTS several times therefore never raises. In v26.10.1 and earlier a duplicate name was accepted.
|
Constraint names:
(Since v26.10.2) A constraint created without a name gets a generated one, constraint_<Label>_<property>[<property>…]<kind>, where the kind is unique, key, existence (for IS NOT NULL) or type (for IS TYPED):
| Statement | Generated name |
|---|---|
|
|
|
|
|
|
|
|
|
|
The name is derived from the statement, so repeating the same unnamed statement does not create a second constraint. It is the name SHOW CONSTRAINTS lists and DROP CONSTRAINT takes.
(Since v26.10.2) A property carries one property type constraint at most: a second IS TYPED constraint that requires a different type of the same property is refused, as in Neo4j. Drop the first one before creating the other.
|
When existence constraints are checked:
A property declared mandatory with IS NOT NULL (or IS NODE KEY) is checked when the statement finishes, not at the instant the node or relationship is created. This is what makes the standard upsert work, since the SET supplies the property after the pattern has created the node:
CREATE CONSTRAINT rec_org_exists IF NOT EXISTS FOR (n:Record) REQUIRE n.orgId IS NOT NULL;
MERGE (n:Record {id: $id}) SET n.orgId = $orgId RETURN n
Any clause of the same statement may supply the property. A statement that ends without supplying it fails, and the incomplete record is removed.
Only CREATE/MERGE pattern writes are deferred this way. Every other constraint (IS TYPED, min, max, regular expressions, read-only) is still checked at the moment of the write, as are writes issued from SQL, the Java API and CALL procedures.
| Between the pattern creating the record and the statement ending, another connection can briefly read a record that does not yet satisfy its existence constraints. If the server stops in that window, the incomplete record is left behind. |
A record left behind that way is not lost track of: it is, by definition, a record that does not satisfy its own type’s existence constraints, and since v26.10.1 CHECK DATABASE asks exactly that question of every record it scans. The findings come back under constraintViolatingRecords, and CHECK DATABASE FIX DELETE INVALID RECORDS removes them; a record that holds data worth keeping is instead completed by a single UPDATE supplying every missing property. See Existence constraints.
IS TYPED data types:
IS TYPED <type> declares the property’s data type in the schema. ArcadeDB maps the ISO/IEC 39075 (GQL) type names onto its native types. Numeric values are stored and reloaded at the declared width, so a value written to a property declared IS TYPED INT16 round-trips as a 16-bit integer and continues to satisfy the x IS TYPED INT16 value-type predicate.
| GQL / Cypher type | ArcadeDB type | Notes |
|---|---|---|
|
|
|
|
|
|
|
|
8-bit signed integer |
|
|
16-bit signed integer |
|
|
32-bit signed integer ( |
|
|
64-bit signed integer ( |
|
|
32-bit IEEE 754 floating point |
|
|
64-bit IEEE 754 floating point |
|
|
|
|
|
Nanosecond precision. A property already declared |
|
|
Keeps the zone |
|
|
|
|
|
Keeps the offset |
|
|
|
|
|
The map |
|
|
For example |
|
|
A list with no element type |
|
|
The LOCAL DATETIME, ZONED DATETIME, LOCAL TIME, ZONED TIME and DURATION types map onto the native ArcadeDB types that hold the value without loss (see Managing Dates). A list of lists (LIST<LIST<STRING>>) and a union type (STRING | INTEGER) cannot be declared and are refused.
- A
NOT NULLon the type itself, as in `REQUIRE n.p IS -
STRING NOT NULL`, is refused, as in Neo4j: existence is a constraint of its own. Declare the type and add an existence constraint:
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.name IS :: STRING
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.name IS NOT NULL
A NOT NULL on the element type of a list (LIST<STRING NOT NULL>) is accepted.
Re-typing a declared property:
- When the property is already declared with another type,
IS TYPED <type>(or `IS -
<type>`) re-declares it with the new type. (Since v26.10.2) The attributes that do not depend on the type are always kept:
MANDATORY,NOTNULL,READONLY,HIDDEN,EXTERNAL,COMPRESSIONand custom values. The type-dependent ones are kept only when the new type reads them the same way:-
OFwhen both types are aLISTor aMAP; -
REGEXPwhen the new type isSTRING; -
MINandMAXwhen both types measure the same thing (characters, bytes, items, a numeric value or a date) and the new type can hold the bound, somax 4moves fromINTEGERtoLONGbut not fromSTRINGtoLONG, andmax 2.5does not move onto anINTEGER; -
DEFAULTwhen it converts to the new type without changing its value (aDOUBLEdefault3.0moves onto aLONG,3.7does not).
-
- Otherwise the statement is refused with an error naming each attribute that blocks it, and the declaration is left unchanged: remove or change the attribute with
ALTER PROPERTYfirst. The stored records are then checked against the new declaration, asCREATE PROPERTYchecks them: if a record holds a value of another type, or breaks a keptNOT NULL,MANDATORY,REGEXP,MINorMAX, the constraint is refused and the previous declaration is put back. In v26.10.1 and earlier the property was dropped and recreated with the new type alone, so every other attribute was lost without a warning (aREQUIRE n.code IS NOT NULLfollowed by `REQUIRE n.code IS -
INTEGER` left
codeoptional), and the stored records were not checked.
Following Cypher 25 / GQL, INTEGER is the 64-bit generic integer type while INT is the alias for the 32-bit INT32. A property declared IS TYPED INT therefore holds 32-bit values; use IS TYPED INTEGER (or INT64) for 64-bit values.
|
|
When upgrading to 26.10.2: before, a property type constraint refused a list with an element type ( |
DROP CONSTRAINT
The DROP CONSTRAINT clause removes a previously created constraint by its name: the name given in CREATE CONSTRAINT, the generated name of a constraint created without one, or any name SHOW CONSTRAINTS lists.
Syntax:
DROP CONSTRAINT constraintName [IF EXISTS]
Examples:
// Drop a constraint by name
DROP CONSTRAINT constraint_Person_email_unique
// Drop the constraint that created the unique index Person[email]
DROP CONSTRAINT `Person[email]`
// Drop only if it exists (no error if missing)
DROP CONSTRAINT `Person[email]` IF EXISTS
The name of the unique index a UNIQUE or NODE KEY constraint created is accepted too, and drops the constraint that created it: an unnamed NODE KEY dropped by its index name is dropped whole, index and existence requirement. Use backtick escaping for names containing special characters like brackets.
|
(Since v26.10.2) dropping a name that no constraint or index carries raises an error, unless IF EXISTS is given, which makes the statement a no-op. In v26.10.1 and earlier an unknown name was dropped silently and counted as removed.
|
What dropping a constraint undoes:
(Since v26.10.2) DROP CONSTRAINT undoes what the constraint created:
-
A
UNIQUEconstraint drops its unique index, aNOT NULLconstraint clears the mandatory flag it set, and aNODE KEY/KEYconstraint does both. -
Only what the constraint itself created is undone. A
MANDATORYflag or a unique index declared before the constraint, for example with SQLCREATE PROPERTY … (mandatory true)orCREATE INDEX, is kept when the constraint is dropped, and so is an index or a flag another named constraint still needs. -
A property type constraint (
IS TYPED) lifts the type restriction. If the constraint declared the property, the declaration is removed. If it replaced an earlier declaration, for example one made with SQLCREATE PROPERTY, that declaration is put back; when records written since cannot be read as the old type, the drop is refused and the constraint stays in place. -
While another constraint still needs the declaration, the type stays until that one is dropped too: a
NOT NULLconstraint needs a declared property to carry its mandatory flag, and aUNIQUEindex is built over the declared type. -
A requirement the schema enforces without a Cypher constraint, which
SHOW CONSTRAINTSlists under a generated name, is dropped the same way:constraint_<Type>_<property>_existenceclears the property’sMANDATORYandNOT NULLflags,constraint_<Type>_<property>_typeremoves the property declaration (refused while the declaration also carries an index or other attributes, such as a default or a min/max, that would go with it), and the name of a unique index drops the index.
|
When upgrading to 26.10.2: before, dropping a property type constraint left the property declared with the constraint’s type, so the type restriction stayed in force after the constraint was gone. |
SHOW CONSTRAINTS
SHOW CONSTRAINTS lists the constraints of the database with Neo4j’s syntax and columns, so the tools and drivers written for Neo4j read it unchanged. (Since v26.10.2) It is answered by the openCypher engine, so it works the same over every protocol that runs Cypher: HTTP, Studio, Bolt and the embedded Java API.
Syntax:
SHOW [ALL] CONSTRAINTS
SHOW [NODE | RELATIONSHIP] {UNIQUE | UNIQUENESS | EXIST | EXISTENCE | KEY | PROPERTY TYPE} CONSTRAINTS
The keyword before CONSTRAINTS restricts the list to one kind of constraint, and a preceding NODE or RELATIONSHIP to the constraints of vertex types or of edge types. The command accepts YIELD, WHERE, ORDER BY, SKIP, LIMIT and RETURN like every SHOW command, see Filtering the output of a SHOW command.
Result columns:
| Column | Description |
|---|---|
|
A sequence number of the row |
|
The constraint name, the one |
|
|
|
|
|
The label or relationship type, as a list |
|
The constrained properties, as a list |
|
The unique index that enforces a uniqueness or key constraint, otherwise |
|
The Cypher type a property type constraint requires, otherwise |
YIELD * adds the columns enforcedLabel, options and createStatement, the CREATE CONSTRAINT statement that recreates the constraint.
The list shows what the schema enforces, whether it was created with Cypher, SQL or the Java API. A requirement that no Cypher constraint created is listed as follows:
-
a unique index, as a uniqueness constraint named after the index;
-
a property declared
MANDATORYorNOT NULL, as an existence constraint namedconstraint_<Type>_<property>_existence; -
a declared property type, as a property type constraint named
constraint_<Type>_<property>_type.
Every name the list shows can be dropped with DROP CONSTRAINT. The types the user is not allowed to read are left out.
Examples:
// Every constraint
SHOW CONSTRAINTS
// The uniqueness constraints of vertex types
SHOW NODE UNIQUENESS CONSTRAINTS
// The property type constraints of one label
SHOW PROPERTY TYPE CONSTRAINTS YIELD name, labelsOrTypes, properties, propertyType
WHERE 'Person' IN labelsOrTypes
// The statements that recreate them
SHOW CONSTRAINTS YIELD name, createStatement ORDER BY name
CREATE INDEX / DROP INDEX
CREATE INDEX creates a (non-unique) LSM_TREE index on one or more properties of a label or relationship type, with Neo4j’s syntax. RANGE is accepted as a synonym of the plain form.
CREATE INDEX person_name FOR (p:Person) ON (p.name)
CREATE INDEX IF NOT EXISTS FOR (p:Person) ON (p.firstName, p.lastName)
CREATE INDEX follows_since FOR ()-[r:FOLLOWS]-() ON (r.since)
DROP INDEX person_name
DROP INDEX person_name IF EXISTS
(Since v26.10.2) The index is created under the name the statement gives it, which is the name SHOW INDEXES lists, DROP INDEX takes, and SQL sees. An index created without a name is named <Type>[<property>,…], for example Person[name]: use backtick escaping to drop it, DROP INDEX `Person[name]`. Before 26.10.2 the name was ignored, so DROP INDEX person_name dropped nothing.
Index and constraint names share one namespace, as in Neo4j:
-
a name already taken by an index or a constraint is refused, and with
IF NOT EXISTSthe statement does nothing; -
an index already defined on the same properties is refused under another name, and with
IF NOT EXISTSthe statement does nothing; -
DROP INDEXof a name no index has raises an error unlessIF EXISTSis given; -
the index a
UNIQUEorNODE KEYconstraint created is dropped withDROP CONSTRAINT:DROP INDEXrefuses it, since the constraint would stay listed with nothing enforcing it.
CREATE VECTOR INDEX / CREATE FULLTEXT INDEX
(Since v26.11.1) Neo4j’s CREATE VECTOR INDEX creates an LSM_VECTOR index and CREATE FULLTEXT INDEX a FULL_TEXT index, so a Neo4j application can create the indexes SEARCH, db.index.vector.queryNodes and db.index.fulltext.queryNodes query. Both keep the given name and honour IF NOT EXISTS, on a label or on a relationship type. A vector index over existing records is built before the statement returns, so it is ONLINE at once and needs no db.awaitIndex.
CREATE VECTOR INDEX moviePlots IF NOT EXISTS FOR (m:Movie) ON m.embedding
OPTIONS { indexConfig: { `vector.dimensions`: 1536, `vector.similarity_function`: 'cosine' } }
CREATE VECTOR INDEX moviePlots FOR (m:Movie) ON m.embedding
OPTIONS { indexConfig: { `vector.dimensions`: 1536, `vector.quantization.type`: 'scalar', efSearch: 200 } }
CREATE FULLTEXT INDEX titles FOR (n:Movie) ON EACH [n.title, n.tagline]
OPTIONS { indexConfig: { `fulltext.analyzer`: 'english', defaultOperator: 'AND' } }
An undeclared vector property is declared ARRAY_OF_FLOATS. The Neo4j settings of indexConfig map onto the index settings:
| Neo4j setting | ArcadeDB setting |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
accepted with either value: the index is updated by the transaction that writes the record |
With no quantization setting the index keeps the exact vectors (NONE), while Neo4j quantizes by default (binary since Neo4j 2026.08, scalar before): a quantized index has a lower recall, which a script must ask for. vector.default_search_expansion_factor is refused: the search beam of an LSM_VECTOR index is efSearch.
Every other setting of the index is given in indexConfig under its own name, the one the METADATA of SQL CREATE INDEX takes (efSearch, quantization: 'PRODUCT', pqSubspaces, bm25_k1, <property>_boost, …), and is checked the same way. A setting named both ways (vector.hnsw.m and maxConnections) or not known is refused, and the statement then creates nothing.
The createStatement column of SHOW INDEXES names every setting of a vector or full-text index that differs from its default, and always the quantization, so it creates the same index again (an odd maxConnections has no vector.hnsw.m spelling, so it is listed under its own name); the options column holds the same indexConfig with the indexProvider.
Not supported yet: an index over several labels or relationship types (FOR (n:A|B)), the filtering properties of CREATE VECTOR INDEX … WITH […], a vector index without vector.dimensions, and the standard analyzer (with English stop words).
SHOW INDEXES
SHOW INDEXES lists the indexes of the database with Neo4j’s syntax and columns. (Since v26.10.2) Like SHOW CONSTRAINTS, it is answered by the openCypher engine over every protocol (HTTP, Studio, Bolt, embedded).
Syntax:
SHOW [ALL | RANGE | TEXT | POINT | VECTOR | FULLTEXT | LOOKUP] INDEXES
The keyword after SHOW restricts the list to one type of index, matched against the type column. ArcadeDB has no TEXT or LOOKUP index, so those two list nothing. YIELD, WHERE, ORDER BY, SKIP, LIMIT and RETURN work as on every SHOW command.
Result columns:
| Column | Description |
|---|---|
|
A sequence number of the row |
|
The index name |
|
|
|
|
|
|
|
|
|
The label or relationship type, as a list |
|
The indexed properties, as a list |
|
The index implementation, for example |
|
The name of the constraint that created the index (for a unique index created without a constraint, the index name, which is the name |
|
Always |
YIELD * adds the columns trackedSince, options, failureMessage and createStatement.
Examples:
// Every index
SHOW INDEXES
// The indexes no constraint owns
SHOW INDEXES YIELD name, type, labelsOrTypes, properties, owningConstraint
WHERE owningConstraint IS NULL
// Count the vector indexes
SHOW VECTOR INDEXES YIELD name RETURN count(name) AS total
SHOW USERS
The SHOW USERS command lists all users configured on the server, along with the databases each user has access to.
This command requires ArcadeDB to be running in server mode (it does not work in embedded mode). See Security - Users for more information about the ArcadeDB security model.
| All user management commands are non-idempotent and require a POST request (command endpoint) when executed via the HTTP API. |
Syntax:
SHOW USERS
Result columns:
| Column | Description |
|---|---|
|
The user name |
|
The set of databases the user has access to ( |
Examples:
// List all users
SHOW USERS
// Only the rows that match
SHOW USERS WHERE user = $name
// Pick the columns to return
SHOW USERS YIELD user WHERE user STARTS WITH 'app_'
Filtering the output of a SHOW command:
Every SHOW command accepts an optional trailing WHERE, or a YIELD that selects columns and may itself be followed by ORDER BY, SKIP, LIMIT, WHERE and RETURN:
SHOW DATABASES WHERE name = $dbName
SHOW DATABASES YIELD name, currentStatus WHERE name STARTS WITH 'prod'
SHOW DATABASES YIELD name ORDER BY name SKIP 1 LIMIT 5
SHOW INDEXES YIELD name, type RETURN count(name) AS total
The filter is evaluated by the Cypher engine, so operators, functions and query parameters behave exactly as they do in any other query. Checking whether a database exists is therefore a matter of asking for it by name and seeing whether a row comes back:
SHOW DATABASES WHERE name = $dbName
A trailing ; is accepted. A UNION of several commands is answered branch by branch, so a trailing clause there applies only within its own branch.
|
SHOW CURRENT USER
The SHOW CURRENT USER command returns information about the currently authenticated user executing the query.
Syntax:
SHOW CURRENT USER
Result columns:
| Column | Description |
|---|---|
|
The current user name |
|
The set of databases the current user has access to |
Over the Bolt protocol, SHOW CURRENT USER and CALL dbms.showCurrentUser() answer the columns Neo4j drivers and
tools expect instead: user, roles, passwordChangeRequired, suspended and home. roles lists the groups the
user holds on the database the session is bound to, sorted, so ArcadeDB’s admin group is Neo4j’s admin role;
passwordChangeRequired and suspended are always false, since ArcadeDB has neither. (Since v26.11.1: roles
was always ["admin"], whatever the user was granted.)
Examples:
// Show the currently authenticated user
SHOW CURRENT USER
CREATE USER
The CREATE USER command creates a new user on the server with the specified password. The new user is assigned to the admin group on all databases (*) by default.
Syntax:
CREATE USER username SET PASSWORD 'password'
CREATE USER username IF NOT EXISTS SET PASSWORD 'password'
Examples:
// Create a new user
CREATE USER bob SET PASSWORD 'SecurePass123!'
// Create a user only if it doesn't already exist (idempotent)
CREATE USER bob IF NOT EXISTS SET PASSWORD 'SecurePass123!'
| The password is stored hashed using PBKDF2 — it is never stored in plain text. See Security - Users for details on password storage. |
IF NOT EXISTS makes the operation safe to run multiple times without error if the user already exists.
|
DROP USER
The DROP USER command removes a user from the server.
Syntax:
DROP USER username
DROP USER username IF EXISTS
Examples:
// Drop a user (error if user doesn't exist)
DROP USER bob
// Drop a user only if it exists (no error if missing)
DROP USER bob IF EXISTS
IF EXISTS makes the operation safe to run even when the user does not exist.
|
ALTER USER
The ALTER USER command changes the password of an existing user.
Syntax:
ALTER USER username SET PASSWORD 'newPassword'
Examples:
// Change a user's password
ALTER USER bob SET PASSWORD 'NewSecurePass456!'
| The user must already exist. If the user does not exist, an error is returned. |
SHOW TRANSACTIONS and TERMINATE TRANSACTIONS
SHOW TRANSACTIONS lists the statements the server is running and TERMINATE TRANSACTIONS stops them, with Neo4j’s
syntax, so the tools and scripts written for Neo4j work unchanged. Available since 26.10.2.
Both commands read the same registry as the HTTP list queries and terminate query server
commands: a "transaction" here is one running statement, whatever protocol it came from (HTTP, PostgreSQL, Bolt,
gRPC, MongoDB, Redis, Gremlin), and its transactionId is the id list queries shows (q<n>-<hex>). The same rule
decides what you see: root sees and stops every statement, any other user only their own.
These commands require ArcadeDB to be running in server mode. They work over Bolt and every other protocol that runs Cypher; in embedded mode they fail with "SHOW TRANSACTIONS and TERMINATE TRANSACTIONS require a server". On an HA cluster each server answers for its own statements: the commands run on the server that receives them, a replica included, and are not forwarded to the leader.
Syntax:
SHOW TRANSACTIONS [ 'id' [, 'id' ...] | $ids ]
[ WHERE ... | YIELD ... [ORDER BY ...] [SKIP ...] [LIMIT ...] [WHERE ...] [RETURN ...] ]
[ TERMINATE TRANSACTIONS ... ]
TERMINATE TRANSACTIONS 'id' [, 'id' ...] | $ids
[ WHERE ... | YIELD ... [WHERE ...] [RETURN ...] ]
$ids is a parameter holding one id or a list of ids. The WHERE and YIELD tail works as for every other SHOW
command, see Filtering the output of a SHOW command. A SHOW TRANSACTIONS can be followed by
a TERMINATE TRANSACTIONS that names the ids by a column the SHOW yields, which stops the statements the SHOW rows
select (see the last example).
Result columns of SHOW TRANSACTIONS:
A bare SHOW TRANSACTIONS returns Neo4j’s default columns:
| Column | Description |
|---|---|
|
The database the statement runs on |
|
The statement’s id, as |
|
The same id |
|
The client connection, when the protocol has one (a PostgreSQL backend process id, |
|
The client’s address, when known, otherwise an empty string |
|
The user running the statement |
|
The statement text, truncated and with credentials masked as in |
|
When the statement started, as an ISO-8601 string |
|
|
|
How long it has been running, as a |
YIELD also reaches these columns:
| Column | Description |
|---|---|
|
How the statement reached the server: |
|
The query language of the statement |
|
A map; its |
|
The HTTP transaction session the statement runs in, if any |
|
Who asked the statement to stop, once it is terminating |
Result columns of TERMINATE TRANSACTIONS:
| Column | Description |
|---|---|
|
The id that was asked for |
|
The user running the statement |
|
|
Like Neo4j, TERMINATE TRANSACTIONS does not wait for the statement to stop: it asks it to stop and answers. The
statement stops at its next check and its client receives the error of its protocol (for example
Neo.ClientError.Transaction.Terminated over Bolt, SQLSTATE 57014 over PostgreSQL). Use SHOW TRANSACTIONS, or
terminate query with its wait, to know when it is gone.
Examples:
// Every statement you may see, with Neo4j's default columns
SHOW TRANSACTIONS
// Some statements by id
SHOW TRANSACTIONS 'q118-5e1f0a9c', 'q121-5e1f0a9c'
SHOW TRANSACTIONS $ids
// The statements labelled by a benchmark run, with where they came from
SHOW TRANSACTIONS YIELD transactionId, username, protocol, language, metaData, elapsedTime
WHERE metaData.tag = 'bench-run-42'
// Stop statements by id
TERMINATE TRANSACTIONS 'q118-5e1f0a9c', 'q121-5e1f0a9c'
TERMINATE TRANSACTIONS $ids YIELD transactionId, message
// Find and stop in one command every statement carrying a label
SHOW TRANSACTIONS YIELD transactionId AS txId, metaData WHERE metaData.tag = 'bench-run-42'
TERMINATE TRANSACTIONS txId
YIELD transactionId, message
RETURN transactionId, message
Transaction Control (START TRANSACTION, COMMIT, ROLLBACK)
ISO/IEC 39075 (GQL) transaction control statements manage a transaction from within the query language, in addition to the driver/HTTP API. A transaction opened with START TRANSACTION stays open across subsequent commands and is finalized only by an explicit COMMIT or ROLLBACK. They mirror the SQL BEGIN / COMMIT / ROLLBACK statements.
Syntax:
START TRANSACTION [ISOLATION <READ_COMMITTED | REPEATABLE_READ>]
COMMIT
ROLLBACK
Examples:
START TRANSACTION
CREATE (p:Person {name: 'Alice'})
CREATE (p:Person {name: 'Bob'})
COMMIT
// Discard the work instead
START TRANSACTION
CREATE (p:Person {name: 'Charlie'})
ROLLBACK
The optional ISOLATION clause is an ArcadeDB extension — GQL does not define isolation levels for START TRANSACTION. It accepts READ_COMMITTED (the default) or REPEATABLE_READ, matching the SQL BEGIN ISOLATION statement.
To span multiple requests the transaction needs a stateful session. Over HTTP, pass a session id (the arcadedb-session-id header); over Bolt, the connection provides the session. Over stateless HTTP without a session each request runs in its own transaction.
|
COMMIT with no active transaction reports an error; ROLLBACK with no active transaction is a no-op. A second START TRANSACTION while one is already active opens a nested transaction (as with SQL BEGIN). The GQL access modes READ ONLY / READ WRITE are not implemented (ArcadeDB has no read-only transaction mode); START TRANSACTION READ ONLY reports a parse error.
|
Session Management (SESSION SET, SESSION RESET, SESSION CLOSE)
ISO/IEC 39075 (GQL) session management statements operate on the current server session:
-
SESSION SET
$name=<value>binds a named query parameter on the session. Subsequent commands in the same session see it as$name(a value supplied with the request wins on a name clash). The value may reference an earlier session parameter. -
SESSION RESET clears the session parameters.
-
SESSION CLOSE releases the session.
Syntax:
SESSION SET $parameter = <value>
SESSION RESET
SESSION CLOSE
Examples:
SESSION SET $threshold = 21
MATCH (p:Person) WHERE p.age >= $threshold RETURN p.name
SESSION RESET
SESSION CLOSE
Session statements require a server session — over HTTP the arcadedb-session-id header, over Bolt the connection. In embedded mode they report an actionable error. Session parameters are resolved by the OpenCypher engine, so they apply to both the /command and /query endpoints.
|
SESSION CLOSE is transport-specific: over HTTP it also rolls back the session transaction and invalidates the session id, whereas over Bolt it clears the session parameters and leaves the connection (and its transaction) live.
|
Session state is node-local and is not replicated across an HA cluster. In an HA setup without session affinity, a SESSION SET on one node followed by a query routed to another node will not see the parameter.
|