Graph data modeling

This section documents the current Neo4j 2026.x calendar-versioned line — Neo4j moved from semantic to calendar versioning (YYYY.MM) in 2025, and the same line applies to the Graph Data Science library — as published at the Neo4j documentation, which is the reference these pages are written and verified against. No specific monthly patch is pinned. Some areas (Aura’s internal infrastructure, the Raft consensus implementation details, and the GDS Pregel API’s low-level internals) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as Neo4j iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

A property graph has no single "correct" schema derived from the data alone — the same facts can be shaped as nodes, relationships or properties in several valid ways. Modeling in Neo4j means starting from the questions the application must answer and shaping the graph so those questions become short, index-backed traversals.

Start from the questions, not a static schema

A relational schema is usually designed from the entities first (normalize the data, add foreign keys, worry about queries later). Graph modeling inverts that order: write down the concrete questions the application needs answered — "which products did this customer buy in the last 90 days", "what is the shortest approval chain between these two roles" — and let each question’s traversal shape drive which things become nodes, which become relationships, and which become properties (Modeling Tips).

The three-way decision for every real-world "thing" is:

  • Node — it has its own identity, is retrieved or matched on independently of anything else, or participates in more than one kind of connection. A Person, a Product, an Order are nodes because each is looked up, filtered and connected to different other things.

  • Relationship — it connects two things that already exist as nodes, and the connection itself is what a query traverses. Relationships are directed and typed, but their direction is often just a storage convenience — most queries can traverse either way, so pick the direction that reads naturally.

  • Property — it describes one node or relationship but is never itself queried as an independent entity, never shared across multiple parents, and never the target of its own traversal. A person’s birthDate is a property; if the application needed to query "everyone born in the same city" as a first-class entity with its own attributes, that city would earn a node instead.

// A first pass driven by a question: "which employees report, directly or
// transitively, into which department, and since when?"
CREATE (:Person {name: 'Ada', birthDate: date('1990-04-02')})
CREATE (:Person {name: 'Grace', birthDate: date('1985-11-30')})
CREATE (:Department {name: 'Engineering'})

MATCH (ada:Person {name: 'Ada'}), (grace:Person {name: 'Grace'})
MATCH (dept:Department {name: 'Engineering'})
CREATE (ada)-[:REPORTS_TO {since: date('2023-01-15')}]->(grace)
CREATE (ada)-[:WORKS_IN]->(dept)
CREATE (grace)-[:WORKS_IN]->(dept)

birthDate stays a property because no question asks for it independently; REPORTS_TO is a relationship because the traversal itself — who reports to whom, and transitively how far up the chain — is the point of the model, and it carries a since property because that single fact belongs to the connection, not to either person.

A property is promoted to a relationship (and its target to a node) as soon as a question needs to traverse it, filter on it independently, or attach further facts to it. A city string on Person stays a property until a question like "who else lives in this city" or "which cities does this company operate in" appears — at that point city becomes a City node and LIVES_IN a relationship, because now the value itself needs identity, indexing and its own connections (Modeling Designs).

Translating a relational join table

A many-to-many relational schema needs a join table only because SQL rows cannot point at two parents at once. A graph relationship already does that natively, so a join table almost always becomes a relationship, not a third node — unless the join row itself has its own identity that other rows point to.

// Relational shape being replaced:
//   students(id, name)
//   courses(id, title)
//   enrollments(student_id, course_id, grade, enrolled_at)   -- the join table

CREATE (:Student {id: 'std-1', name: 'Ada'})
CREATE (:Course {id: 'c-1', title: 'Graph Theory'})

MATCH (s:Student {id: 'std-1'}), (c:Course {id: 'c-1'})
CREATE (s)-[:ENROLLED_IN {grade: 'A', enrolledAt: date('2026-02-01')}]->(c)

// The former join table's own columns (grade, enrolled_at) live directly on
// the relationship -- no separate "Enrollment" node is needed to hold them.
MATCH (s:Student)-[e:ENROLLED_IN]->(c:Course)
WHERE e.grade = 'A'
RETURN s.name, c.title, e.enrolledAt

Keep the join table as its own node only when the join row is referenced by something other than its two endpoints — for example a Payment node pointing at a specific ENROLLED_IN fact, which relationships cannot be a traversal endpoint for. In that case, materialize an intermediate node (e.g. Enrollment) between Student and Course and hang both the grade and the payment link off it (Modeling Designs).

Anti-pattern: supernodes

A supernode is a single node with an extremely high relationship count — a "country" node connected to every citizen, a "status: active" node connected to every active order. Every traversal through it must scan all of its relationships even when only a handful matter to the query, and writes that touch it serialize against every other transaction touching the same node.

// Anti-pattern: every order connects straight to one shared "PENDING" node
MATCH (o:Order)-[:HAS_STATUS]->(s:Status {name: 'PENDING'})
RETURN count(o)
// A frequently written status shared by millions of orders makes ":Status"
// a hot supernode: high fan-out to traverse, and a write hotspot to update.

Mitigations:

  • Prefer a property over fan-out to a shared low-cardinality node. A status string property on Order, backed by an index, answers the same question without ever creating the supernode.

  • Break fan-out into intermediate nodes when the connection genuinely needs to stay a relationship (e.g. bucket a celebrity’s millions of followers by month or shard, rather than one FOLLOWS relationship per follower straight into the same node).

  • Push data onto the relationship itself instead of introducing a fan-out node purely to hold shared detail — a relationship property is cheaper to add than a new hop through a densely connected node.

// Mitigation: status becomes an indexed property, not a shared node
CREATE INDEX order_status IF NOT EXISTS FOR (o:Order) ON (o.status);

MATCH (o:Order {id: 'ord-42'})
SET o.status = 'PENDING';

MATCH (o:Order)
WHERE o.status = 'PENDING'
RETURN count(o);

See also Modeling Tips for further guidance on recognizing dense-node patterns before they reach production scale.

Versioned and temporal modeling

Two common patterns capture how facts change over time without losing the earlier values.

Dated relationships — add validFrom/validTo properties to the relationship itself, and query the one whose range covers a given point in time (a null or absent validTo means "still current").

// A person's address history as successive, non-overlapping relationships
MATCH (p:Person {name: 'Ada'}), (old:Address {city: 'Turin'}), (new:Address {city: 'Porto'})
CREATE (p)-[:LIVED_AT {validFrom: date('2020-01-01'), validTo: date('2025-06-01')}]->(old)
CREATE (p)-[:LIVED_AT {validFrom: date('2025-06-01'), validTo: null}]->(new)

// The address that was current on a given date
MATCH (p:Person {name: 'Ada'})-[r:LIVED_AT]->(a:Address)
WHERE r.validFrom <= date('2024-01-01')
  AND (r.validTo IS NULL OR r.validTo > date('2024-01-01'))
RETURN a.city

Snapshot nodes — when a whole entity’s state at a point in time matters (not just one relationship), version the node instead: create a new node per snapshot and chain them, keeping the identifying node stable.

// One stable Product node, with a new PriceSnapshot node per change
MATCH (prod:Product {sku: 'A-1'})
CREATE (snap:PriceSnapshot {price: 42.00, asOf: date('2026-03-01')})
CREATE (prod)-[:HAS_PRICE]->(snap)

// Most recent snapshot for a product
MATCH (p:Product {sku: 'A-1'})-[:HAS_PRICE]->(s:PriceSnapshot)
RETURN s.price, s.asOf
ORDER BY s.asOf DESC
LIMIT 1

Dated relationships suit facts that attach to an existing edge (a role held over a period, an address lived at); snapshot nodes suit facts about the whole entity’s state, especially when several properties change together and must be read back as one consistent version.

A dedicated Python-oriented treatment of this material exists — see the bibliography.

  • Data Modeling Tutorial — a worked, end-to-end modeling exercise from questions to a finished graph.

  • MongoDB Schema Design — the equivalent embed-vs-reference decision one layer over, in a document store rather than a property graph.

  • Choosing the Right Database — when a graph model is, and is not, the better fit than a relational or document store.