Data Model
patinaDB stores a labelled property graph.
Vertices (nodes)
A vertex has:
- A stable UUID identity (assigned on creation, baked into the engram log so replays are deterministic).
- A primary label plus zero or more secondary labels (multi-label nodes
are supported;
labels(n)returns all of them). - A set of properties — string keys mapped to typed values.
CREATE (a:Person:Employee {name: 'Ada', born: 1815})
Edges (relationships)
An edge is directed, has exactly one type (a label), connects two vertices, and may carry its own properties:
CREATE (a)-[:KNEW {since: 1833}]->(b)
Edges can be traversed in either direction, and patterns may be left-to-right,
right-to-left, or undirected (-[:KNEW]-), in which case both directions
are searched.
Element identity (id() / elementId())
-
elementId(n)is the blessed, stable string handle for a node — the vertex’s UUID as a string. It is exactly what a driver stores and what you look nodes back up by:MATCH (n:Person) WHERE elementId(n) = $eid RETURN n -
id(n)returns the same UUID (as aUuid). A lookup by the UUID string resolves the node on every surface —WHERE id(n) = '<uuid>',WHERE elementId(n) = '<uuid>', andMATCH (n {__id: '<uuid>'})— so a round-trip never silently returns zero rows. All three are served by an O(1) point lookup, not a scan.There is no integer node id. patinaDB has no Neo4j-style integer id, so
WHERE id(n) = <number>(or a boolean) raises a clear error rather than silently matching nothing — compare against a string element id instead. -
idis an ordinary property — patinaDB does not hijack it. You may store and query your ownidfreely, exactly as in Neo4j:CREATE (:User {id: '550e8400-e29b-41d4-a716-446655440000', name: 'ada'}) MATCH (u:User {id: '550e8400-e29b-41d4-a716-446655440000'}) RETURN u // matches MATCH (u:User) WHERE u.id = 42 RETURN u // a plain propertyThe reserved property name
__idis the internal element id:n.__idreads the node’s UUID andMATCH (n:Label {__id: <uuid>})is the O(1) point lookup on it. Property names beginning with__are reserved for patinaDB; writing__id— via a pattern property,SET n.__id = …,SET n = {…}/n += {…}, or bulk import — is rejected with aNeo.ClientError.Statement.ArgumentErrorand leaves the graph untouched. -
id(r)/elementId(r)return a relationship’s canonical<start>:<end>:<type>string handle. Because an edge is that triple (the storage index keys on it), the handle is unique and stable. Fetch a relationship by it with a scan:MATCH ()-[r:KNEW]->() WHERE elementId(r) = $handle RETURN rA driver’s
r.element_idover Bolt 5+ carries the identical handle. (The legacy Bolt integeridfield is derived from a lossy 63-bit hash and is not round-trippable — always useelement_id.) The model is id-less by design: it cannot store two parallel edges of the same type between the same pair of endpoints, so the handle is unique over everything it can represent.
Parallel edges: what actually happens
The “no parallel edges” rule above is a storage fact — the triple
(start, end, type) is the edge’s identity. Unlike Neo4j, an edge here has
no independent relationship identity of its own, so a second edge of the same
type between the same two nodes has no separate place to live.
A CREATE that would collide with an existing edge is therefore rejected:
CREATE (a)-[:R {n: 2}]->(b) // (a)-[:R {n: 1}]->(b) already exists
fails with Neo.ClientError.Schema.ConstraintValidationFailed. The rejection
happens before anything is written: the existing edge keeps its properties
(n: 1 above), the graph is left untouched, and no write counters are
reported. There is no partial effect to clean up.
This is deliberate. CREATE is never MERGE semantics in Cypher, and the
alternative — quietly overwriting the existing edge’s properties — loses data
without saying so. If you want the upsert, say so explicitly with MERGE.
Bulk import paths (
LOAD CSV, CSV/Parquet/Arrow import) are not covered by this rejection. A bulk re-import is often deliberately upsert-shaped, so those paths keep the overwrite behaviour.
Modelling consequence. If your domain genuinely has several relationships of the same kind between the same two nodes — a user commenting on the same ticket twice, two separate employments of one person at one company — an edge per occurrence cannot express it. Reify the occurrence as an intermediate node instead:
// NOT this — the second CREATE is rejected, so the second comment is lost:
// (u:User)-[:COMMENTED]->(t:Ticket)
// This — one node per comment, edges stay unique:
CREATE (u:User)-[:WROTE]->(c:Comment {id: $commentId, body: $body, at: datetime()})
CREATE (c)-[:ON]->(t:Ticket)
A UNIQUE constraint on the intermediate node’s business key
(:Comment(id) above) then gives you real duplicate protection on top — see
Constraints.
Property value types
The AttributeValue type covers:
| Type | Example literal | Notes |
|---|---|---|
| String | 'hello' | Order-preserving in the value index. |
| Integer | 42 | 64-bit signed. |
| Float | 3.14, 1.0e9, .5 | 64-bit IEEE-754. |
| Boolean | true, false | First-class (AttributeValue::Bool). |
| Null | null | Drives three-valued logic (see below). |
| List | [1, 2, 3] | Heterogeneous; indexable, sliceable. |
| Map | {a: 1, b: 'x'} | Nested. |
| Temporal | date('2026-06-28'), datetime(...) | Date / Time / LocalTime / DateTime / LocalDateTime / Duration. |
| Point | point({x: 1, y: 2}) | Cartesian/WGS-84 (2-D or 3-D); order-preserving space-filling-curve index. See Spatial / Geo. |
| Polygon | polygon([p1, p2, p3]) | Ring(s) of points, holes supported; not spatially indexed (full-scan only). See Spatial / Geo. |
| Path | result of a path pattern | Sequence of nodes and edges. |
Three-valued (Kleene) logic
Comparisons and boolean operators follow SQL-style three-valued logic. Any
comparison involving null yields Unknown, not true or false:
RETURN 1 = null // null (Unknown)
RETURN null AND false // false (false dominates)
RETURN null OR true // true (true dominates)
IS NULL / IS NOT NULL remain strictly two-valued, by specification. In a
WHERE filter, Unknown collapses to “not matched”.
Storage internals (informational)
The on-disk tables, key layouts, inline labels and property storage are described in How Storage Works.
Properties are kept in a label-scoped, order-preserving value index, so a
scan over :Person.born never returns :Robot nodes with the same value, and
sorted pagination over a property is O(limit) rather than a full scan.
Compound indexes over several fields accelerate equality-prefix + sort
queries. You normally don’t manage these directly — they are maintained
automatically on writes. To declare your own indexes and enforce data integrity,
see Constraints and Query Planning;
Limitations lists the current DDL gaps.