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
-
~4% of the openCypher TCK does not pass (~3712 / 3868 scenarios; ~156 failures). 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 User-defined procedure fixtures 50 Scenarios that register a test procedure ( there exists a procedure …). patinaDB has no user-defined procedures, so these can’t run — one limitation, 26% of the failures.Missing typed errors 34 Negative tests expecting a specific error ( InvalidArgumentType,IntegerOverflow,DeletedEntityAccess,MergeReadOwnWrites, …). patinaDB is more permissive and doesn’t raise them — a validation gap, not wrong results on valid queries.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, …) ~30 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. -
No user-defined procedures or functions.
CALLreaches only the built-in procedures (Procedures). You cannot register your own. (This alone accounts for ~50 TCK failures, as above.) -
Constraints: uniqueness, existence (NOT NULL), node-key, property-type, and relationship existence/property-type are supported; relationship key/uniqueness is not.
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>are 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 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. Node-key uniqueness is served by an automatically registered backing compound index. Not supported: relationship key/uniqueness (a clear “not supported yet” error),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 redb file per database. patinaDB targets embedded and 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 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 (both the embedded and the server/Raft paths are implemented). For millions of rows the offlinepatinadb <db> importbulk-loader 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 (redb backend). patinaDB runs on redb,
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
redb 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). One window remains: the embedded
Dataset::querycapture path resolves ops after the in-memory mutation, so a very large single-query write is atomic only up to the flush buffer; the Raft leader closes this by resolving-then-applying against a mirror.
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). - RBAC: global + per-database roles + per-label grants + optional
closed-mode tenant isolation; enforcement is reject, not row-filtering.
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 rejects a query touching an ungranted (or unclassifiable) label — including one reached only through aWHERE EXISTS {…}, a RETURN pattern comprehension, or aCOUNT {…}subquery — rather than transparently filtering out the ungranted rows; row-level filtering, per-property privileges, and relationship-type/property RBAC (a label-granted user still reads all edge data) 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.
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 transient, driver-retryable error), which prevents lost updates. 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 or an application-level guard. See Bolt & Neo4j Browser.
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 (relationship key/uniqueness constraints, 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.