Cypher Support
patinaDB implements a subset of Cypher, not complete Neo4j/openCypher parity. The openCypher Compatibility Reference is the feature-by-feature inventory, including unsupported syntax, function names and semantic differences. The openCypher Technology Compatibility Kit (TCK) is a prioritized gap report, not a strict regression gate or certification. This chapter provides an overview and usage examples.
Reading
MATCHwith node/edge patterns, optional labels, property maps, and all three directions (->,<-,-undirected).OPTIONAL MATCH.- Variable-length paths —
-[:T*1..3]->,[*0..n](length-0 self-row),shortestPath. WHERE— full boolean expressions:AND/OR/NOT/XOR, comparisons,IN,IS NULL,STARTS WITH/ENDS WITH/CONTAINS, regex=~, nested parenthesisation.RETURNwithDISTINCT, aliases (AS), expressions, ordering.WITHchained query stages (projection, filtering, aggregation hand-off, ordering before projection).UNWIND,UNION/UNION ALL.ORDER BY/SKIP/LIMIT— single-key ordering uses an index fast-path; multi-key falls back to a post-sort.
Relationship type alternatives in paths
A MATCH path such as (a)-[:ROAD|RAIL*0..3]->(b) accepts either listed
relationship type at each hop, including routes mixing both types. Repeating a
type name does not duplicate a route. Distinct routes remain distinct result
rows unless the query requests DISTINCT; matching endpoints alone do not
collapse them. A physical relationship cannot be reused within one matched
path. A lower bound of zero permits the empty path where the endpoint
constraints match.
The same type alternatives work with incoming and undirected patterns.
CREATE and MERGE still require a single fixed relationship type and reject
variable-length relationship creation.
Named shortest paths
MATCH p = shortestPath((a)-[:ROAD*]->(b)) selects one unweighted shortest
path per endpoint pair. allShortestPaths selects every tied shortest path;
it does not enumerate longer paths. When several shortest paths tie,
shortestPath does not promise which one is chosen.
The supported named form contains one relationship segment, with a minimum
length of zero or one and an optional maximum. Incoming, outgoing and
undirected traversal and relationship-type alternatives are supported.
A zero minimum permits an empty path for matching endpoints. Identical
endpoints with a positive minimum raise an error. Disconnected pairs produce
no path; OPTIONAL MATCH retains its null-row behavior.
This zero-length behavior differs from Neo4j with its default
dbms.cypher.forbid_shortestpath_common_nodes=true, which rejects common
endpoints even when the minimum is zero.
Relationship-variable bindings, relationship property maps, multiple segments,
and a minimum above one are rejected in this form. A WHERE attached to the
shortest-path MATCH and another relationship pattern in that same MATCH clause
are also rejected. Filtering after WITH p is supported: it filters paths
already selected and does not search for a longer path satisfying the filter.
Shortest-path search uses the query’s execution-memory budget and cancellation
checks. Search buffers and emitted path collections also observe
PATINADB_MAX_AGG_ROWS. A refusal aborts the query; it does not return an
incomplete set of tied shortest paths as a successful result. Result memory
is conservatively retained for the query-result lifetime, so discarded
intermediates can cause an early budget refusal.
Membership values
Relationship patterns can be Boolean operands in projections, for example
RETURN NOT ((a)-[:LINK]->(b)) AS disconnected or
RETURN ((a)-[:LINK]->(b)) AND true AS connected. Named pattern bindings must
already exist in scope. A bare pattern is not a general scalar value: returning
(a)-[:LINK]->(b) alone, assigning it directly, or passing it to size() is
rejected. Use an explicit EXISTS { (a)-[:LINK]->(b) } for a standalone Boolean
existence result. With a null endpoint there is no matching path, so the negated
existence result is true. Scalar parentheses and list expressions keep their
ordinary meaning.
item IN items and item NOT IN items compare the value bound to item.
For example, a node tested against collect(node) remains an entity; it is not
implicitly converted to its UUID. Scalar and list bindings likewise retain their
types. Use an explicit property such as node.id, or node.__id for the internal
UUID, when the list contains those values. Dotted property membership keeps its
existing lookup and seek behavior; null membership retains three-valued logic.
The bare-binding parser correction in PR #1410 has focused and core regression coverage. It does not fix the existing identifier limitation for literal dots inside backticks. No query performance claim follows from this semantic correction.
Loading CSV
LOAD CSV WITH HEADERS FROM 'file:///data/people.csv' AS row
WITH toInteger(row.id) AS id, row.name AS name
CREATE (:Person {id: id, name: name})
LOAD CSV [WITH HEADERS] FROM '<url>' AS <var> [FIELDTERMINATOR '<c>'] streams
rows from a CSV file into the query as a row source — like UNWIND, but from a
file. It is read row by row (the file is never fully buffered).
WITH HEADERS→ each row is a map keyed by the header, sorow.columnNameworks. Without it, each row is a list of string cells:row[0],row[1], ….- Cells are strings (openCypher semantics). Convert explicitly with
toInteger/toFloat/toBoolean. A coercion may sit directly inside aCREATE/MERGEpattern property —CREATE (… {p: toInteger(row.x)})— as well as in aWITH; only arow[i]list-index expression still needs aWITHstage first. FIELDTERMINATOR '<c>'overrides the delimiter (default,).- Sources:
file:///absolute/path,file://host/absolute/path(the host is ignored), and bare/relative filesystem paths. Quoted fields and embedded delimiters/newlines are handled.http(s)://URLs are not yet supported.
Large loads (memory): a plain
LOAD CSV … CREATEbuffers all its writes into one transaction (on the server, one Raft entry), so a very large load has an O(rows) memory cliff. Wrap it inCALL { … } IN TRANSACTIONS(below) to chunk it into many small commits — the online answer. For a large columnar file already on the server,POST /mgmt/upsertis faster still — see Bulk Upsert. ALOAD CSV … RETURN rowread query streams end-to-end.
Server-side file reads (security): on the server,
LOAD CSV FROM 'file://…'reads a server-side file, so it is deny-by-default: it reads only directories whitelisted with--allow-csv-dir, and any file-I/O query is raised to require the global Admin role. See Bulk Loading → Server file-I/O sandbox and Authentication & TLS.
Batched writes: CALL { … } IN TRANSACTIONS
LOAD CSV WITH HEADERS FROM 'file:///data/people.csv' AS row
CALL {
WITH row
CREATE (:Person {id: toInteger(row.id), name: row.name})
} IN TRANSACTIONS OF 1000 ROWS
CALL { <write-subquery> } IN TRANSACTIONS [OF <n> ROW[S]] [ON ERROR {CONTINUE|BREAK|FAIL}] chunks a large write into many small, independently
committed transactions instead of one giant one. The outer stream (typically
LOAD CSV … AS row or UNWIND $rows AS r) feeds rows to the inner subquery,
which runs and commits every n rows as a separate transaction (default
n = 1000). This bounds peak memory to one chunk — the fix for the
LOAD CSV … CREATE RAM cliff — and, on a cluster, replicates as one small entry
per chunk instead of one unbounded one.
- Each chunk is its own commit ⇒ its own engram (so batched ingest is versioned and time-travellable, unlike the offline importer), and partial progress is durable: a crash between chunks recovers to a chunk boundary (all-or-nothing per chunk). A later chunk sees the writes committed by earlier chunks.
OF <n> ROW[S]sets the chunk size (omit for the 1000 default).ON ERROR—FAIL(default) aborts the whole statement on a failing chunk;CONTINUEskips the failing chunk and commits the rest;BREAKstops after the failing chunk, keeping the chunks already committed. A skipped/failed chunk changes nothing (it is resolved against a throwaway copy of the graph, so a partial chunk is never left behind).- The
CALL { … } IN TRANSACTIONSmust be the final clause of the query.
Writing
CREATE,MERGE(match-or-create),SET(properties, map merge, andSET n:Labellabel mutation),REMOVE,DELETE/DETACH DELETE.FOREACH.- Writes inside
WITHstages.
Expressions & functions
- Arithmetic, string, list, and map operators; list/map indexing and slicing.
- Aggregation —
count(incl.count(*)),sum,avg,min,max,collect, withGROUP BYsemantics (mix of aggregated and grouping keys inRETURN),DISTINCTinside aggregates, and aggregate hoisting through nested function calls and arithmetic. CASE(simple and searched forms). A simple CASE value may be a complete Boolean expression, for exampleCASE n.active IS NULL WHEN true THEN .... Boolean values use the same equality and NULL behavior as other simple CASE values;CASE WHEN predicate THEN ...remains the searched form.- List comprehensions and pattern comprehensions.
- Quantifiers —
any/all/none/single, including aggregate andcount(*)sources. - Scalar functions —
toInteger,toString,size,labels,type,nodes,relationships,range, and many more. - Temporal functions —
date,time,localtime,datetime,localdatetime,duration, plus namespaced calls (date.truncate,duration.between,datetime.fromepoch) with ISO-8601 week numbering.
datetime({epochMillis: value}) constructs an instant from integer Unix
milliseconds, including negative values. For example,
datetime({epochMillis: -1}).millisecond is 999. An optional timezone changes
the local representation of that instant; calendar components can override it.
A no-op override in an ambiguous daylight-saving hour retains the original
offset. Invalid epoch types or competing temporal selectors raise an argument
error; datetime(null) still returns null.
Epoch-map construction rejects instants or override years outside the supported calendar range rather than wrapping them. Named zones have an additional calendar-library range bound. A calendar override into a nonexistent local daylight-saving time raises an error; it does not shift the time forward as Neo4j does. This is not full temporal-range or override compatibility.
Value semantics
Numeric equivalence
patinaDB treats an integer and a mathematically-equal float as the same
value for equivalence purposes, matching Neo4j. The fold is exact: a
float counts as its integer twin only when it is finite, has a zero fractional
part, and round-trips bit-for-bit ((f as i64) as f64 == f) — so a large
integer that is not exactly float-representable never falsely merges, and two
genuinely different integers never collide.
Because of this, DISTINCT, an implicit GROUP BY key, and a distinct UNION
all merge numeric twins:
UNWIND [1, 1.0] AS x RETURN DISTINCT x
-- 1 row (previously 2)
UNWIND [1, 1.0, 2] AS x RETURN x, count(*)
-- 1 → 2, 2 → 1 (1 and 1.0 fall into one group)
The surviving value keeps its original type — first occurrence wins (as in
Neo4j), so the result shows whichever of 1 / 1.0 appeared first, not a
normalized form. UNION ALL is unaffected (it never de-duplicates). The same
equivalence drives numeric-twin-aware
UNIQUE constraints.
Cross-type ORDER BY
A missing sort property is NULL. A storage error while reading a required
node or relationship sort property fails the query, for both full sorting and
ORDER BY ... LIMIT. Empty inputs and LIMIT 0 do not require those reads.
When a single ORDER BY column holds values of different types, patinaDB
follows Neo4j’s documented precedence, lowest to highest:
Map < Node < Relationship < List < Path < Temporal
< String < Boolean < Number < Point < Polygon < NaN < Null
Every temporal type (Date, Time, LocalTime, DateTime, LocalDateTime,
Duration) now sorts between Path and String — i.e. before strings and
numbers (previously they sorted after numbers). min() and max() follow the
same order. This applies only to a mixed-type column; a column of a single
type is ordered normally and is unaffected. (Point and Polygon are patinaDB
spatial extensions beyond Neo4j’s list, shown here for completeness.)
Arithmetic errors
Integer division and modulo by zero raise
Neo.ClientError.Statement.ArithmeticError. Integer +, -, *, and sum()
over integers raise the same error on i64 overflow, rather than silently
saturating or wrapping. A mixed integer/float expression is promoted to float
before the operation (so it never overflows there — Neo4j parity), and float
arithmetic is unchanged: it still yields ±Inf / NaN, not an error.
Procedures
CALL invokes built-in procedures (history, diff, full-text/vector search,
graph algorithms, statistics, export, cache stats). See
Procedures. User-defined scripting-language procedures
are not supported, but user-defined read-only WASM procedures are — see
WASM Procedures.
DDL
-
CREATE INDEX/DROP INDEX/SHOW INDEXES— single-property and compound (multi-field) B-tree indexes.SHOW INDEXESreturns anindexeslist of strings. Single-property entries remain bare property names and point indexes retain theirPOINT FOR …form. Each registered compound definition appears once, for exampleCOMPOUND INDEX [`batch_lookup`] FOR (:`Batch`) ON (`run_id`, `id`). The field order is part of its identity; matching registered names are listed together. Unnamed definitions omit the name list. Identifiers are backtick quoted, with embedded backticks doubled. This inventory reports registered schema; it does not report a separate readiness or population state. -
CREATE CONSTRAINT/DROP CONSTRAINT/SHOW CONSTRAINTS— all four Neo4j constraint kinds (uniqueness, existence, node-key/relationship-key, property-type), on both nodes and relationships. See Constraints. -
CREATE FUNCTION … LANGUAGE wasm/DROP FUNCTION/SHOW FUNCTIONS— user-defined, sandboxed, read-only WASM procedures. See WASM Procedures. -
CREATE POINT INDEX/DROP POINT INDEX/SHOW POINT INDEXES— accelerates spatial radius/bbox/kNN queries. See Spatial / Geo. -
CREATE EDGE SORTED INDEX(patinaDB extension) — pre-orders, per anchor vertex, that anchor’s targets over one relationship type by a target property, so a traversal-fan-out +ORDER BY target.prop [DESC] LIMIT kis served by a seek + take (no fan-out, no post-sort — bench Q5–Q8). Two orientations:CREATE EDGE SORTED INDEX [name] FOR ()-[:REPORTED]->(m:Ticket) ON m.created_at CREATE EDGE SORTED INDEX [name] FOR (m:Ticket)<-[:HAS_LABEL]-() ON m.created_at SHOW EDGE SORTED INDEXESCreating the index over a populated graph backfills existing edges; it is kept live on writes.
EXPLAINnameslimit.edge_sorted_topkonce a covering index serves the query. On a server it replicates to every node (deterministic re-run). Thenameis optional and cosmetic (the def is identified by its shape). Not yet carried in Raft snapshots — see Limitations. -
CREATE FULLTEXT INDEX/DROP INDEX/SHOW FULLTEXT INDEXES— see Full-Text Search. -
CREATE DATABASE/DROP DATABASE/SHOW DATABASES(server only) — see Multi-Database. -
EXPLAINandPROFILErender the physical operator tree.
Query introspection
EXPLAIN MATCH (p:Person)-[:KNEW]->(q) WHERE p.born < 1850 RETURN q
PROFILE MATCH (p:Person {name: 'Ada'}) RETURN p
EXPLAIN also includes a Decisions: section after the physical-access details.
Each entry names the decision point, chosen alternative, available row/cost
estimates, reason and provenance. These are the selector’s existing estimates,
not measured runtimes. Priority walks stop when their winner is known, so later
alternatives may be absent.
PROFILE adds Decisions (executed): and an Executed operators (query thread):
section. Operator numbers are preorder indices of the executed plan. rows=N
counts successful rows actually pulled from that iterator; a LIMIT can leave an
input only partly consumed. MatchScan also shows its entry estimate and q-error
(the larger of estimate/actual and actual/estimate, replacing zero with one).
An operator bypassed by a fast path is marked rows=unobserved (bypassed/fused).
Worker internals and mutation/seeded paths that bypass the iterator are not
per-operator measurements. The separately planned access tree is still labelled
Planned access (before execution); use the executed section for row counts.
The audit retains at most 128 distinct decisions and coalesces repeats by point,
subject and chosen alternative. repeats includes repeated seeded executions;
the displayed estimates are from the first observation. Ordinary queries do not
construct audit events or use a per-row counting adaptor.
EXPLAIN shows the chosen plan (scan strategy: compound index, property-value
index, label scan, or all-vertices) without running it; PROFILE runs and
annotates it.
Planning note for filtered pages
For indexed keyset pagination, use a matching compound index and a qualified ordering property, for example:
CREATE INDEX tickets_by_project_id IF NOT EXISTS
FOR (ticket:Ticket) ON (ticket.project, ticket.id)
MATCH (ticket:Ticket {project: $project})
WHERE ticket.id > $after
RETURN ticket.id AS id, ticket.title AS title
ORDER BY ticket.id LIMIT $limit
Use separate parameterized query shapes when an optional filter/cursor is absent,
rather than assuming ($after IS NULL OR ticket.id > $after) preserves a seek.
ORDER BY id is valid syntax, but the #1261 million-ticket application diagnosis
observed a separate sort for that projected alias while ORDER BY ticket.id
selected the compound sort-covered path. Check EXPLAIN on the actual query;
equivalent result semantics do not guarantee identical access plans. The local
engineering evidence is docs/customer-blackbox-million-1261-2026-09-16.md.
For a required single-node match with equality predicates covering exactly a
compound index’s fields, count(*) or count(ticket) can use
agg.filtered_count_scan: it counts matching index entries rather than loading
each node. This is still a scan of matching entries, not a precomputed counter.
Optional matches, additional filters, secondary-label matches and unsupported
value encodings retain the general execution path. EXPLAIN reports the actual
choice. The snapshot transaction and numeric-equality semantics are unchanged.
A non-null scalar comparison on the ordered property can prove that missing values cannot match, avoiding a separate whole-label population check for that compound sort. Without such a predicate, NULL placement must still be preserved; first-page planning can therefore cost more than a bounded keyset page. These planning rules do not promise a fixed latency for arbitrary distributions.
What is not supported
A precise list lives in Limitations. The headline gaps:
no general scripting-language user-defined procedures (only sandboxed
read-only WASM procedures), no triggers, and partial
subquery, expression and error-semantic compatibility. See the
compatibility reference rather than using a
historical TCK percentage as a current support contract. (Index
DDL and all four constraint kinds, on nodes and relationships, are
supported; see Constraints and
Limitations for the exact scope.) Side-effect counters
(nodes_created, relationships_created, properties_set, … in query
summaries) are reported on both surfaces: as stats in the REST write
response and as ResultSummary.counters over Bolt — see
Bolt → Side-effect counters
for which counters exist and which are deliberately never emitted.