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).
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 |
Putting It Together
Real-world graphs use these patterns in combination. A fraud detection system might use:
- Adjacency lists for user-account connections
- Intermediate nodes for transaction details (amount, timestamp, location)
- Time-series for account activity history
- Hierarchies for merchant category codes
- Polymorphic relationships for linking alerts to suspicious entities regardless of type
The ontology article established what exists in your graph. These patterns determine how it connects. Together they form the foundation of a graph that is queryable, performant, and maintainable as it grows.