Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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~ failsWhat it is
    User-defined procedure fixtures50Scenarios 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 errors34Negative 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~14any/all/none/single and list comprehensions whose list holds nodes/relationships (not scalars), plus statically-true-predicate edge cases.
    ORDER BY edge cases~10Ordering by expression / aggregate / cross-type value ordering.
    Temporal edge cases~89-digit extended-year date parsing, datetime timezone serialization, duration.between over huge spans.
    Aggregation grouping~3Aggregates inside non-aggregate expressions; multiple aggregates on one variable.
    Long tail (lists, literals, precedence, …)~30Many features with 1–3 scenarios each; no further large cluster.

    OPTIONAL MATCH … WHERE was closed (2026-07-08): a per-clause WHERE on an OPTIONAL MATCH now filters the optional side before the left-join null-extension (a non-matching predicate yields NULLs, not a dropped row), and a chained OPTIONAL MATCH binds 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 MATCH is 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 n applies 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. CALL reaches 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-4 ON (n:Label) ASSERT … form and the shorthand ON :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 forms FOR ()-[r:TYPE]-() REQUIRE r.prop IS NOT NULL | IS :: <TYPE> are supported, along with DROP CONSTRAINT name [IF EXISTS] and SHOW CONSTRAINTS; all replicate across a cluster and are carried in Raft snapshots + portable backups. Enforcement is at write time on CREATE, MERGE, and SET/REMOVE for nodes and on edge create/SET/REMOVE for relationships (a UNIQUE NULL is exempt; existence rejects a missing/null required property, including a SET n.p = null or REMOVE n.p; property-type rejects a present wrong-typed value but allows a missing/null one); CREATE CONSTRAINT refuses 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 (bare LIST only), 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 INDEX is 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.

  • shortestPath is supported, but the broader weighted/all-shortest-paths and full APOC-style procedure library are not.

  • Some advanced predicate forms (ALL/ANY/NONE/SINGLE list 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).

  • 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"~3 is 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 … CREATE buffers 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 large LOAD CSV … CREATE has an O(rows) memory cliff. Wrap it in CALL { … } IN TRANSACTIONS [OF n ROWS] to chunk it into many small commits with O(n) memory — one engram per chunk, and on a cluster one client_write per chunk (both the embedded and the server/Raft paths are implemented). For millions of rows the offline patinadb <db> import bulk-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 BY uses an index fast-path, but the general executor still has O(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::query capture 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/reader roles, per-database overrides (GRANT … ON DATABASE …), and per-label READ/WRITE grants (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-closed opts 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 a WHERE EXISTS {…}, a RETURN pattern comprehension, or a COUNT {…} 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, plus tracing target patinadb::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 --bootstrap node 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. BEGIN pins a consistent snapshot (repeatable reads); COMMIT is 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.