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?

TRAIL

Edges must be unique per path. Vertices can repeat.

Yes (default)

ACYCLIC

No vertex repeated per path (strongest restriction). Implies edge uniqueness.

WALK

No restrictions — vertices and edges can be revisited. Requires an explicit max hop bound.

TRAIL is the default path mode per the GQL/Cypher v25 standard. If no mode is specified, TRAIL semantics apply.

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

TRAIL

B, C, D, A (vertex A revisited after full cycle — edges stay unique)

ACYCLIC

B, C, D (stops — cannot revisit A)

WALK

B, C, D, A, B, C, …​ (repeats edges and vertices up to max hops)

WALK mode without an explicit maximum hop bound will throw an error. Always specify an upper bound, e.g., [*1..10].

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 inline WHERE and the path mode (under the default TRAIL, a list naming the same relationship twice matches nothing).

  • A null list, or a list containing null, matches nothing. A value that is not a list of relationships raises a TypeError.

  • The same rule applies to a pattern predicate (WHERE (a)-[rs*]→(b)), a pattern comprehension and an EXISTS { } or COUNT { } 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..n and *1..n);

  • there is no named path and the relationship variable is not used anywhere (not in WHERE, RETURN, ORDER BY or an inline WHERE);

  • the RETURN is either DISTINCT over the start or end vertices or their properties, or it groups by such values and aggregates them with count(DISTINCT …​), collect(DISTINCT …​), sum(DISTINCT …​), avg(DISTINCT …​), min(…​) or max(…​). ORDER BY may 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

=, <>, <, >, ⇐, >=

WHERE n.age >= 25

Logical

AND, OR, NOT

WHERE n.age > 25 AND n.city = 'NYC'

Null check

IS NULL, IS NOT NULL

WHERE n.email IS NOT NULL

List

IN

WHERE n.name IN ['Alice', 'Bob']

String

STARTS WITH, ENDS WITH, CONTAINS

WHERE n.name STARTS WITH 'A'

Regex

=~

WHERE n.email =~ '.*@gmail.com'

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 SET that assigns a value the matched record already holds writes nothing, so read-mostly MERGE workloads 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 LIMIT no 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 SET ran right after the MERGE of each row, so the MERGE of a later row already saw it: the example above created two nodes, and adding a WITH between the clauses changed the result depending on how many rows were pulled at a time. Since 26.11.1 the answer is the same in every spelling, and the same as Neo4j’s. A bulk load that used SET n += row after a MERGE now holds its rows in memory until the MERGE has run for all of them: to keep it streaming, set the properties by name (SET n.name = row.name, n.age = row.age).


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 (adult reads age above).

  • LET declares a new variable: naming a variable already in scope is refused (Variable 'p' already declared). To replace a value, use WITH.

  • LET cannot aggregate: use WITH for count(), 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:

  • UNION removes duplicate rows from the combined result (like SELECT DISTINCT in SQL)

  • UNION ALL preserves 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

db.labels()

Returns all vertex type names (labels)

db.relationshipTypes()

Returns all edge type names

db.propertyKeys()

Returns all property keys defined in the schema

db.schema()

Returns schema visualization data

db.schema.visualization()

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

db.index.vector.queryNodes(indexSpec, k, vector [, options])

Neo4j-compatible vector search. Returns node (the document) and score (in [0, 1], higher is better, as in Neo4j: (1 + cosine) / 2 for COSINE and DOT_PRODUCT, 1 / (1 + d^2) for EUCLIDEAN). On a PRODUCT index it walks on the PQ codes and re-ranks rerankOversample (index setting, default 10) candidates per neighbor on the stored vectors since 26.11.1, so the score is exact. The optional options map, an ArcadeDB extension since 26.11.1, takes where and filter as vector.neighbors() does

vector.neighbors(indexSpec, vector, k [, options])

ArcadeDB-native vector search. Returns document properties and distance (lower is better). It takes the options of the SQL vector.neighbors(), among them where (since 26.11.1) and filter, which restrict the candidates during the search

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

db.index.fulltext.queryNodes(indexName, query [, options])

Searches a full-text index on a vertex type. Returns node and score (BM25 relevance, higher is better)

db.index.fulltext.queryRelationships(indexName, query [, options])

Searches a full-text index on an edge type. Returns relationship and score

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

arcadedb.opencypher.loadCsv.allowFileUrls

true

Allow access to local files via file:/// URLs and bare file paths. Set to false in multi-tenant server deployments.

arcadedb.opencypher.loadCsv.importDirectory

(empty)

Root directory for file URLs. When set, file paths are resolved relative to this directory and path traversal (../) is blocked. Empty means no restriction.

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

CREATE CONSTRAINT FOR (p:Person) REQUIRE p.email IS UNIQUE

constraint_Person_email_unique

CREATE CONSTRAINT FOR (p:Person) REQUIRE (p.firstName, p.lastName) IS UNIQUE

constraint_Person_firstName_lastName_unique

CREATE CONSTRAINT FOR (p:Person) REQUIRE p.id IS NODE KEY

constraint_Person_id_key

CREATE CONSTRAINT FOR (p:Person) REQUIRE p.name IS NOT NULL

constraint_Person_name_existence

CREATE CONSTRAINT FOR (p:Person) REQUIRE p.age IS TYPED INT16

constraint_Person_age_type

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

BOOLEAN, BOOL

BOOLEAN

STRING, VARCHAR

STRING

INT8, INTEGER8

BYTE

8-bit signed integer

INT16, INTEGER16

SHORT

16-bit signed integer

INT, INT32, INTEGER32

INTEGER

32-bit signed integer (INT is the alias for INT32)

INTEGER, INT64, INTEGER64, SIGNED INTEGER

LONG

64-bit signed integer (INTEGER is the generic integer type)

FLOAT32

FLOAT

32-bit IEEE 754 floating point

FLOAT, FLOAT64

DOUBLE

64-bit IEEE 754 floating point

DATE

DATE

LOCAL DATETIME, TIMESTAMP WITHOUT TIME ZONE

DATETIME_NANOS

Nanosecond precision. A property already declared DATETIME, DATETIME_SECOND or DATETIME_MICROS is left at its precision

ZONED DATETIME, TIMESTAMP WITH TIME ZONE

ZONED_DATETIME

Keeps the zone

LOCAL TIME, TIME WITHOUT TIME ZONE

LOCAL_TIME

ZONED TIME, TIME WITH TIME ZONE

OFFSET_TIME

Keeps the offset

DURATION

DURATION

POINT

MAP

The map point() returns

LIST<T>, T LIST, ARRAY<T>, LIST<T NOT NULL>

LIST OF the type T maps to

For example LIST<INTEGER> is LIST OF LONG, LIST<FLOAT> is LIST OF DOUBLE, LIST<INT32> is LIST OF INTEGER, LIST<DATE> is LIST OF DATE

LIST, LIST<ANY>

LIST

A list with no element type

MAP

MAP

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 NULL on 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, COMPRESSION and custom values. The type-dependent ones are kept only when the new type reads them the same way:

  • OF when both types are a LIST or a MAP;

  • REGEXP when the new type is STRING;

  • MIN and MAX when both types measure the same thing (characters, bytes, items, a numeric value or a date) and the new type can hold the bound, so max 4 moves from INTEGER to LONG but not from STRING to LONG, and max 2.5 does not move onto an INTEGER;

  • DEFAULT when it converts to the new type without changing its value (a DOUBLE default 3.0 moves onto a LONG, 3.7 does 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 PROPERTY first. The stored records are then checked against the new declaration, as CREATE PROPERTY checks them: if a record holds a value of another type, or breaks a kept NOT NULL, MANDATORY, REGEXP, MIN or MAX, 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 (a REQUIRE n.code IS NOT NULL followed by `REQUIRE n.code IS

INTEGER` left code optional), 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 (LIST<STRING>), LOCAL DATETIME and ZONED DATETIME declared a DATETIME (millisecond precision, which truncated the sub-millisecond digits and dropped the zone), LOCAL TIME and ZONED TIME declared a STRING, DURATION declared a LONG (which refused every duration value) and POINT declared a STRING. A constraint created before 26.10.2 keeps the declaration it made then.


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 UNIQUE constraint drops its unique index, a NOT NULL constraint clears the mandatory flag it set, and a NODE KEY / KEY constraint does both.

  • Only what the constraint itself created is undone. A MANDATORY flag or a unique index declared before the constraint, for example with SQL CREATE PROPERTY …​ (mandatory true) or CREATE 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 SQL CREATE 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 NULL constraint needs a declared property to carry its mandatory flag, and a UNIQUE index is built over the declared type.

  • A requirement the schema enforces without a Cypher constraint, which SHOW CONSTRAINTS lists under a generated name, is dropped the same way: constraint_<Type>_<property>_existence clears the property’s MANDATORY and NOT NULL flags, constraint_<Type>_<property>_type removes 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

id

A sequence number of the row

name

The constraint name, the one DROP CONSTRAINT takes

type

UNIQUENESS, RELATIONSHIP_UNIQUENESS, NODE_KEY, RELATIONSHIP_KEY, NODE_PROPERTY_EXISTENCE, RELATIONSHIP_PROPERTY_EXISTENCE, NODE_PROPERTY_TYPE or RELATIONSHIP_PROPERTY_TYPE

entityType

NODE or RELATIONSHIP

labelsOrTypes

The label or relationship type, as a list

properties

The constrained properties, as a list

ownedIndex

The unique index that enforces a uniqueness or key constraint, otherwise null

propertyType

The Cypher type a property type constraint requires, otherwise null

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 MANDATORY or NOT NULL, as an existence constraint named constraint_<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 EXISTS the statement does nothing;

  • an index already defined on the same properties is refused under another name, and with IF NOT EXISTS the statement does nothing;

  • DROP INDEX of a name no index has raises an error unless IF EXISTS is given;

  • the index a UNIQUE or NODE KEY constraint created is dropped with DROP CONSTRAINT: DROP INDEX refuses 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

vector.dimensions (required)

dimensions

vector.similarity_function: cosine (default), euclidean

similarity: COSINE, EUCLIDEAN (dot_product is accepted too)

vector.quantization.type: none, scalar, binary

quantization: NONE, INT8, BINARY

vector.quantization.enabled (deprecated by Neo4j)

quantization: INT8 when true, NONE when false. It must agree with vector.quantization.type when both are given

vector.hnsw.m

maxConnections, doubled: JVector does not double the degree at the base layer as an HNSW index does

vector.hnsw.ef_construction

beamWidth

fulltext.analyzer

analyzer: a Neo4j analyzer name (standard-no-stop-words, english, simple, …​) or the class name of a Lucene analyzer

fulltext.eventually_consistent

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

id

A sequence number of the row

name

The index name

state

ONLINE, or POPULATING while the index is being built

populationPercent

100.0 when the index is online, otherwise 0.0

type

RANGE (an LSM_TREE index), HASH, FULLTEXT, VECTOR, SPARSE_VECTOR or POINT (a geospatial index)

entityType

NODE or RELATIONSHIP

labelsOrTypes

The label or relationship type, as a list

properties

The indexed properties, as a list

indexProvider

The index implementation, for example range-1.0

owningConstraint

The name of the constraint that created the index (for a unique index created without a constraint, the index name, which is the name SHOW CONSTRAINTS lists it under), otherwise null

lastRead, readCount

Always null: ArcadeDB does not track index reads

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

user

The user name

databases

The set of databases the user has access to (* means all databases)

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

user

The current user name

databases

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

database

The database the statement runs on

transactionId

The statement’s id, as list queries shows it

currentQueryId

The same id

connectionId

The client connection, when the protocol has one (a PostgreSQL backend process id, bolt-<n>, …​), otherwise an empty string

clientAddress

The client’s address, when known, otherwise an empty string

username

The user running the statement

currentQuery

The statement text, truncated and with credentials masked as in list queries

startTime

When the statement started, as an ISO-8601 string

status

Running, or Terminating once it was asked to stop

elapsedTime

How long it has been running, as a Duration

YIELD also reaches these columns:

Column Description

protocol

How the statement reached the server: http, postgres, bolt, grpc, mongodb, redis or gremlin

language

The query language of the statement

metaData

A map; its tag is the label the client sent with the X-ArcadeDB-Query-Tag HTTP header

sessionId

The HTTP transaction session the statement runs in, if any

statusDetails

Who asked the statement to stop, once it is terminating

Result columns of TERMINATE TRANSACTIONS:

Column Description

transactionId

The id that was asked for

username

The user running the statement

message

Transaction terminated., or Transaction not found. when no such statement is running or it is another user’s

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.