Graph Data Modeling Patterns: From Whiteboard to Neo4j | Graphs | graphwiz.ai
Graph Data Modeling Patterns: From Whiteboard to Neo4j
neo4jgraph-databasesdata-modelingcypherontology
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.
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.
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., between people) with no attendant data.
KNOWS
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.
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:
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:
// 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. The HAS_SUBCATEGORY relationship 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 NEXT relationships. 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_WITH relationship 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.