Graph Data Modeling Patterns: From Whiteboard to Neo4j
Why Data Modeling Matters in Graphs
Relational databases have decades of normalisation theory. Graph databases have patterns — repeatable structural solutions to common data modelling problems. The difference is that a bad relational schema still answers queries (slowly), whereas a bad graph model often cannot answer the question at all without restructuring the graph.
The ontology article in this series covered what nodes and edges should exist in your domain. This article covers how to structure them — the canonical graph data modelling patterns that appear across virtually every Neo4j production deployment.
Pattern 1: The Adjacency List
The adjacency list is the default pattern: nodes connected directly by relationships. It maps naturally onto the property graph model and works well for simple domains.
CREATE (alice:Person {name: "Alice"})
CREATE (bob:Person {name: "Bob"})
CREATE (report:Report {title: "Q3 Analysis"})
CREATE (alice)-[:AUTHORED]->(report)
CREATE (bob)-[:REVIEWED]->(report)
This pattern is optimal when relationships are one-to-one or one-to-many, and when traversal depth is bounded. Most social graph queries fit here.
Use when: Your domain is a simple network of entities with direct relationships. Avoid when: You need to attach properties to the relationship itself, or model many-to-many connections with temporal semantics.
Pattern 2: Intermediate Nodes (Reification)
When a relationship needs properties — a timestamp, a weight, a status — promote the relationship to a node. This is called reification and it is the single most important graph modelling technique.
CREATE (alice:Person {name: "Alice"})
CREATE (project:Project {name: "GraphWiz"})
CREATE (assignment:Assignment {
role: "Lead Engineer",
startedAt: date("2026-01-15"),
endedAt: date("2026-06-30"),
allocation: 0.8
})
CREATE (alice)-[:ASSIGNED_TO]->(assignment)
CREATE (assignment)-[:ON_PROJECT]->(project)
The intermediate Assignment node carries metadata that a bare WORKS_ON relationship could not. It also allows the assignment to participate in its own relationships — for example, linking evaluations or timesheets to the assignment rather than to Alice or the project.
// Find all current leads on active projects
MATCH (p:Project {status: "active"})<-[*2]-(a:Person)
WHERE EXISTS {
MATCH (a)-[:ASSIGNED_TO]->(asgn:Assignment)
WHERE asgn.endedAt IS NULL
AND asgn.role CONTAINS "Lead"
}
RETURN a.name, p.name, asgn.role
Use when: A relationship has properties, multiple relationships of the same type need to coexist between the same nodes, or the relationship must connect to other entities.
Avoid when: The relationship is purely structural (e.g., KNOWS between people) with no attendant data.
Pattern 3: Time-Series and Event Modeling
Graphs are terrible at sequential scans and excellent at point-in-time lookups. The classic time-series pattern uses a linked-list of event nodes annotated with timestamps.
// Daily sensor readings as a linked timeline
CREATE (sensor:Sensor {id: "TEMP-001", location: "Rack-4A"})
CREATE (d1:Reading {date: date("2026-07-01"), temp: 42.1})
CREATE (d2:Reading {date: date("2026-07-02"), temp: 43.0})
CREATE (d3:Reading {date: date("2026-07-03"), temp: 41.8})
CREATE (sensor)-[:RECORDED]->(d1)
CREATE (d1)-[:NEXT]->(d2)
CREATE (d2)-[:NEXT]->(d3)
The NEXT chain enables range queries without scanning every node:
MATCH (s:Sensor {id: "TEMP-001"})-[:RECORDED]->(start:Reading {date: date("2026-07-01")})
MATCH path = (start)-[:NEXT*]->(end:Reading)
WHERE end.date <= date("2026-07-03")
RETURN [r IN nodes(path) | r.temp] AS temperatures
For high-frequency data (millions of events), use time-bucketed aggregation — group events into hourly or daily summary nodes to limit chain length:
| Event Volume | Bucket | Chain Depth | Query Pattern |
|---|---|---|---|
| < 1K/day | Per-event nodes | Days | Precise, easy to update |
| 1K–100K/day | Hourly summary | Hours | Aggregate at write time |
| > 100K/day | Daily or weekly summary | Weeks | Batch-processed |
Use when: You need to query events by time range, or model sequential processes (order fulfilment, CI/CD pipeline stages). Avoid when: You need real-time OLAP-style aggregation — that is what time-series databases are for.
Pattern 4: Hierarchies and Trees
Graphs handle hierarchies better than relational databases, but the modelling choice matters. Three approaches exist:
| Pattern | Query Ease | Write Ease | Use Case |
|---|---|---|---|
Parent pointer ([:PARENT_OF]) | Requires recursion | Simple | Org charts, categories |
| Materialised path (string property) | Single STARTS WITH | Moderate | Content taxonomies |
| Nested sets (left/right values) | Single range query | Complex (rewrites) | Static hierarchies |
For most Neo4j use cases, the parent pointer pattern combined with Cypher's variable-length path matching is the right default:
CREATE (root:Category {name: "Electronics"})
CREATE (laptops:Category {name: "Laptops"})
CREATE (phones:Category {name: "Phones"})
CREATE (macbooks:Category {name: "MacBooks"})
CREATE (thinkpads:Category {name: "ThinkPads"})
CREATE (root)-[:HAS_SUBCATEGORY]->(laptops)
CREATE (root)-[:HAS_SUBCATEGORY]->(phones)
CREATE (laptops)-[:HAS_SUBCATEGORY]->(macbooks)
CREATE (laptops)-[:HAS_SUBCATEGORY]->(thinkpads)
Query all descendants of Electronics:
MATCH (root:Category {name: "Electronics"})
MATCH (root)-[:HAS_SUBCATEGORY*]->(sub)
RETURN sub.name
Query two specific leaf nodes and their common ancestor:
MATCH path = (c1:Category {name: "MacBooks"})<-[:HAS_SUBCATEGORY*]-(ancestor)
WHERE EXISTS {
MATCH (ancestor)-[:HAS_SUBCATEGORY*]->(c2:Category {name: "ThinkPads"})
}
RETURN ancestor.name AS commonAncestor, length(path) AS depth
LIMIT 1
Use when: Organisational charts, product categories, permission hierarchies, or any domain with parent-child relationships. Avoid when: The tree is extremely deep (100+ levels) — consider materialised paths instead.
Pattern 5: Polymorphic Relationships
In graph databases, a single relationship type often needs to connect different node labels in semantically equivalent ways. For example, a TAGGED_WITH relationship might connect a Person, a Project, and a Document to a Tag node.
The cleanest approach is to keep the relationship type constant while varying the source label:
CREATE (tag:Tag {name: "graphrag"})
CREATE (article:Document {title: "GraphRAG Guide"})
CREATE (person:Person {name: "Alice"})
CREATE (project:Project {name: "GraphWiz"})
CREATE (article)-[:TAGGED_WITH]->(tag)
CREATE (person)-[:TAGGED_WITH]->(tag)
CREATE (project)-[:TAGGED_WITH]->(tag)
Querying polymorphic relationships uses label predicates:
// Find all people and documents tagged "graphrag"
MATCH (tag:Tag {name: "graphrag"})<-[:TAGGED_WITH]-(entity)
WHERE entity:Person OR entity:Document
RETURN
labels(entity) AS type,
coalesce(entity.name, entity.title) AS name
Use when: A concept (tags, statuses, locations) applies uniformly across different entity types.
Avoid when: The relationship semantics differ by entity type — use distinct relationship names instead (e.g., LOCATED_IN for places vs ASSIGNED_TO for people).
Common Modelling Anti-Patterns
Even experienced developers fall into these traps. Recognising them will save you hours of refactoring.
1. Proliferating Relationship Types
Creating a new relationship type for every semantic nuance — OWNS, PURCHASED, HAS_ORDER, PAID_FOR — bloats the schema and makes pattern matching unpredictable. The fix: consolidate semantically related edges into a single generic relationship pointing to an intermediate node with a type property.
// Anti-pattern: four relationship types for related concepts
(:Customer)-[:OWNS]->(:Product)
(:Customer)-[:PURCHASED]->(:Product)
// Fix: single relationship type with intermediate node
(:Customer)-[:HAS_TRANSACTION]->(:Transaction {type: "purchase"})-[:INVOLVES]->(:Product)
This keeps your relationship catalogue manageable and lets the Transaction node carry shared metadata (date, channel, invoice ID) that any of the original edge types would have duplicated.
2. Overloaded Node Properties
Dumping every known attribute as a node property creates wide, brittle nodes. A Person node carrying favouriteColour, lastLoginIP, onboardingCoach, and shoeSize makes the schema opaque and prevents those attributes from participating in relationships.
// Anti-pattern: everything as flat properties
CREATE (:Person {name: "Alice", lastLoginIP: "203.0.113.42", favouriteColour: "blue"})
// Fix: promote distinct concepts to their own nodes
CREATE (:Person {name: "Alice"})-[:LOGGED_IN_FROM]->(:Session {ip: "203.0.113.42"})
CREATE (:Person {name: "Alice"})-[:PREFERS]->(:Colour {name: "blue"})
A good rule of thumb: if a property is ever queried in a WHERE clause, used in aggregation, or joined across nodes, it deserves its own node or relationship.
3. Ignoring Relationship Direction
Leaving relationships directionless (-[:KNOWS]-) with Cypher's undirected syntax works for small graphs but limits the analytical power of your graph. Directed edges let GDS algorithms compute meaningful centrality, pathfinding, and community detection. Choose a direction even for symmetric domains — model bidirectionally as two directed edges when both directions carry distinct semantics.
// Anti-pattern: directionless — limits algorithm support
MATCH (a:Person)-[:KNOWS]-(b:Person)
// Better: explicit direction
MATCH (a:Person)-[:KNOWS]->(b:Person)
// Query undirectionally only when the domain genuinely demands it
When in doubt, ask: "Would PageRank or Betweenness Centrality on this relationship type produce a meaningful result?" If yes, pick a direction.
Choosing the Right Pattern
No single pattern solves every modelling problem. The decision depends on three dimensions:
| Dimension | Adjacency List | Intermediate Node | Time-Series | Hierarchy | Polymorphic |
|---|---|---|---|---|---|
| Relationship has data | No | Yes | Sometimes | No | No |
| Multi-hop traversal | Fast | Moderate | Chained | Recursive | Fast |
| Write complexity | Low | Moderate | Moderate (bucketing) | Low | Low |
| Query complexity | Low | Moderate | Moderate | Moderate (depth) | Low |
| Schema flexibility | High | Highest | Low (bucketed) | Low (stable tree) | Moderate |
FAQ: Common Modelling Questions
Should I use an intermediate node or relationship properties?
Relationship properties work well for simple, always-present metadata (2–3 fields). Promote to an intermediate node when the relationship carries 4+ attributes, needs to connect to other entities, or multiple instances must coexist between the same nodes with different metadata. For example, a WORKS_ON relationship with a role, start date, end date, and allocation percentage is better modelled as an :Assignment intermediate node.
Can I mix multiple patterns in a single query?
Yes. A single Cypher query can traverse an adjacency list into a hierarchy and then follow a time-series chain — Neo4j's query planner optimises the join. Always bound variable-length paths to prevent unbounded traversal: [:REL*..10].
What is the performance cost of intermediate nodes?
Each extra hop adds a relationship lookup. A path such as (:Person)-[:ASSIGNED_TO]->(:Assignment)-[:ON_PROJECT]->(:Project) requires two lookups versus one for a direct (:Person)-[:WORKS_ON]->(:Project). Profile both patterns with PROFILE and benchmark against your query latency requirements — flexibility trades off against speed.
Why is my hierarchy query slow?
Three common fixes: (1) add an index on the property used in your MATCH anchor clause; (2) bound variable-length depth to a reasonable maximum ([:HAS_SUBCATEGORY*..10]); (3) switch to the materialised path pattern if your tree exceeds 50 levels — a single STARTS WITH on a path string is faster than recursive traversal at scale.
Should relationships always be directed?
Use directed relationships by default — they encode semantic meaning (AUTHORED, CONTAINS). For truly symmetric connections such as KNOWS, model in one direction and query with (a)-[:KNOWS]-(b). GDS projections can change orientation at query time without modifying the stored graph, so you never need to store both directions.
Putting It Together
Real-world graphs rarely use a single pattern in isolation. The five patterns in this article compose naturally, and recognising how they fit together is the difference between a well-structured graph and one that fights every query.
Consider a knowledge graph pipeline that ingests technical documentation and exposes it for GraphRAG retrieval. Each stage of the pipeline maps to a different pattern:
-
Document hierarchy (Pattern 4) — Source documents are organised into a category tree:
Electronics → Laptops → ThinkPad T14. TheHAS_SUBCATEGORYrelationship supports queries like "find all documents under Electronics" with a single variable-length traversal. -
Chunk sequence (Pattern 3) — Each document is split into chunks, linked in reading order via
NEXTrelationships. This preserves document structure while enabling range queries — "give me chunks 3 through 7 of document X." -
Entity extraction with intermediate nodes (Pattern 2) — Entities extracted from each chunk carry extraction metadata: confidence score, extracted text span, and the LLM prompt version used. These are modelled as intermediate nodes:
(:Chunk)-[:EXTRACTED]->(:EntityMention {
confidence: 0.92,
textSpan: "ThinkPad T14 Gen 5",
extractedAt: datetime("2026-07-06T14:30:00Z")
})-[:REFERS_TO]->(:Product {name: "ThinkPad T14 Gen 5"})
The intermediate EntityMention node captures what the extraction itself produced, separate from the canonical product entity.
-
Entity relationships (Pattern 1) — Canonical entities connect via direct adjacency:
(:Product)-[:MANUFACTURED_BY]->(:Company). These edges form the browsable knowledge graph that powers GraphRAG traversal at query time. -
Cross-cutting tags (Pattern 5) — Tags like "cloud-native" or "AI-powered" attach polymorphically to documents, products, and companies alike through a single
TAGGED_WITHrelationship type, enabling queries like "find everything tagged 'AI-powered' regardless of entity type."
The power of composing these patterns becomes clear when you write a query that spans all five:
// Find AI-tagged products in the laptop category
// with high-confidence extractions from recent documents
MATCH (cat:Category {name: "Laptops"})-[:HAS_SUBCATEGORY*]->(doc:Document)
MATCH (doc)-[:NEXT*0..]->(chunk:Chunk)
MATCH (chunk)-[:EXTRACTED]->(mention:EntityMention)
WHERE mention.confidence > 0.85
MATCH (mention)-[:REFERS_TO]->(p:Product)
MATCH (p)-[:TAGGED_WITH]->(tag:Tag {name: "AI-powered"})
RETURN DISTINCT p.name, collect(DISTINCT doc.name) AS sources
ORDER BY p.name
This single Cypher statement traverses a hierarchy, walks a linked sequence, dereferences intermediate extraction nodes, follows adjacency edges, and filters by polymorphic tags — all five patterns composed into one unified traversal. No SQL equivalent can express this query without recursive CTEs, multiple joins, and application-level post-processing.
Pattern Composition Decision Tree
| If your application involves… | Use patterns… | Example domain |
|---|---|---|
| Hierarchical content with sequential children | 4 (tree) + 3 (time-series) | Documentation, e-book chapters |
| Extracted entities with provenance metadata | 2 (intermediate) + 1 (adjacency) | Knowledge graph construction |
| Cross-cutting classification across entity types | 5 (polymorphic) + 1 (adjacency) | Tagging, access control |
| Temporal event log on hierarchical org structure | 3 (time-series) + 4 (hierarchy) | Audit trails, compliance |
| Recommendation engine from tagged content graph | 5 (polymorphic) + 1 (adjacency) | Content discovery |
The art of graph data modeling is not memorising these patterns as isolated recipes but learning to see the underlying connection topologies in your domain. A product catalogue is a tree. A conversation thread is a linked list. A transaction network is a bipartite graph with intermediate nodes. Once you recognise the topology, the pattern choice becomes automatic.
Further Reading
- Ontology in Graph Databases — Formal ontology design principles: what becomes a node, what becomes a property, and how to seed your schema.
- Graph Data Modeling with Neo4j — Advanced patterns including hyperedges, dense node mitigation, time-aware graphs, and a decision matrix for relational vs. graph approaches.
- Knowledge Graph Construction from Unstructured Data — End-to-end pipeline for building knowledge graphs from raw text, covering chunking, schema-guided extraction, entity resolution, and GraphRAG integration.
- Introduction to GraphRAG — How to use the graphs you build for retrieval-augmented generation, with community summarisation and hybrid search strategies.
These patterns — adjacency lists, intermediate nodes, time-series chains, hierarchies, and polymorphic relationships — form the structural vocabulary of graph data modeling. Combined with a well-designed ontology, they are the foundation of a graph that remains queryable, performant, and comprehensible from hundreds to millions of nodes.