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

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 a Uuid). A lookup by the UUID string resolves the node on every surface — WHERE id(n) = '<uuid>', WHERE elementId(n) = '<uuid>', and MATCH (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.

  • id is an ordinary property — patinaDB does not hijack it. You may store and query your own id freely, 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 property
    

    The reserved property name __id is the internal element id: n.__id reads the node’s UUID and MATCH (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 a Neo.ClientError.Statement.ArgumentError and 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 r
    

    A driver’s r.element_id over Bolt 5+ carries the identical handle. (The legacy Bolt integer id field is derived from a lossy 63-bit hash and is not round-trippable — always use element_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:

TypeExample literalNotes
String'hello'Order-preserving in the value index.
Integer4264-bit signed.
Float3.14, 1.0e9, .564-bit IEEE-754.
Booleantrue, falseFirst-class (AttributeValue::Bool).
NullnullDrives three-valued logic (see below).
List[1, 2, 3]Heterogeneous; indexable, sliceable.
Map{a: 1, b: 'x'}Nested.
Temporaldate('2026-06-28'), datetime(...)Date / Time / LocalTime / DateTime / LocalDateTime / Duration.
Pointpoint({x: 1, y: 2})Cartesian/WGS-84 (2-D or 3-D); order-preserving space-filling-curve index. See Spatial / Geo.
Polygonpolygon([p1, p2, p3])Ring(s) of points, holes supported; not spatially indexed (full-scan only). See Spatial / Geo.
Pathresult of a path patternSequence 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.