Introduction

ArcadeDB provides native support for the Cypher query language through a new, high-performance implementation built from the ground up. This native OpenCypher module uses an ANTLR4-based parser with the official Cypher v25 grammar, providing excellent compatibility and performance.

The query language identifiers opencypher and cypher are interchangeable: both run on the same native OpenCypher engine.

Key Features

The native OpenCypher implementation provides:

  • High Performance: Native direct execution, comparable to native SQL

  • Full Read/Write Support: MATCH, CREATE, MERGE, SET, DELETE with automatic transaction handling

  • Advanced Pattern Matching: Variable-length paths with path modes (WALK, TRAIL, ACYCLIC), OPTIONAL MATCH, multiple MATCH clauses

  • Query Composition: UNION/UNION ALL for combining results, WITH for query chaining

  • Procedures and Profiling: CALL for invoking built-in procedures and custom functions, PROFILE for performance analysis

  • Rich Function Library: 23+ native Cypher functions plus bridge to 100+ ArcadeDB SQL functions

  • Cost-Based Optimizer: Intelligent query planning with index selection and join ordering

  • 100% Test Coverage: Comprehensive test suite with 295+ passing tests

Using OpenCypher

To execute OpenCypher queries, use opencypher as the query language identifier.

From Java API:

ResultSet result = database.query("opencypher",
    "MATCH (p:Person) WHERE p.age >= $minAge RETURN p.name, p.age ORDER BY p.age",
    "minAge", 25);

Through Neo4j BOLT Protocol:

Connect using any Neo4j driver via the BOLT protocol:

Driver driver = GraphDatabase.driver("bolt://localhost:7687", AuthTokens.basic("root", "password"));
Session session = driver.session(SessionConfig.forDatabase("mydatabase"));
Result result = session.run("MATCH (p:Person) WHERE p.age >= 25 RETURN p.name, p.age ORDER BY p.age");

Through PostgreSQL Driver:

Prefix your query with {opencypher}:

"{opencypher} MATCH (p:Person) WHERE p.age >= 25 RETURN p.name, p.age ORDER BY p.age"

Through HTTP/JSON API (GET - idempotent queries):

curl "http://localhost:2480/query/graph/opencypher/MATCH%20(p:Person)%20RETURN%20p"

Through HTTP/JSON API (POST - updates):

curl -X POST "http://localhost:2480/command/graph" \
  -d "{'language': 'opencypher', 'command': 'CREATE (p:Person {name: \"Alice\", age: 30})'}"

Using Record IDs (RIDs)

You can use ArcadeDB’s RecordIDs (RID) in Cypher queries to start from a specific vertex. RIDs in Cypher are always strings, so they must be contained between single or double quotes.

MATCH (m:Movie)<-[a:ACTED_IN]-(p:Person) WHERE id(m) = '#1:0' RETURN *

Parameters

OpenCypher supports parameterized queries using the $paramName syntax:

MATCH (p:Person) WHERE p.age >= $minAge AND p.city = $city RETURN p

Parameters are passed as key-value pairs after the query string:

ResultSet result = database.query("opencypher",
    "MATCH (p:Person) WHERE p.age >= $minAge RETURN p",
    "minAge", 25);

Automatic Transaction Handling

A write statement (CREATE, SET, DELETE, MERGE, REMOVE, FOREACH, a write procedure) automatically handles its transaction:

  • If no transaction is active, the whole statement runs in one transaction, committed when the statement has run to its end, as over HTTP and as an implicit transaction in Neo4j. A statement that fails leaves nothing behind, and one that meets a concurrent conflict is run again from the committed state, up to arcadedb.txRetries times (since v26.11.1; before, each write clause committed its own transaction row by row, so a failing statement kept the writes made before the error)

  • If a transaction is already active, the statement runs in it and leaves the commit or rollback to its owner

  • A CALL { …​ } IN TRANSACTIONS subquery still commits each batch on its own, so a statement using one is not run again on a conflict

// Automatic transaction - the whole statement commits once it completes
database.command("opencypher", "CREATE (n:Person {name: 'Alice'})");

// Manual transaction - you control commit/rollback
database.transaction(() -> {
    database.command("opencypher", "CREATE (a:Person {name: 'Alice'})");
    database.command("opencypher", "CREATE (b:Person {name: 'Bob'})");
    database.command("opencypher", "MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'}) CREATE (a)-[:KNOWS]->(b)");
    // Commits when lambda completes successfully
});

Performance Considerations

The native OpenCypher implementation includes a cost-based query optimizer that:

  • Automatically selects optimal indexes for node lookups. When several predicates on the same node can use an index (an equality, an IN list, an OR of equalities or a range), each one is costed and the cheapest drives the lookup, so a UNIQUE index resolved by its whole key is preferred over a non-unique one whatever the properties are called or which operator they use. Two candidates of the same cost are decided by the order the query writes them in (since v26.11.1)

  • Uses range index scans for inequality predicates (>, <, >=, ⇐)

  • Drives a single-hop relationship pattern from the edge type’s own index when the WHERE clause pins the relationship’s indexed properties and neither endpoint is selective, e.g. MATCH ()-[t:TRANSFER]→() WHERE t.transactionId IN ['A', 'B'] RETURN t. An equality, an IN list or an OR of equalities on the same property all qualify, and a leading prefix of a composite index is enough: with an index on (transactionId, date), filtering transactionId alone walks that prefix of the index instead of scanning every edge. The plan shows it as MATCH EDGE BY INDEX

  • Optimizes join order based on cardinality estimation

  • Detects bounded patterns for efficient relationship traversal

  • Joins the parts of a pattern no relationship connects - MATCH (a:Person), (c:City), or two MATCH clauses - on the WHERE conjuncts that relate them instead of crossing every row with every row (since v26.10.1). A conjunct that reads one part alone narrows that part before the join. Cypher does not fix the order conjuncts are evaluated in, so a conjunct that reads one part, like either side of a join key, is evaluated once per row of that part rather than per pair, and ahead of the conjuncts written before it: rand() there is drawn per row, and an expression that can fail, such as 1 / 0, can fail on a row the written order would have skipped. A c.property = expression conjunct over an indexed property seeks the index once per row of the other part (IndexNestedLoopJoin in the plan), which holds nothing in memory; any other left = right conjunct is answered by a hash join (ValueHashJoin). Only parts nothing relates are crossed (CartesianProduct). The rows a join buffers count against arcadedb.queryMaxHeapElementsAllowedPerOp and reserve their heap from the budget all the running queries share, arcadedb.queryMaxHeapRAM, and past a few thousand rows a query that only reads, or only adds entities (CREATE, MERGE without SET actions), holds them by record identity rather than in full ([compact buffer] in the plan)

For best performance:

  • Create indexes on frequently queried properties

  • Use labeled nodes in MATCH patterns for faster type filtering

  • Prefer specific property constraints over full scans

  • Use parameters for queries executed multiple times (enables query plan caching). Since v26.10.2 queries that differ only in the literal values they embed share a cached plan too (see the queryLiteralParameterization setting), but parameters also keep values out of the query text