Limitations
This chapter is the honest inventory of what patinaDB does not do, or does differently from Neo4j. Read it before committing to a production workload.
Query language
-
~3% of the openCypher TCK does not pass (~3755 / 3868 scenarios pass, ~97.1%; ~113 failures — this number moves by a handful run to run because of a known, harmless grouping non-determinism, see below). The TCK is a prioritized gap report, not a guarantee. The failures are not separate bugs — they cluster as follows:
Cluster ~ fails What it is Missing typed errors 34 Negative tests expecting a specific error ( InvalidArgumentType,DeletedEntityAccess,MergeReadOwnWrites, …). patinaDB is more permissive and doesn’t raise them — a validation gap, not wrong results on valid queries.User-defined-procedure DDL/YIELD gaps ~16 Standalone CALL procwith no parentheses, standaloneCALL proc(...) YIELD *, a backtick-quotedRETURN … AS \ident`alias, and missing compile-time validation (arg-count/type mismatch, YIELD-alias shadowing) for aCALL`. The 36 other procedure-fixture scenarios (registering + invoking a test procedure) now pass.Quantifiers / comprehensions over entity lists ~14 any/all/none/singleand list comprehensions whose list holds nodes/relationships (not scalars), plus statically-true-predicate edge cases.ORDER BYedge cases~10 Ordering by expression / aggregate / cross-type value ordering. Temporal edge cases ~8 9-digit extended-year date parsing, datetimetimezone serialization,duration.betweenover huge spans.Aggregation grouping ~3 Aggregates inside non-aggregate expressions; multiple aggregates on one variable. Long tail (lists, literals, precedence, …) ~28 Many features with 1–3 scenarios each; no further large cluster. OPTIONAL MATCH … WHERE was closed (2026-07-08): a per-clause
WHEREon anOPTIONAL MATCHnow filters the optional side before the left-join null-extension (a non-matching predicate yields NULLs, not a dropped row), and a chainedOPTIONAL MATCHbinds variables introduced by an earlier optional. A negative sub-second-only ISO duration (P1DT-0.001S) also round-trips correctly now.MERGE
ON CREATE/ON MATCHis supported:SET x = <entity>,SET x += <map/entity>(nodes + relationships),SET x:Label, null-in-map removal, and combined ON CREATE+ON MATCH all work, plus MERGE matches-or- creates per row and undirected MERGE matches either direction.Known non-determinism (to fix):
GROUP BY … ORDER BY <aggregate> LIMIT napplies the limit in group-insertion order rather than after sorting by the aggregate, so the surviving rows can vary run to run. Read-only today (it does not diverge Raft followers, which apply leader-resolved ops), but a real correctness bug. -
User-defined procedures exist, but only as read-only, sandboxed WASM modules.
CREATE FUNCTION <name> LANGUAGE wasm FROM 'file://…' EXPORT '…'registers a compiled WebAssembly module, callable asCALL wasm.<name>(...); it cannot write to the graph, and has no filesystem/network/clock/RNG access (deterministic-by-construction, so it’s safe to replicate). It is not a scalar UDF — it can’t be used inline in an expression likeRETURN f(n.x). See WASM Procedures. This is a different mechanism from the TCK’s own ad-hoc test-fixture procedures (there exists a procedure …), which the TCK harness now implements directly against the sameCALLdispatch — see the DDL/YIELD gap row above for what still fails there. -
Constraints: all four Neo4j kinds — uniqueness, existence (NOT NULL), node-key/relationship-key, and property-type — are supported on both nodes and relationships.
CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR (n:Label) REQUIRE n.prop IS UNIQUE(plus the Neo4j-4ON (n:Label) ASSERT …form and the shorthandON :Label(prop)),REQUIRE n.prop IS NOT NULL(existence),REQUIRE (n.p1, n.p2) IS NODE KEY(composite unique + existence over the key),REQUIRE n.prop IS :: <TYPE>/IS TYPED <TYPE>(property-type), and the relationship formsFOR ()-[r:TYPE]-() REQUIRE r.prop IS NOT NULL | IS :: <TYPE> | IS UNIQUEandREQUIRE (r.p1, r.p2) IS [REL|RELATIONSHIP] KEYare all supported, along withDROP CONSTRAINT name [IF EXISTS]andSHOW CONSTRAINTS; all replicate across a cluster and are carried in Raft snapshots + portable backups. Enforcement is at write time onCREATE,MERGE, andSET/REMOVEfor nodes and on edge create/SET/REMOVEfor relationships (a UNIQUE NULL is exempt; existence/key rejects a missing/null required property, including aSET n.p = nullorREMOVE n.p; property-type rejects a present wrong-typed value but allows a missing/null one);CREATE CONSTRAINTrefuses to run if the existing data already violates the constraint.FOREACH-body mutations are enforced too (GH #575, fixed):SET(per-property and map-form+=/=) andREMOVEinside aFOREACH (… | …)block route through the same enforcing core as a top-levelSET/REMOVE, so aFOREACHwrite can no longer bypass a declared UNIQUE / existence / node-key / property-type constraint. AFOREACH-bodyCREATEpattern property that references the loop variable now resolves correctly (GH #578, fixed):FOREACH (i IN [1,2,3] | CREATE (:Tag {v: i}))creates nodes withv:1/v:2/v:3, notv: null— this was a separate, pre-existing data-correctness bug found while testing #575 (apply_foreach’s CREATE-path resolver only threaded vertex bindings, never the scalar loop variable, into the pattern-property resolver);toInteger(i)and other function-arg positions resolve it too. (A loop variable nested inside another map literal as a pattern property —{m: {inner: i}}— is still unsupported, but that is a separate, pre-existing parser limitation orthogonal toFOREACH— it reproduces identically with a plainUNWIND … AS i CREATE (n {m: {inner: i}}), noFOREACHinvolved.)MERGE/DELETE/SET n:Labelinside aFOREACHbody are rejected with a clear parse error (GH #579, fixed): the grammar used to accept them but the executor silently dropped them — aFOREACH (i IN [1] | MERGE (:X))/| DELETE m)/| SET m:VIP)returned success and wrote nothing. They now fail loud at parse time instead (SET/REMOVE/CREATEinside aFOREACHbody are unaffected); wiring MERGE/DELETE/label- add up to actually execute is a documented follow-on. Node-key and relationship-key uniqueness are each served by an index (an automatically registered backing compound index for node-key; the existing edge-property value index for relationship-key/uniqueness) — no separate index to create. Not supported:LIST<T>element-type refinement (bareLISTonly), and bare composite-UNIQUE without existence. See Constraints. (Index DDL —CREATE INDEX ON :Label(prop)and compound:Label(p1, p2)— is supported and replicates across a cluster; see the Data Model.) -
CREATE EDGE SORTED INDEXis not carried in Raft snapshots. The DDL replicates via the Raft log (re-run per node), so all live nodes serve it, but a node that bootstraps purely from a streamed snapshot after the log is purged will lack the def and fall back to traverse + sort until the DDL is re-issued. (Correctness is unaffected — the fallback returns identical rows.) -
No triggers, no stored procedures, no server-side scripting.
-
Side-effect counters are not tracked — query summaries don’t report
nodes created,relationships deleted, etc. TCK side-effect assertions are accepted as no-ops. -
shortestPathis supported, but the broader weighted/all-shortest-paths and full APOC-style procedure library are not. -
Some advanced predicate forms (
ALL/ANY/NONE/SINGLElist predicates,COUNT { … }subqueries, pattern comprehensions) are evaluated per-row (O(N)) — not yet lowered into the streaming operator tree for index/hash-join shortcuts — and the quantifiers do not yet fully handle lists whose elements are nodes/relationships (see the TCK table above).
Full-text search
- String properties only (matching Neo4j). No numeric/range terms inside the full-text query string.
- No phrase slop / proximity —
"a b"matches only exactly-consecutive terms;"a b"~3is not supported. - No highlighting or snippet extraction.
- Prefix/fuzzy expansion is capped at 256 terms per term to bound cost; very broad prefixes silently match only the first 256 dictionary expansions.
- Postings are updated read-modify-write with no segment merging. This is fine for typical write rates but is not engineered for very high write throughput over a large indexed corpus.
Storage & scale
- Single-machine storage. Each node holds the full graph; there is no horizontal sharding of one graph across machines. The cluster replicates, it does not partition.
- The whole graph is on one B-tree file per database (
patinadb.redb). patinaDB targets small-to-medium graphs, not multi-terabyte datasets. - Time-travel speed (not memory) grows with delta distance. Snapshots are
captured and reconstructed as a streamed O(chunk) record run — building a
periodic snapshot and rebuilding a past state both use bounded memory (the
reconstruction runs into an on-disk temp store, not RAM), so the graph is not
capped by memory. What still grows is time: reconstructing a point far from
the nearest snapshot replays a longer delta chain, so frequent snapshots keep
far-back queries fast. A narrow
AS OFread now scales with what the query touches, not the whole graph, once its snapshot window is warm (RFC-0011 increment 1): the nearest snapshot is materialised once and shared across every time-travel read in its window, and a query reads through it lazily with only the in-window deltas overlaid — so history browsing /AS OFdashboards / diff exploration over a large graph are fast. The first read into a cold snapshot window still pays the O(graph) snapshot materialisation (cold O(query) for point reads is RFC-0011 increment 2). - A plain
LOAD CSV … CREATEbuffers into one transaction. The CSV source streams row by row, but a bare (un-chunked) write buffers all resolved ops into a single transaction (on the server, one Raft entry) before committing — so a very largeLOAD CSV … CREATEhas anO(rows)memory cliff. Wrap it inCALL { … } IN TRANSACTIONS [OF n ROWS]to chunk it into many small commits withO(n)memory — one engram per chunk, and on a cluster oneclient_writeper chunk. For a large columnar file already on the server’s disk,POST /mgmt/upsert(index-accelerated match-or-create, chunkable) is faster still.LOAD CSV … RETURN row(read) streams end-to-end. See Bulk Loading & Import. - Some sort paths are O(N). Single-key
ORDER BYuses an index fast-path, but the general executor still hasO(N)sort paths for cases not covered by the fast-path (e.g. an un-limited multi-key sort with no covering index) — see Query Planning. - On-disk format evolution needs a migration or a dump/reload. Each database
records an on-disk schema version. Because the storage encoding is
non-self-describing bincode, appending a variant to the end of a persisted
enum is compatible and needs nothing, but a struct field add/remove/reorder, an
enum variant insert/reorder, or a key/value byte-encoding change is breaking and
bumps the version. An older database is not unconditionally bricked on open:
if a migration step is registered for the version boundary it is upgraded in
place; if none is registered the open fails loud and points you at the
dump/reload path — export with the old build via the portable backup
(
GET /mgmt/snapshot), upgrade, then import (that backup is JSON and cross-version by construction). A database written by a newer build than the running binary is always rejected (there is no downgrade). - Cross-tree atomicity is guaranteed by the embedded B-tree storage engine, whose write transactions are natively cross-tree atomic. A commit, a Raft-applied entry, and a snapshot install each land as an all-or-nothing transaction of any size: a crash before the terminal durability barrier rolls the store back to the prior root. This is proven non-vacuously by the crash-recovery harness (a torn cross-tree flush at the property↔value-index seam recovers atomically). The Raft leader resolves a data-mutating query against a throwaway mirror and applies the resolved batch as one crash-atomic engram, so a very large single-query write never partially mutates the live graph.
The server
- Native TLS (
--tls-cert/--tls-key) covers REST + management + peer RPCs and the Bolt endpoint (bolt+s:///wss://) (Authentication & TLS). It’s opt-in; without it everything is plaintext (terminate at a reverse proxy instead). - Firewall the Bolt port regardless of TLS. Issue #440 closed the
unauthenticated “slowloris” connection-exhaustion vector: a silent, a
stalled-mid-handshake, or an idled-after-HELLO socket is now bounded in
time by
--bolt-handshake-timeout-secs(default 10) /--bolt-idle-timeout-secs(default 300) and can never spend the--max-bolt-connectionspermit budget (default 1024, acquired only after a successful LOGON — see Connection resource limits). The Bolt endpoint is nonetheless still best run reachable only from your trusted backend network, not the open internet — every other resource (accepted file descriptors, per-message CPU) is still finite. - RBAC: global + per-database roles + per-label grants (row-filtered where
provably safe) + optional strict relationship-type/property modes + optional
closed-mode tenant isolation. Per-user accounts with
admin/writer/readerroles, per-database overrides (GRANT … ON DATABASE …), and per-labelREAD/WRITEgrants (GRANT READ|WRITE ON <db>:<Label> …) exist (Authentication & TLS). By default (open mode) a user’s global role reaches every database, including one it holds no grant on;--rbac-closedopts into real database-level deny for non-admins (see Authentication & TLS). Per-label enforcement for a read query is row-filtering, not a blanket reject, wherever it can be proven safe: a read that touches only granted labels returns full rows; a read that would observe any ungranted label (e.g. a traversal into it) returns an empty result — as if the ungranted nodes didn’t exist — rather than every property/value on the granted side being withheld one by one; only a read the analyzer cannot statically classify (an unlabeledMATCH (n), a procedureCALL, or a query that projects a whole matched entity that might carry an ungranted secondary label) is rejected outright, since row-filtering it could otherwise leak data through a side channel. A write touching an ungranted label is always rejected (a write can’t be partially applied). Two more layers are opt-in and reject-based (not row-filtered):--rbac-rel-grants(relationship-type grants — without it, a label-scoped user still reads all edge data) and--rbac-rel-property-grants(per relationship property). Per-property privileges on nodes are not yet implemented. Auth is fail-closed: an empty password refuses to start unless you pass--insecure-disable-auth. - Security audit log is node-local + in-memory. Authenticated write/admin/DDL
operations and authorization denials are recorded (
GET /mgmt/audit, plustracingtargetpatinadb::audit), but the ring is bounded, not Raft-replicated, and not persisted across a restart; reads are not audited. - No encryption-at-rest. The on-disk graph (and the audit ring) are not encrypted by patinaDB — use OS-level disk encryption. This is a storage-backend change tracked as a follow-on.
- No online membership reconfiguration UI — cluster changes go through the
/mgmt/*HTTP endpoints by hand. - Writes are linearizable through Raft, so write latency includes a consensus
round trip on a multi-node cluster. (A single
--bootstrapnode has no such cost.) - Reads are served from a node’s local applied state by default. On a
follower/learner this can momentarily lag the leader. For a read that reflects
all committed writes, request
"consistency": "linearizable"on/cypher(leader-only, one round-trip — see High Availability). This knob is REST-only; the Bolt path always reads locally. - Explicit Bolt transactions are snapshot-isolated, not serializable — and
your client MUST retry the conflict error.
BEGINpins a consistent snapshot (repeatable reads);COMMITis first-committer-wins (a write-write conflict against a commit made since the snapshot is rejected with a transientNeo.TransientError— surfaced to a Neo4j driver as the sameLockClientStopped-shaped transient conflict a real Neo4j cluster can also raise), which prevents lost updates. This is a hard requirement for concurrent multi-writer correctness, not an edge case to shrug off: use your driver’s managed-transaction API (execute_write/executeWrite, or equivalent), which retries a transient error automatically — a client that treats it as a hard failure will see avoidable errors under write contention even though the database itself never lost or corrupted any data. The same pipelined-write conflict-and-retry applies to autocommit writes on the server too. Snapshot isolation does not prevent write skew (a read-write conflict with disjoint write-sets), and it is not serializable and not linearizable. For an invariant spanning rows one transaction reads and another writes, use a single autocommit statement, a sentinel node your transactions all touch (forcing the conflict to be detected), or an application-level guard. See Bolt & Neo4j Browser. - An explicit (
BEGIN…COMMIT) transaction’s snapshot base is fast for a selective read, and still has a residual cost for a genuinely-scanning one at very large scale. Earlier, a managed transaction rebuilt its whole snapshot-isolation base from scratch on every statement, which under concurrent write load could mean nearly every transaction re-materialized the entire graph. That base is now shared and incrementally maintained, and a further fix removed a redundant planning-time cost for the common case — so a selective managed read (a point lookup, an indexed match) stays fast and roughly flat as the graph grows, even under heavy concurrent writes. What’s not yet fixed: a managed transaction that genuinely scans a large, uncovered portion of the graph still pays a cost that grows with graph size under write churn; closing that fully needs an MVCC-style read handle that has been built and reviewed but is not yet merged tomain. If your workload runs frequent large scans inside explicit transactions at very large scale, measure before committing to it.
Time travel
- Reads only. Time travel never writes. To bring a past state back to the
live graph, use
CALL patinadb.restore('<id>')— it promotes that state to HEAD as a new engram (append-only; see Engrams).
Compatibility
- patinaDB implements a subset of Neo4j. The Bolt endpoint and the system shim are sufficient for the official drivers and the Neo4j Browser, but Neo4j-specific admin surfaces, APOC, GDS, multi-database access control, and enterprise features are not present.
Tracked work
The limitations above that are called out as “not yet” or “a follow-on” are
on the roadmap (LIST<T> element-type refinement, the O(N) sort fast-path
integration, lowering list-predicate/subquery forms into the operator tree,
edge-sorted-index defs in Raft snapshots, and the deferred full-text items
above). If a limitation here blocks you, reach out to your vendor or account
representative to check on its status.