Skip to main content

The LoraDB Graph Data Model

LoraDB uses the labelled property graph model. Three things live in the graph:

PurposeExample
NodeAn entitya :Person, a :City, a :Movie
RelationshipA typed, directed link between two nodes(a)-[:KNOWS]->(b)
PropertyA typed key/value attached to a node or relationship{name: 'Ada', born: 1815}

Both nodes and relationships can carry any number of properties. Every relationship has exactly one type and exactly one direction, and always connects two (possibly equal) nodes.

See it in four queries

The model is easier to feel than to describe. Walk through these four queries in order — they build a tiny graph and read it back four different ways.

1. Make two nodes

CREATE (:Person {name: 'Ada',   born: 1815})
CREATE (:Person {name: 'Grace', born: 1906})

Two nodes with label Person and two properties each.

2. Connect them

MATCH (ada:Person {name: 'Ada'}), (grace:Person {name: 'Grace'})
CREATE (ada)-[:INFLUENCED {year: 1843}]->(grace)

One directed relationship with type INFLUENCED and its own year property.

3. Read nodes

MATCH (p:Person) RETURN p.name, p.born

Two rows — same shape as the properties we wrote. Label and direction are invisible in this projection.

4. Read through the relationship

MATCH (a)-[r:INFLUENCED]->(b)
RETURN a.name AS influencer, r.year AS year, b.name AS influenced

One row — Ada → Grace, with the relationship's own property alongside. Notice we can project properties from the relationship itself, not just from the nodes at its ends. That's the shape of a property graph: every piece — nodes, their labels, relationships, their types, and the properties on either side — is addressable in a query.

Vocabulary

TermMeaning
LabelA tag on a node. Zero or more per node. :Person, :Admin. Case-sensitive, conventionally PascalCase.
TypeThe kind of a relationship. Exactly one per edge. :FOLLOWS, :WORKS_AT. Case-sensitive, conventionally UPPER_SNAKE_CASE.
Property keyThe name of a key in a property map. Case-sensitive string.
DirectionSource → destination on a relationship. Mandatory at creation, optional in MATCH.
DegreeThe number of relationships touching a node.
PathAn alternating sequence of nodes and relationships, produced by a matched traversal.

Schema-free

LoraDB has no CREATE TABLE step. Labels, relationship types, and property keys are created implicitly the first time you use them in a write:

CREATE (c:Country {name: 'NL', iso: 'NLD'})

The first time this runs, the label Country and properties name, iso come into existence. Writes are permissive; reads validate labels and relationship types against the live graph. The full rules — and the trade-offs that come with "no schema" — live on their own page: Schema-free writes and soft validation.

Handle the lack of constraints in application code, or by matching before writing, or with MERGE for idempotent writes.

Relationship semantics

  • Direction is mandatory at creation ((a)-[:T]->(b)) but optional in MATCH: (a)-[:T]-(b) matches both directions.
  • A relationship has one type, not a list.
  • Types are case-sensitive strings, conventionally UPPER_SNAKE.
  • Relationships cannot be dangling — src and dst must exist at creation. Deleting a node with edges requires DETACH DELETE.
  • Self-loops are allowed: (a)-[:R]->(a).

Property values

Properties are typed. See Data Types for the full list — in short: scalars (null, booleans, integers, floats, strings), lists and maps, temporals (Date, Time, DateTime, Duration, …), and spatial points (2D and 3D, Cartesian and WGS-84).

CREATE (:Trip {
from: 'AMS',
to: 'LHR',
when: datetime('2026-04-20T08:00:00Z'),
duration: duration('PT75M'),
route: ['AMS', 'LHR'],
origin: point({latitude: 52.31, longitude: 4.76})
})

Identity

Every node and relationship gets an auto-generated u64 ID. IDs are:

  • Stable within a process — they do not change after creation.
  • Opaque — don't treat the number as meaningful; use properties for external identity.
  • Not reused — deleting an entity does not free its ID.

Use the built-in id() function to read the internal ID if you really need it, but prefer matching on your own property keys.

MATCH (n:User {email: $email}) RETURN id(n) AS internal_id

One useful trick

To avoid symmetric-pair duplicates in an undirected match, filter by id(a) < id(b):

MATCH (a:Person)-[:KNOWS]-(b:Person)
WHERE id(a) < id(b)
RETURN a.name, b.name

Otherwise you'd get both (alice, bob) and (bob, alice) rows.

Storage model (at a glance)

  • In-memory only. All data lives in the process; nothing persists across restarts. See Limitations for the full storage shape.
  • Single mutex. Queries serialise. No per-row locking, no isolation levels.
  • Adjacency on both ends. Each relationship is reachable from both endpoints without a separate index.

Comparison to other models

ModelHow LoraDB differs
Relational (SQL)No schema, no joins — relationships are first-class edges.
Document (JSON)Relationships are explicit, queryable, and indexable.
RDF / triplestoreRelationships carry properties; labels are per-node.
HypergraphNot supported — every edge connects exactly two nodes.

Modelling checklist

A short, pragmatic checklist when deciding how to model a new thing.

"Is it a node or a property?"

Use a nodeUse a property
You'll traverse to it from elsewhereIt's a leaf value
It has its own identity / lifecycleIt's strictly owned by one entity
It's enumerated over by other queriesIt's only read alongside its owner

Exampleemail: address of a user. On a User node, a string property. If two users share emails across accounts, promote to an :Email node with (u)-[:HAS_EMAIL]->(e).

"Is it a relationship or a node?"

If the "edge" itself has a lot of data, including another relationship pointing at it, it's probably a node. Cypher can't point edges at other edges:

-- Edge carrying a little data — fine
CREATE (a)-[:RATED {stars: 4, at: datetime()}]->(b)

-- Edge with further lifecycle / attachments — promote to node
CREATE (a)-[:WROTE]->(r:Review {stars: 4, body: '…', at: datetime()})
CREATE (r)-[:ABOUT]->(b)
CREATE (r)-[:IN_LANG]->(:Language {code: 'en'})

"Directional, undirected, or two edges?"

Data is…Model it as
Asymmetric (FOLLOWS, REPORTS_TO)One directed edge
Symmetric (FRIEND, MARRIED)One directed edge + undirected MATCH
Both sides carry data (mutual but with direction-specific fields)Two directed edges

Symmetric relationships storing one edge with undirected MATCH is cheaper and avoids mutability bugs — you never have to keep both mirror edges consistent.

"Small enumeration — property or labelled node?"

For something like order status with a known set of values:

-- String property — simpler
CREATE (:Order {id: 1, status: 'paid'})

-- Label — makes WHERE slightly more efficient and pattern-readable
CREATE (:Order:Paid {id: 1})

Labels as status flags work well when the status rarely changes and is often the primary filter. Property status scales better when values churn or when you carry status metadata (status_changed_at, status_reason).

What is not modeled

  • Hyperedges (a relationship connects exactly two nodes).
  • Typed schemas with required properties — LoraDB will happily create two :Person nodes with different property sets.
  • Uniqueness constraints — nothing prevents two nodes with identical labels and properties. Enforce uniqueness in your application code, or by matching before creating, or with MERGE.
  • Weighted relationships as a native primitive — shortest paths count hops regardless of any weight property.

See also