Cypher Compatibility Matrix
This section provides a comprehensive overview of Cypher language features and their support status in ArcadeDB’s native OpenCypher implementation.
OpenCypher TCK Compliance
ArcadeDB’s OpenCypher implementation has been validated against the official OpenCypher Technology Compatibility Kit (TCK) version 9, developed by Neo4j and released under the Apache License 2.0.
| Metric | Value | Status |
|---|---|---|
TCK Pass Rate |
97.8% |
✅ Production Ready |
Total Scenarios |
3,897 |
Official OpenCypher v9 test suite |
Passed |
3,813 |
Full compatibility with core features |
Failed |
0 |
All executable tests pass |
Skipped |
84 |
Documented limitations (2.2%) |
|
Verify TCK Compliance Yourself To run the OpenCypher TCK tests on your system:
The TCK test suite is located at: For more information about the OpenCypher TCK, visit: https://github.com/opencypher/openCypher/tree/master/tck |
Known Limitations
The 84 skipped scenarios (2.2%) represent either architectural design choices or edge cases that rarely occur in production use. The following limitations affect 84 TCK scenarios (2.2%) and represent either design choices or rare edge cases.
1. Temporal Range Limitation (Java Platform Constraint)
TCK Impact: 4 scenarios (temporal sorting with extreme date ranges)
Reason: ArcadeDB uses Java’s LocalDateTime with nanosecond precision stored as a 64-bit long, limiting the date range to approximately 1677-2262.
Examples that don’t work:
// Year 0001 with nanoseconds - exceeds range
CREATE (:Event {datetime: localdatetime({year: 1, month: 1, day: 1,
hour: 1, minute: 1, second: 1,
nanosecond: 1})})
// Year 9999 with nanoseconds - exceeds range
CREATE (:FutureEvent {datetime: localdatetime({year: 9999, month: 9, day: 9,
hour: 9, minute: 59, second: 59,
nanosecond: 999999999})})
Workaround: Use dates within the 1677-2262 range (covers virtually all real-world data), or store historical dates as strings.
2. Case-Insensitive Identifiers (By Design)
TCK Impact: ~40 scenarios (case-sensitive type/property names)
Reason: ArcadeDB follows SQL conventions where identifiers are case-insensitive, providing consistency across query languages.
Examples that don’t work:
// These create a collision - T2 and t2 are considered the same type
CREATE ()-[:T2]->()
CREATE ()-[:t2]->() // ❌ Conflicts with :T2
Workaround: Use distinct type names (e.g., :Type2 and :Type2Alt).
3. Cross-Type Comparison Strictness (Semantic Edge Case)
TCK Impact: ~20 scenarios (incompatible type comparisons)
Reason: Comparing fundamentally different types (nodes vs strings, paths vs primitives) in production code typically indicates a query error.
Affected: Strict null-return semantics for cross-type comparisons.
4. User Functions Support
TCK Impact: 0 scenarios (extension feature)
ArcadeDB enhances OpenCypher with user-defined functions in 4 languages:
// Define in SQL
DEFINE FUNCTION math.sum "SELECT :a + :b" PARAMETERS [a,b] LANGUAGE sql
// Define in JavaScript
DEFINE FUNCTION js.greet "return 'Hello ' + name" PARAMETERS [name] LANGUAGE js
// Define in OpenCypher
DEFINE FUNCTION cypher.double "RETURN $x * 2" PARAMETERS [x] LANGUAGE opencypher
// Call from any query language
RETURN math.sum(3, 5) // Works in Cypher, SQL, Gremlin, etc.
See User Functions for complete documentation.
6. Map-Valued Properties (Multi-Model Boundary)
A property cannot hold a map in openCypher, the same rule Neo4j applies ("Property values can only be of primitive
types or arrays thereof"). CREATE, MERGE and SET all refuse one, whether it is written as a literal or bound
as a parameter:
CREATE (n:R {config: {debug: true}}) // refused
MATCH (n:R) SET n += $p // refused when $p holds a map value
ArcadeDB’s SQL is not bound by that rule, because MAP is a first-class schema type. So a record written through
SQL can carry a map property that openCypher can read but never write:
-- SQL: allowed
CREATE VERTEX R SET id = 1, config = {'debug': true}
// Cypher: all of these work
MATCH (n:R) RETURN n.config // returns the map
MATCH (n:R) RETURN n.config.debug // returns true
MATCH (n:R) WHERE n.config.debug RETURN n
MATCH (n:R) SET n.other = 1 // untouched map is left alone
// but copying it is refused, because that would make openCypher store one
MATCH (n:R), (t:T) SET t = n
MATCH (n:R), (t:T) SET t.config2 = n.config
The refusal names the property and where the value came from, so the two copy forms above are diagnosable without reading the server log:
TypeError: InvalidPropertyType - Property values can only be of primitive types or arrays thereof. Encountered a map [debug] assigned to property 'config', copied from record #1:0. ArcadeDB SQL can store a map in a property and openCypher can not, so such a property can be read and returned but never copied into another one.
Over Bolt it is reported as Neo.ClientError.Statement.TypeError, Neo4j’s own code for this refusal, and over HTTP
as 400. To move such a property through openCypher, flatten it into scalar properties, or do the write in SQL.
Not Implemented Features
The following Cypher features are not currently implemented:
-
Index hints:
USING INDEX n:Person(name)- Query planning is automatic via cost-based optimizer
Cypher 25 / GQL Strictness
Following ISO/IEC 39075 (GQL) and Cypher 25, ArcadeDB rejects removed legacy syntax with an actionable error that points at the supported replacement:
-
PERIODIC COMMITis removed — useCALL { … } IN TRANSACTIONS [OF n ROWS]for batched writes.USING PERIODIC COMMITreports an error with this hint. -
Legacy
{param}parameters are removed — use$param. The old curly-brace parameter form reports an error with this hint (map projections such asn{.name}and quantified path quantifiers such as{1,5}are unaffected). -
Ambiguous aggregation grouping is rejected — a
RETURN(orWITH) that mixes an aggregating expression with a non-grouping reference raisesAmbiguousAggregationExpression. Aggregations are not allowed inWHERE.
Three-Valued Logic (UNKNOWN)
ArcadeDB follows the GQL three-valued logic: the UNKNOWN truth value is represented as null in boolean context (no separate UNKNOWN value type). AND / OR / NOT / XOR propagate null, comparisons against null evaluate to null, and a null condition in WHERE is treated as non-matching, while RETURN preserves the null.
RETURN true AND null AS a // null
RETURN false AND null AS b // false
RETURN true OR null AS c // true
RETURN null = null AS d // null
The list predicates all(), any(), none() and single() follow the same rule: if the inner condition is
null for one or more elements and no element decides the result outright (a false for all(), a true for
any()/none()), the predicate itself evaluates to null rather than true/false. A definite mismatch always
wins over an unknown one, so a single element that decides the result short-circuits regardless of any `null`s
elsewhere in the list.
WITH [1, 2, null] AS list
RETURN any(x IN list WHERE x > 5) AS a // null: no match, but one candidate was unknown
RETURN none(x IN list WHERE x > 5) AS b // null: same reasoning
RETURN all(x IN list WHERE x IS NOT NULL) AS c // false: a definite mismatch always wins over unknown
Evaluation order is not specified for AND / OR, so ArcadeDB evaluates the operand that cannot run a subquery
(COUNT { }, EXISTS { }, COLLECT { }, a pattern predicate) first, and skips the subquery when the other operand
already decides the result. Do not rely on left-to-right order to guard an operand that can raise an error.
coalesce() stops at its first non-null argument: later arguments are not evaluated.
Zoned DateTime Equality and Ordering
Two DATETIME values are ordered by the instant they denote; at the same instant, by their offset from west to east,
then by time zone name. Two values are equal only when they denote the same instant in the same time zone, so
datetime('2026-01-01T12:00:00+01:00') = datetime('2026-01-01T11:00:00Z') is false, and DISTINCT and grouping
keep the two apart as well. Compare d.epochMillis (or test with ⇐ and >=) to check for the same instant
regardless of zone. A java.util.Date or java.time.Instant query parameter carries no time zone and is equal to any
DATETIME at its instant.
|
Since v26.10.1. Before, |
Comparing and Ordering Temporal Values
Values of two different temporal types are never equal and are not comparable, as in Neo4j: = is false, <> is
true, and <, ⇐, >, >= are null. That holds for a localdatetime() against a datetime() too, so
localdatetime('2024-01-01T12:00') = datetime('2024-01-01T12:00Z') is false.
The one exception is a DATETIME property read from the database. ArcadeDB stores a DATETIME as an instant and
reads it back in Cypher as a local datetime in UTC, so against a zoned datetime() it compares as that instant:
MATCH (e:Event) WHERE e.createdAt > datetime('2024-01-01T00:00Z') filters on the stored instant, whether the bound
is a literal or a parameter.
Durations are equal when every component is (duration('P1D') <> duration('PT24H')), and are not comparable, since a
month or a day has no fixed length: duration('P1D') < duration('P2D') is null, and ⇐ / >= are true only for
two equal durations. A range filter on a duration therefore keeps no row; filter on a number instead, such as the
average length in seconds, d.months * 2629746 + d.days * 86400 + d.seconds, or a single component when the durations
hold nothing else (d.days for durations of whole days).
ORDER BY, min() and max() order every value, temporal ones included:
-
values of different temporal types by type:
DATETIME,LOCAL DATETIME,DATE,TIME,LOCAL TIME,DURATION, all of them after paths and before strings; -
durations by average length, a month counting 365.2425 / 12 days, then by months and then by days, so
PT24Hsorts beforeP1D, andP1MbeforeP31D.
|
Since v26.11.1. Before, a |
Duration Arithmetic and Aggregation
Multiplying or dividing a duration is exact down to the nanosecond: a fraction of a month carries into days at the
average month of 30.436875 days, a fraction of a day into seconds, and only the last digit, the nanosecond, is
truncated toward zero. So duration('P1D') / 24 is PT1H and duration('P1M') / 3 is P10DT3H29M42S. An INTEGER
operand is used as the integer it is, and a FLOAT as the decimal it is written as, so duration('P1M') * 0.1 is
exactly a tenth of a month. Neo4j computes the same cascade in double precision, and where the two answers differ it is
by Neo4j’s rounding error (it answers PT7H18M17.459999999S for duration('P1M') / 100).
sum() adds durations component by component, and avg() divides that sum by their count, the way / divides:
avg() of P1M and P1D is P15DT17H14M33S. Numbers and durations cannot be mixed in one aggregation.
duration.between(), duration.inMonths() and duration.inDays() count months and days on the calendar of the first
argument’s time zone, and only the rest in elapsed time, so the day of a daylight saving change is one day:
duration.between(datetime('2024-03-30T12:00[Europe/Paris]'), datetime('2024-03-31T12:00[Europe/Paris]')) is P1D,
and duration.inSeconds() of the same pair is PT23H. The second argument is read in the time zone of the first, and a
value with no time zone in the time zone of the other.
|
Since v26.11.1. Before, |
Stored TIME, LOCAL TIME, Zoned DATETIME and DURATION Values
(Since v26.10.2.) ArcadeDB stores time(), localtime(), a zoned datetime() and duration() values natively, in
the property types OFFSET_TIME, LOCAL_TIME, ZONED_DATETIME and DURATION (see
Managing Dates). A value reads back as what it was written as, with no guessing from its text, so a
String such as an opening-hours range '09:00-17:00' or a code 'P100D' stays a String and compares, filters and sorts
as one.
In v26.10.1 and earlier these values were stored as their ISO-8601 text (for example 10:35-08:00 or PT1M30.5S) and
turned back into a temporal value by their shape when an undeclared property was read. A database written by those
versions keeps that reading after the upgrade until CALL db.migrateLegacyTemporals() converts the stored text to the
native types (see Migrating Cypher Temporals Stored as Strings). Until then, declare a
property STRING when its values may look like a time or a duration but are plain text: a declared STRING is never
converted.
The millisecond, microsecond and nanosecond fields of time(), localtime(), datetime(), localdatetime()
and the .truncate() functions must each fit in their own range, as in Neo4j: millisecond 0-999, microsecond
0-999 when millisecond is also given (else 0-999999), nanosecond 0-999 when a coarser field is given (else up to
999999999). A value out of range, such as time({hour: 12, millisecond: 5000}), is an error rather than being carried
into the seconds. Given with a base value (time:, datetime:, …), these fields replace its whole fraction of second:
localtime({time: localtime('12:30:45.123456789'), nanosecond: 5}) is 12:30:45.000000005. The adjustment map of a
.truncate() keeps the units the truncation left: localtime.truncate('millisecond', t, {nanosecond: 5}) keeps the
milliseconds of t.
datetime({epochSeconds: …}) and datetime({epochMillis: …}) take a timezone like any other datetime() map,
and read the instant in it. A dayOfWeek without a week moves a base date within its own ISO week, and a
dayOfQuarter without a quarter within its own quarter: date({date: date('2024-02-29'), dayOfWeek: 1}) is
2024-02-26. datetime() reads any year date() and localdatetime() read, such as -0001-12-31T23:59:59Z or
+10000-01-01T00:00Z. The timezone of a time() can be a zone name: a time has no date, so 'Asia/Tokyo' stands for
the offset the zone has now (+09:00), as in Neo4j.
duration.between(a, b) splits the interval into whole months, then whole days, then the remaining time, as Neo4j
does, so a + duration.between(a, b) is always b.
|
Since v26.10.1. Before, a declared |
Strict Numeric Types
The IS TYPED value-type predicate and CREATE CONSTRAINT … IS TYPED <type> honor the GQL numeric width hierarchy (INT8/INT16/INT32/INT64, FLOAT32/FLOAT64), mapping each onto a native ArcadeDB width so a declared property persists and reloads at that width. See IS TYPED data types for the full mapping.
Temporal and Spatial Value Types
The IS TYPED / IS :: value-type predicate and the valueType() function recognise temporal values and points by Neo4j’s type names: DATE, LOCAL DATETIME, ZONED DATETIME, LOCAL TIME, ZONED TIME, DURATION and POINT. This holds whether Cypher built the value (date(), datetime(), …) or it was read from a property written with SQL or the Java API. A point is a POINT and not a MAP, although it is a map at runtime. A list type with a non-nullable element type checks every element:
RETURN date() IS :: DATE // true
RETURN valueType(date()) // 'DATE NOT NULL'
RETURN valueType(localdatetime()) // 'LOCAL DATETIME NOT NULL'
RETURN valueType(duration('P1D')) // 'DURATION NOT NULL'
RETURN point({x: 1, y: 2}) IS :: MAP // false
RETURN [1, null] IS :: LIST<INTEGER NOT NULL> // false
|
Relationship Types and Sub-types
A relationship type matches its sub-types, both in a pattern and in a predicate: with CREATE EDGE TYPE Friend EXTENDS Knows, MATCH ()-[r:Knows]→() and MATCH ()-[r]→() WHERE r:Knows both return the Friend relationships.
shortestPath() and Hop Bounds
shortestPath() and allShortestPaths() honor the *min..max quantifier written on the relationship: a
shortest path outside that range is not an answer, and no longer path is substituted for it.
MATCH (a:City {name:'Rome'}), (b:City {name:'Oslo'}), p = shortestPath((a)-[:ROAD*..2]-(b))
RETURN length(p) // no row when the shortest Rome..Oslo path is longer than 2 hops
A relationship written without a quantifier, -[:ROAD]-, declares exactly one hop and is bounded accordingly.
When both endpoints resolve to the same vertex, the answer is the zero-length path, as in Neo4j. An explicitly written minimum above one hop rejects it, since zero hops is not in the declared range:
MATCH (a:City {name:'Rome'}), p = shortestPath((a)-[:ROAD*]-(a)) RETURN length(p) // 0
MATCH (a:City {name:'Rome'}), p = shortestPath((a)-[:ROAD*3..5]-(a)) RETURN length(p) // no row
Such a pattern is not reinterpreted as a request for the shortest cycle back to the start.
Self-Loops in Undirected Patterns
An undirected pattern follows a self-loop (a relationship from a node to itself) once, whether the pattern is written hop by hop or with a variable-length quantifier, as in Neo4j:
MATCH (n)-[*2]-() RETURN count(*) // same count as the fixed-length form below
MATCH (n)-[]-()-[]-() RETURN count(*)
Before 26.10.1 the variable-length form counted each self-loop twice, once from each end.
Indexes Inherited from a Parent Type
An index declared on a parent type serves queries on every child type, exactly as it does in SQL, so a single index at the top of a hierarchy is enough:
CREATE VERTEX TYPE Node;
CREATE VERTEX TYPE Entity EXTENDS Node;
CREATE PROPERTY Node.id STRING;
CREATE INDEX ON Node (id) UNIQUE;
MATCH (e:Entity) WHERE e.id = 'foo-1' RETURN e // uses the inherited Node[id] index
A child type cannot declare a second index on a property its parent already indexes, and does not need one: the parent’s index already covers the child’s records.
Architectural Differences from Neo4j
While implementing the OpenCypher standard, ArcadeDB differs from Neo4j in these intentional design choices:
-
Case-Insensitive Identifiers: Type names and property keys follow SQL conventions
-
Multi-Model Database: Supports Document, Key-Value, and Graph models in one database
-
Native Indexing: Uses LSM-Tree indexes optimized for write-heavy workloads
-
Query Language Flexibility: Supports SQL, Cypher, Gremlin, GraphQL, and MongoDB Query Language on the same data
-
User Functions: Dynamic functions in 4 languages (SQL, JavaScript, OpenCypher, Java) vs Neo4j’s Java-only compiled plugins