
Graph Schema
- 178 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
graph-schema: A skill for development. This provides functionality for development workflows.
Key points
- graph-schema
Graph Schema by the numbers
- 178 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #2,224 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill graph-schemaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 178 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use graph-schema for development tasks?
Use graph-schema for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with graph-schema.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use graph-schema for development tasks, or when graph-schema: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to graph-schema: graph-schema.
Files
dot-skills Graph Database Schema Design Best Practices
Comprehensive graph database data modeling guide for property graphs (Neo4j, Memgraph, Amazon Neptune, etc.). Contains 46 rules across 8 categories, prioritized by modeling impact from critical (entity classification, relationship design) to incremental (scale and evolution). Each rule includes detailed explanations, real-world Cypher examples comparing incorrect vs. correct models, and specific impact descriptions.
Philosophy: Data modeling correctness first, performance second. Always ask "what is the user trying to achieve?" before choosing structure.
When to Apply
Reference these guidelines when:
- Designing a new graph database schema from domain requirements
- Translating a relational schema to a graph model
- Deciding whether something should be a node, relationship, or property
- Reviewing an existing graph schema for modeling errors
- Refactoring a graph that produces awkward or slow queries
- Planning for schema evolution and data growth
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Entity Classification | CRITICAL | entity- |
| 2 | Relationship Design | CRITICAL | rel- |
| 3 | Property Placement | HIGH | prop- |
| 4 | Query-Driven Refinement | HIGH | query- |
| 5 | Structural Patterns | HIGH | pattern- |
| 6 | Anti-Patterns | MEDIUM | anti- |
| 7 | Constraints & Integrity | MEDIUM | constraint- |
| 8 | Scale & Evolution | LOW-MEDIUM | scale- |
Quick Reference
1. Entity Classification (CRITICAL)
- `entity-events` - Model multi-participant events as first-class nodes
- `entity-shared-values` - Promote shared property values to nodes
- `entity-specific-labels` - Use specific labels over generic ones
- `entity-multi-label` - Qualify entities with multiple labels
- `entity-identity-state` - Separate identity from mutable state
- `entity-reify-actions` - Reify lifecycle actions into nodes
- `entity-avoid-god-nodes` - Avoid kitchen-sink entity nodes
2. Relationship Design (CRITICAL)
- `rel-specific-types` - Use specific relationship types over generic ones
- `rel-meaningful-direction` - Choose semantically meaningful direction
- `rel-naming-conventions` - Follow UPPER_SNAKE_CASE for relationship types
- `rel-no-redundant-reverse` - Don't create redundant reverse relationships
- `rel-properties-scope` - Put data on relationships only when it describes the connection
- `rel-single-semantic` - One relationship type per semantic meaning
- `rel-typed-over-filtered` - Prefer typed relationships over generic + property filter
3. Property Placement (HIGH)
- `prop-no-foreign-keys` - Don't embed foreign keys as properties
- `prop-promote-to-node` - Promote frequently-queried values to nodes
- `prop-correct-data-types` - Use appropriate data types for properties
- `prop-no-arrays-for-connections` - Don't use property arrays when you need relationships
- `prop-relationship-vs-node-data` - Know when data belongs on relationship vs. node
4. Query-Driven Refinement (HIGH)
- `query-critical-traversals` - Design for your most critical traversals first
- `query-shortcut-relationships` - Add shortcut relationships for frequent multi-hop queries
- `query-denormalize-reads` - Denormalize for read-heavy paths
- `query-filter-by-rel-props` - Use relationship properties to filter traversals
- `query-test-before-deploy` - Test model against real queries before deploying
5. Structural Patterns (HIGH)
- `pattern-intermediary-nodes` - Use intermediary nodes for multi-entity relationships
- `pattern-hierarchy` - Model hierarchies with category nodes and depth relationships
- `pattern-linked-list` - Use linked lists for ordered sequences
- `pattern-timeline-tree` - Apply timeline trees for temporal data
- `pattern-fan-out` - Fan-out pattern for event streams and activity feeds
- `pattern-bipartite` - Use bipartite structure for many-to-many with context
6. Anti-Patterns (MEDIUM)
- `anti-join-table-nodes` - Don't model relational join tables as nodes
- `anti-generic-relationships` - Don't use generic RELATED_TO or CONNECTED relationships
- `anti-relational-porting` - Don't port relational schemas directly to graph
- `anti-over-modeling` - Don't make everything a node
- `anti-duplicate-data` - Don't duplicate data instead of creating relationships
- `anti-string-encoded-structure` - Don't encode structured data as delimited strings
7. Constraints & Integrity (MEDIUM)
- `constraint-unique-identifiers` - Define uniqueness constraints on natural identifiers
- `constraint-existence` - Use existence constraints for required properties
- `constraint-index-traversals` - Create indexes on traversal entry point properties
- `constraint-no-over-index` - Don't over-index — each index has a write cost
- `constraint-node-key` - Use composite node keys for natural multi-part identifiers
8. Scale & Evolution (LOW-MEDIUM)
- `scale-supernode-mitigation` - Mitigate supernodes with fan-out or partitioning
- `scale-temporal-versioning` - Separate current state from historical state
- `scale-schema-migration` - Plan for label and relationship type evolution
- `scale-batch-refactoring` - Use APOC or batched queries for schema refactoring
- `scale-dense-node-detection` - Monitor and detect emerging supernodes
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Graph Database Schema Design
Version 0.1.0 dot-skills March 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive graph database data modeling guide designed for AI agents and LLMs. Contains 46 rules across 8 categories, prioritized by impact from critical (entity classification, relationship design) to incremental (scale and evolution). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct graph models using Cypher, and specific impact descriptions to guide schema design decisions. Focuses primarily on data modeling correctness — understanding the user's goal and translating it into the right graph structure — with performance as a secondary concern.
---
Table of Contents
1. Entity Classification — CRITICAL
- 1.1 Avoid Kitchen-Sink Entity Nodes — CRITICAL (prevents unqueryable monoliths and supernodes)
- 1.2 Model Multi-Participant Events as First-Class Nodes — CRITICAL (prevents N+1 queries on event attributes)
- 1.3 Promote Shared Property Values to Nodes — CRITICAL (eliminates redundant data, enables faceted queries)
- 1.4 Qualify Entities with Multiple Labels — CRITICAL (enables cross-cutting queries without duplication)
- 1.5 Reify Lifecycle Actions into Nodes — CRITICAL (enables 3-5x richer queries on business events)
- 1.6 Separate Identity from Mutable State — CRITICAL (enables history tracking and temporal queries)
- 1.7 Use Specific Labels Over Generic Ones — CRITICAL (reduces traversal scope by orders of magnitude)
2. Relationship Design — CRITICAL
- 2.1 Avoid Redundant Reverse Relationships — CRITICAL (halves storage cost and prevents data inconsistency)
- 2.2 Choose Semantically Meaningful Relationship Direction — CRITICAL (prevents directional ambiguity and query logic errors)
- 2.3 Follow UPPER_SNAKE_CASE for Relationship Types — CRITICAL (prevents query bugs from inconsistent naming)
- 2.4 One Relationship Type per Semantic Meaning — CRITICAL (prevents ambiguous traversals and query errors)
- 2.5 Prefer Typed Relationships Over Generic + Property Filter — CRITICAL (eliminates property filtering on every traversal)
- 2.6 Put Data on Relationships Only When It Describes the Connection — CRITICAL (prevents misplaced data that becomes unqueryable)
- 2.7 Use Specific Relationship Types Over Generic Ones — CRITICAL (enables targeted traversals, avoids full-graph scans)
3. Property Placement — HIGH
- 3.1 Avoid Embedding Foreign Keys as Properties — HIGH (eliminates the #1 relational-thinking mistake in graphs)
- 3.2 Avoid Property Arrays When You Need Relationships — HIGH (prevents O(n) scans on opaque array values)
- 3.3 Know When Data Belongs on Relationship vs. Node — HIGH (prevents unqueryable properties and semantic confusion)
- 3.4 Promote Frequently-Queried Values to Nodes — HIGH (converts O(n) full-label scans to O(k) targeted traversals)
- 3.5 Use Appropriate Data Types for Properties — HIGH (enables range queries, saves storage, prevents data corruption)
4. Query-Driven Refinement — HIGH
- 4.1 Add Shortcut Relationships for Frequent Multi-Hop Queries — MEDIUM-HIGH (reduces 3-5 hop traversals to 1 hop for hot paths)
- 4.2 Denormalize for Read-Heavy Paths — MEDIUM-HIGH (eliminates N+1 traversals on read-heavy display paths)
- 4.3 Design the Model for Your Most Critical Traversals First — MEDIUM-HIGH (prevents costly schema refactors after deployment)
- 4.4 Test Your Model Against Real Queries Before Deploying — MEDIUM-HIGH (prevents 10-100× refactoring cost post-deployment)
- 4.5 Use Relationship Properties to Filter Traversals — MEDIUM-HIGH (reduces traversal scope by 10-100× on time-filtered queries)
5. Structural Patterns — HIGH
- 5.1 Apply Timeline Trees for Temporal Data — HIGH (enables efficient time-based queries without scanning all events)
- 5.2 Model Hierarchies with Category Nodes and Depth Relationships — HIGH (enables both drill-down and roll-up queries on taxonomies)
- 5.3 Use Bipartite Structure for Many-to-Many with Context — HIGH (reduces entity confusion and prevents 2× node duplication)
- 5.4 Use Fan-Out Pattern for Event Streams and Activity Feeds — HIGH (reduces timeline queries from O(n) to O(k) for last k events)
- 5.5 Use Intermediary Nodes for Multi-Entity Relationships — HIGH (enables connecting 3+ entities through one event node)
- 5.6 Use Linked Lists for Ordered Sequences — HIGH (preserves insertion order without index properties)
6. Anti-Patterns — MEDIUM
- 6.1 Avoid Duplicating Data Instead of Creating Relationships — MEDIUM (eliminates update anomalies and storage waste)
- 6.2 Avoid Encoding Structured Data as Delimited Strings — MEDIUM (prevents unqueryable opaque blobs hiding in properties)
- 6.3 Avoid Generic RELATED_TO or CONNECTED Relationships — MEDIUM (prevents ambiguous traversals that return wrong results)
- 6.4 Avoid Making Everything a Node — MEDIUM (avoids graph bloat and unnecessary traversal complexity)
- 6.5 Avoid Modeling Relational Join Tables as Nodes — MEDIUM (reduces traversal depth by 2× per join-table elimination)
- 6.6 Avoid Porting Relational Schemas Directly to Graph — MEDIUM (prevents graphs that are just slow, denormalized relational databases)
7. Constraints & Integrity — MEDIUM
- 7.1 Avoid Over-Indexing — Each Index Has a Write Cost — MEDIUM (prevents write amplification that degrades insert and update performance)
- 7.2 Create Indexes on Properties Used as Traversal Entry Points — MEDIUM (turns O(n) lookups into O(log n) for query starting points)
- 7.3 Define Uniqueness Constraints on Natural Identifiers — MEDIUM (prevents duplicate entities and enables fast lookups)
- 7.4 Use Composite Node Keys for Natural Multi-Part Identifiers — MEDIUM (enforces uniqueness on combinations, not just single properties)
- 7.5 Use Existence Constraints for Required Properties — MEDIUM (prevents NULL-related query failures at insert time)
8. Scale & Evolution — LOW-MEDIUM
- 8.1 Mitigate Supernodes with Fan-Out or Partitioning — LOW-MEDIUM (prevents single nodes from becoming traversal bottlenecks at scale)
- 8.2 Monitor and Detect Emerging Supernodes — LOW-MEDIUM (prevents 10-100× query slowdown from undetected supernodes)
- 8.3 Plan for Label and Relationship Type Evolution — LOW-MEDIUM (prevents breaking changes when the domain model evolves)
- 8.4 Separate Current State from Historical State — LOW-MEDIUM (enables time-travel queries without polluting current-state traversals)
- 8.5 Use APOC or Batched Queries for Schema Refactoring — LOW-MEDIUM (prevents out-of-memory errors on large-scale schema changes)
---
References
1. https://neo4j.com/docs/getting-started/data-modeling/ 2. https://neo4j.com/docs/getting-started/data-modeling/modeling-tips/ 3. https://neo4j.com/docs/getting-started/data-modeling/modeling-designs/ 4. https://neo4j.com/blog/graph-data-science/data-modeling-pitfalls/ 5. https://memgraph.com/docs/data-modeling/best-practices 6. https://bigbear.ai/blog/property-graphs-is-it-a-node-a-relationship-or-a-property/ 7. https://neo4j.com/graphacademy/training-gdm-40/03-graph-data-modeling-core-principles/ 8. https://neo4j.com/docs/cypher-manual/current/syntax/naming/
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Rule Title}
{1-3 sentences explaining WHY this matters for data modeling correctness and queryability.}
Incorrect ({what's wrong}):
// {Description of the modeling mistake}Correct ({what's right}):
// {Description of the correct model}When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Reference: [{Reference Title}]({Reference URL})
{
"version": "1.0.3",
"organization": "dot-skills",
"technology": "Graph Database Schema Design",
"date": "March 2026",
"abstract": "Comprehensive graph database data modeling guide designed for AI agents and LLMs. Contains 46 rules across 8 categories, prioritized by impact from critical (entity classification, relationship design) to incremental (scale and evolution). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct graph models using Cypher, and specific impact descriptions to guide schema design decisions. Focuses primarily on data modeling correctness — understanding the user's goal and translating it into the right graph structure — with performance as a secondary concern.",
"references": [
"https://neo4j.com/docs/getting-started/data-modeling/",
"https://neo4j.com/docs/getting-started/data-modeling/modeling-tips/",
"https://neo4j.com/docs/getting-started/data-modeling/modeling-designs/",
"https://neo4j.com/blog/graph-data-science/data-modeling-pitfalls/",
"https://memgraph.com/docs/data-modeling/best-practices",
"https://bigbear.ai/blog/property-graphs-is-it-a-node-a-relationship-or-a-property/",
"https://neo4j.com/graphacademy/training-gdm-40/03-graph-data-modeling-core-principles/",
"https://neo4j.com/docs/cypher-manual/current/syntax/naming/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Entity Classification (entity)
Impact: CRITICAL Description: What becomes a node determines the entire graph's shape and queryability — events, shared values, and domain concepts modeled as properties instead of nodes cripple traversal and insight.
2. Relationship Design (rel)
Impact: CRITICAL Description: Relationship type naming, direction, granularity, and property placement define traversal semantics — generic or misnamed relationships make the graph unreadable and unqueryable.
3. Property Placement (prop)
Impact: HIGH Description: Choosing whether data lives on a node, a relationship, or as a separate node affects correctness, deduplication, and query flexibility — misplaced properties are the most common modeling error.
4. Query-Driven Refinement (query)
Impact: HIGH Description: Understand your access patterns first, then design the model to serve them — shortcut relationships, denormalization, materialized paths — without breaking semantic correctness.
5. Structural Patterns (pattern)
Impact: HIGH Description: Proven graph structures (intermediary nodes, linked lists, hierarchies, temporal trees) solve recurring modeling challenges that ad-hoc designs get wrong.
6. Anti-Patterns (anti)
Impact: MEDIUM Description: Relational thinking habits (join tables as nodes, foreign key properties, generic relationships, over-modeling) are the most common source of bad graph schemas.
7. Constraints & Integrity (constraint)
Impact: MEDIUM Description: Uniqueness constraints, existence constraints, indexes, and validation rules enforce data quality in graph databases' schema-flexible world.
8. Scale & Evolution (scale)
Impact: LOW-MEDIUM Description: Supernode mitigation, temporal versioning, schema migration, and growth planning keep a correct model performant as data volume and complexity increase.
Avoid Duplicating Data Instead of Creating Relationships
Copying data between nodes (e.g., storing company name on every Employee node) is a relational-world habit. In graphs, you traverse: the company name lives on the Company node, and every Employee reaches it via the WORKS_AT relationship. Duplicated data becomes stale and inconsistent the moment the source changes.
Incorrect (company data duplicated on every employee node):
// Company info copied onto every employee — update nightmare when Acme moves offices
CREATE (alice:Employee {name: "Alice", companyName: "Acme Corp", companyAddress: "123 Main St"})
CREATE (bob:Employee {name: "Bob", companyName: "Acme Corp", companyAddress: "123 Main St"})
CREATE (carol:Employee {name: "Carol", companyName: "Acme Corp", companyAddress: "123 Main St"})
// Acme moves to a new address — must update every employee node:
MATCH (e:Employee {companyName: "Acme Corp"})
SET e.companyAddress = "456 Oak Ave"
// If one update fails or a new employee is created with the old address,
// the data is silently inconsistent. No constraint can prevent this.Correct (single source of truth reached via traversal):
// Company data lives in one place — all employees reach it via WORKS_AT
CREATE (acme:Company {name: "Acme Corp", address: "123 Main St"})
CREATE (alice:Employee {name: "Alice"})
CREATE (bob:Employee {name: "Bob"})
CREATE (carol:Employee {name: "Carol"})
CREATE (alice)-[:WORKS_AT {role: "Engineer"}]->(acme)
CREATE (bob)-[:WORKS_AT {role: "Designer"}]->(acme)
CREATE (carol)-[:WORKS_AT {role: "Manager"}]->(acme)
// Acme moves — single update, instantly consistent for all employees:
MATCH (c:Company {name: "Acme Corp"})
SET c.address = "456 Oak Ave"
// Every employee query gets the current address via traversal:
MATCH (e:Employee)-[:WORKS_AT]->(c:Company)
RETURN e.name, c.name, c.addressAvoid Generic RELATED_TO or CONNECTED Relationships
This rule is about semantic ambiguity. Generic relationship types like :RELATED_TO, :CONNECTED, :LINKED, or :HAS convey no meaning. When different developers use the same generic type for different meanings (friendship, employment, purchase), queries return wrong results because the database cannot distinguish between them. A MATCH ()-[:RELATED_TO]->() traversal silently mixes friends, employers, and products into a single result set.
Incorrect (generic relationships that obscure meaning):
// What does RELATED_TO mean? Different things in every context.
CREATE (alice:Person {name: "Alice"})
CREATE (bob:Person {name: "Bob"})
CREATE (acme:Company {name: "Acme Corp"})
CREATE (laptop:Product {name: "Laptop Pro"})
CREATE (alice)-[:RELATED_TO {type: "friend"}]->(bob)
CREATE (alice)-[:RELATED_TO {type: "employee"}]->(acme)
CREATE (alice)-[:RELATED_TO {type: "purchased"}]->(laptop)
// Every query must filter by a string property — error-prone and unindexable by type:
MATCH (alice:Person {name: "Alice"})-[r:RELATED_TO]->(target)
WHERE r.type = "friend"
RETURN target.name
// Typo "freind" silently returns zero results. No schema enforcement.Correct (specific, self-documenting relationship types):
CREATE (alice:Person {name: "Alice"})
CREATE (bob:Person {name: "Bob"})
CREATE (acme:Company {name: "Acme Corp"})
CREATE (laptop:Product {name: "Laptop Pro"})
CREATE (alice)-[:FRIEND_OF {since: date("2020-03-15")}]->(bob)
CREATE (alice)-[:WORKS_AT {role: "Engineer", startDate: date("2022-01-10")}]->(acme)
CREATE (alice)-[:PURCHASED {orderDate: date("2024-06-01")}]->(laptop)
// Queries are precise and self-documenting:
MATCH (alice:Person {name: "Alice"})-[:FRIEND_OF]->(friend:Person)
RETURN friend.name
// Relationship type IS the filter — no string matching, no ambiguitySee also: `rel-typed-over-filtered` for the performance cost of property filtering. `rel-specific-types` for naming conventions.
Avoid Modeling Relational Join Tables as Nodes
In relational databases, many-to-many relationships require a join table. In graphs, relationships ARE the join. Creating a node to represent a pure many-to-many connection (no additional data) adds an unnecessary hop to every traversal. Only create intermediary nodes when they carry meaningful data or need their own relationships.
Incorrect (relational join table ported as a node):
// StudentCourse node is a relational artifact — it carries no additional data
CREATE (s:Student {name: "Alice", id: "s1"})
CREATE (sc:StudentCourse {studentId: "s1", courseId: "c1"})
CREATE (c:Course {name: "Graph Theory", id: "c1"})
CREATE (s)-[:ENROLLED]->(sc)-[:FOR_COURSE]->(c)
// "What courses is Alice taking?" requires 2 hops through a meaningless intermediary:
MATCH (s:Student {name: "Alice"})-[:ENROLLED]->(:StudentCourse)-[:FOR_COURSE]->(c:Course)
RETURN c.nameCorrect (relationship carries the enrollment context directly):
// The relationship IS the join — enrollment metadata lives on the relationship
CREATE (s:Student {name: "Alice"})
CREATE (c:Course {name: "Graph Theory"})
CREATE (s)-[:ENROLLED_IN {semester: "Fall 2024", grade: "A", enrolledAt: date("2024-09-01")}]->(c)
// "What courses is Alice taking?" is a single hop:
MATCH (s:Student {name: "Alice"})-[e:ENROLLED_IN]->(c:Course)
RETURN c.name, e.semester, e.grade
// Only promote to a node if enrollment needs its own relationships
// (e.g., linking to an Instructor, Classroom, or FinancialAid entity)Avoid Making Everything a Node
Not every piece of data needs to be a node. Email addresses, phone numbers, and timestamps rarely need their own relationships. If a value is only ever accessed as part of its parent entity and never queried independently or shared across entities, keep it as a property. Over-modeling inflates the graph and adds traversal hops with no analytical benefit.
Incorrect (over-modeled contact information as separate nodes):
// Every scalar value promoted to a node — unless you're building an email analytics platform
CREATE (alice:Person {name: "Alice"})
CREATE (email:Email {value: "alice@example.com"})
CREATE (domain:Domain {name: "example.com"})
CREATE (phone:Phone {number: "+1-555-0123"})
CREATE (country:CountryCode {code: "+1"})
CREATE (alice)-[:HAS_EMAIL]->(email)
CREATE (email)-[:HAS_DOMAIN]->(domain)
CREATE (alice)-[:HAS_PHONE]->(phone)
CREATE (phone)-[:HAS_COUNTRY_CODE]->(country)
// "Get Alice's contact info" requires 4 hops across 5 nodes:
MATCH (a:Person {name: "Alice"})-[:HAS_EMAIL]->(e:Email),
(a)-[:HAS_PHONE]->(p:Phone)
RETURN e.value, p.numberCorrect (simple properties for data that doesn't need independent identity):
// Contact info as properties — accessed only through the Person node
CREATE (alice:Person {
name: "Alice",
email: "alice@example.com",
phone: "+1-555-0123"
})
// "Get Alice's contact info" is a single node lookup:
MATCH (a:Person {name: "Alice"})
RETURN a.email, a.phone
// Promote to a node ONLY when needed:
// - Multiple people share the same email (shared mailbox)
// - You query "find all people at example.com" frequently
// - The email itself has relationships (e.g., linked to a VerificationToken)Avoid Porting Relational Schemas Directly to Graph
Directly mapping relational tables to node labels and foreign keys to properties produces a graph that's worse than the relational original. Graph databases shine when you model relationships as first-class citizens. A ported relational schema has no traversable relationships, no semantic meaning in its labels, and pays the overhead of a graph engine while getting none of the benefits.
Incorrect (direct table-to-label mapping with foreign keys as properties):
// Tables ported 1:1 — foreign keys stored as properties, no relationships
CREATE (u:users {id: 1, name: "Alice", email: "alice@example.com"})
CREATE (o:orders {id: 100, user_id: 1, product_id: 50, status: "shipped"})
CREATE (p:products {id: 50, name: "Widget", category_id: 10})
CREATE (c:categories {id: 10, name: "Gadgets"})
// "What did Alice order?" requires property-matching instead of traversal:
MATCH (u:users {id: 1})
MATCH (o:orders {user_id: u.id})
MATCH (p:products {id: o.product_id})
RETURN p.name, o.status
// This is just a relational JOIN emulated in Cypher — slower and more awkwardCorrect (domain-driven graph model with relationships as first-class citizens):
// Domain concepts replace table names; relationships replace foreign keys
CREATE (alice:Customer {name: "Alice", email: "alice@example.com"})
CREATE (order:Order {placedAt: datetime("2024-11-01T14:30:00Z"), status: "shipped"})
CREATE (widget:Product {name: "Widget"})
CREATE (gadgets:Category {name: "Gadgets"})
CREATE (alice)-[:PLACED]->(order)
CREATE (order)-[:CONTAINS {quantity: 2, unitPrice: 29.99}]->(widget)
CREATE (widget)-[:IN_CATEGORY]->(gadgets)
// "What did Alice order?" is a natural traversal:
MATCH (alice:Customer {name: "Alice"})-[:PLACED]->(o:Order)-[:CONTAINS]->(p:Product)
RETURN p.name, o.status
// Clean, readable, and leverages the graph engine's traversal strengthsAvoid Encoding Structured Data as Delimited Strings
Storing structured data as delimited strings (e.g., tags: "python,go,rust" or path: "US/CA/SF") requires string parsing in every query. This defeats the graph's native ability to traverse structure. Split structured values into nodes or use native arrays only for truly flat, non-queryable lists.
Incorrect (structured data encoded as delimited strings):
// Tags and categories stored as comma-separated and slash-delimited strings
CREATE (a:Article {
title: "Intro to Graph Databases",
tags: "neo4j,graphs,databases,cypher",
categories: "tech/data-science/tutorials",
authors: "alice|bob|carol"
})
// Every query requires string manipulation:
MATCH (a:Article)
WHERE a.tags CONTAINS "neo4j"
RETURN a.title
// "neo4j" also matches "neo4j-enterprise" — no boundary safety.
// Cannot count articles per tag, find co-occurring tags, or traverse category hierarchy.Correct (structured data modeled as traversable nodes and relationships):
// Tags, categories, and authors are nodes with relationships
CREATE (a:Article {title: "Intro to Graph Databases"})
CREATE (neo4j:Tag {name: "neo4j"})
CREATE (graphs:Tag {name: "graphs"})
CREATE (databases:Tag {name: "databases"})
CREATE (a)-[:TAGGED]->(neo4j)
CREATE (a)-[:TAGGED]->(graphs)
CREATE (a)-[:TAGGED]->(databases)
CREATE (tutorials:Category {name: "tutorials"})
CREATE (dataSci:Category {name: "data-science"})
CREATE (tech:Category {name: "tech"})
CREATE (a)-[:IN_CATEGORY]->(tutorials)
CREATE (tutorials)-[:CHILD_OF]->(dataSci)
CREATE (dataSci)-[:CHILD_OF]->(tech)
// Precise tag query — no string parsing, no false matches:
MATCH (a:Article)-[:TAGGED]->(t:Tag {name: "neo4j"})
RETURN a.title
// Category hierarchy traversal:
MATCH (a:Article)-[:IN_CATEGORY]->(c:Category)-[:CHILD_OF*]->(parent:Category)
RETURN a.title, collect(parent.name) AS parentCategories
// Co-occurring tags analysis:
MATCH (a:Article)-[:TAGGED]->(t1:Tag), (a)-[:TAGGED]->(t2:Tag)
WHERE id(t1) < id(t2)
RETURN t1.name, t2.name, count(a) AS coOccurrencesUse Existence Constraints for Required Properties
Graph databases are schema-flexible by default — you can create a :Person node without a name property and nothing complains. Existence constraints enforce that critical properties are always present, catching data quality issues at insert time rather than discovering NULL values during downstream queries or application code. Use sparingly: only constrain properties that are truly required for the domain to function.
Incorrect (no existence constraint — incomplete data sneaks in):
// No constraint — some Person nodes have name, others don't
CREATE (:Person {email: "alice@example.com", name: "Alice"})
CREATE (:Person {email: "bob@example.com"}) // no name — allowed silently
// Application code breaks on null names
MATCH (p:Person)
RETURN p.name + " (" + p.email + ")"
// Returns null for Bob — causes NullPointerException in application layer
// Worse: aggregation queries silently exclude incomplete records
MATCH (p:Person)
WHERE p.name STARTS WITH "A"
RETURN count(p) // Bob is invisible — data quality issue goes unnoticedCorrect (existence constraint catches missing data at write time):
// Every Person must have a name — insert without one fails immediately
CREATE CONSTRAINT person_name_exists FOR (p:Person) REQUIRE p.name IS NOT NULL
// Every Order must have a total and a customerId
CREATE CONSTRAINT order_total_exists FOR (o:Order) REQUIRE o.total IS NOT NULL
CREATE CONSTRAINT order_customer_exists FOR (o:Order) REQUIRE o.customerId IS NOT NULL
// This now fails with a clear error at insert time
CREATE (:Person {email: "bob@example.com"}) // ERROR: name is required
// Correct insert
CREATE (:Person {email: "bob@example.com", name: "Bob"}) // succeedsCreate Indexes on Properties Used as Traversal Entry Points
Every graph query starts somewhere — typically by finding a specific node via a property lookup (WHERE p.email = "alice@example.com"). Without an index, this requires a full label scan over every node with that label. Index the properties that serve as query entry points: identifiers, names, dates used in range filters. Don't index properties that are only accessed after a traversal — those nodes are already found by following edges.
Incorrect (no index — full label scan on every query start):
// No index on email — this scans every Person node to find Alice
MATCH (p:Person {email: "alice@example.com"})-[:PLACED]->(o:Order)
RETURN o.total, o.date
// With 10M Person nodes, this initial lookup is O(n) — slow
// No index on Order.date — range queries scan all orders
MATCH (o:Order)
WHERE o.date >= date("2024-01-01") AND o.date < date("2024-02-01")
RETURN o.orderNumber, o.total
// Full scan of every Order node to filter by date rangeCorrect (indexes on entry-point properties — O(log n) lookups):
// Index on the property used to start traversals
CREATE INDEX person_email FOR (p:Person) ON (p.email)
// Composite index for queries that filter on multiple properties
CREATE INDEX order_date_status FOR (o:Order) ON (o.date, o.status)
// Now these queries use the index for fast entry-point lookup
MATCH (p:Person {email: "alice@example.com"})-[:PLACED]->(o:Order)
RETURN o.total, o.date
// O(log n) lookup for Alice, then traversal to her orders
// Range query uses the composite index
MATCH (o:Order)
WHERE o.date >= date("2024-01-01") AND o.status = "SHIPPED"
RETURN o.orderNumber, o.total
// Index narrows to matching orders — no full scanAvoid Over-Indexing — Each Index Has a Write Cost
Every index must be updated on every write to the indexed property. In write-heavy workloads (event ingestion, IoT sensors, real-time activity feeds), over-indexing can degrade write throughput significantly. Index only properties that serve as query entry points or appear in frequent WHERE clauses — not every property on a node.
Incorrect (indexes on every property — write amplification):
// Indexing every property on Person — 7 indexes
CREATE INDEX person_email FOR (p:Person) ON (p.email)
CREATE INDEX person_name FOR (p:Person) ON (p.name)
CREATE INDEX person_age FOR (p:Person) ON (p.age)
CREATE INDEX person_created FOR (p:Person) ON (p.createdAt)
CREATE INDEX person_last_login FOR (p:Person) ON (p.lastLogin)
CREATE INDEX person_bio FOR (p:Person) ON (p.bio)
CREATE INDEX person_avatar FOR (p:Person) ON (p.avatarUrl)
// Every Person write now updates 7 indexes
// lastLogin updates on every session — 7 index writes per login
SET p.lastLogin = datetime() // triggers index maintenance on all 7Correct (index only entry-point properties — minimal write overhead):
// email is a unique identifier — constrained (auto-indexed)
CREATE CONSTRAINT person_email_unique FOR (p:Person) REQUIRE p.email IS UNIQUE
// createdAt is used for time-range queries (e.g., "users who signed up this month")
CREATE INDEX person_created FOR (p:Person) ON (p.createdAt)
// name, age, bio, avatarUrl, lastLogin are NOT indexed
// They are accessed after traversal, not used as query entry points
// e.g., MATCH (p:Person)-[:FRIEND_OF]->(f) RETURN f.name
// f is found via traversal — f.name doesn't need an index
// Only 2 indexes maintained on writes instead of 7
SET p.lastLogin = datetime() // no unnecessary index maintenanceUse Composite Node Keys for Natural Multi-Part Identifiers
Some entities have natural composite identifiers: a flight is unique by (airline + flightNumber + date), a course enrollment by (studentId + courseId + semester), a warehouse slot by (warehouseId + aisle + shelf). A single-property uniqueness constraint cannot express this. Composite node key constraints enforce that the combination of properties is unique and that all component properties exist, while also creating a composite index for efficient lookups.
Incorrect (single-property constraint on a naturally composite identifier):
// Only constraining flightNumber — but flight numbers are reused across airlines and dates
CREATE CONSTRAINT flight_number_unique FOR (f:Flight) REQUIRE f.flightNumber IS UNIQUE
// These are different flights but "123" alone collides
CREATE (:Flight {airline: "UA", flightNumber: "123", date: date("2024-03-15")})
CREATE (:Flight {airline: "AA", flightNumber: "123", date: date("2024-03-15")})
// ERROR: constraint violation — but these are legitimately different flights
// Without any constraint, duplicates accumulate
CREATE (:Flight {airline: "UA", flightNumber: "123", date: date("2024-03-15")})
CREATE (:Flight {airline: "UA", flightNumber: "123", date: date("2024-03-15")})
// Two identical flights — no constraint to prevent itCorrect (composite node key enforces uniqueness on the combination):
// Node key: the combination of airline + flightNumber + date must be unique
// Also enforces that all three properties exist (IS NOT NULL implied)
CREATE CONSTRAINT flight_key FOR (f:Flight)
REQUIRE (f.airline, f.flightNumber, f.date) IS NODE KEY
// Different airlines with same flight number — allowed
CREATE (:Flight {airline: "UA", flightNumber: "123", date: date("2024-03-15")}) // OK
CREATE (:Flight {airline: "AA", flightNumber: "123", date: date("2024-03-15")}) // OK
// Same airline, same number, same date — rejected
CREATE (:Flight {airline: "UA", flightNumber: "123", date: date("2024-03-15")})
// ERROR: node key constraint violation
// Composite index is auto-created — fast lookup by combination
MATCH (f:Flight {airline: "UA", flightNumber: "123", date: date("2024-03-15")})
RETURN f // O(log n) via composite indexNote: IS NODE KEY constraints require Neo4j Enterprise Edition. On Community Edition, combine a uniqueness constraint with individual existence constraints to approximate this. Memgraph and Neptune have different constraint mechanisms — consult their documentation for equivalents.
Define Uniqueness Constraints on Natural Identifiers
Without uniqueness constraints, duplicate nodes can silently accumulate — two :Person nodes for "Alice" with slightly different property sets. Constraints enforce data integrity at the database level AND automatically create an index for O(1) lookups. Always constrain natural identifiers: email, SSN, product SKU, order number.
Incorrect (no uniqueness constraint — duplicates accumulate silently):
// No constraint defined — MERGE may create duplicates if properties don't match exactly
// and lookups by identifier require full label scans
CREATE (:Person {email: "alice@example.com", name: "Alice"})
CREATE (:Person {email: "alice@example.com", name: "Alice Smith"})
// Two Person nodes for the same email now exist
// MATCH (p:Person {email: "alice@example.com"}) returns both — which is correct?Correct (uniqueness constraint prevents duplicates and auto-indexes):
// Constraint enforces uniqueness and creates an automatic index
CREATE CONSTRAINT person_email_unique FOR (p:Person) REQUIRE p.email IS UNIQUE
// Now this fails immediately with a constraint violation
CREATE (:Person {email: "alice@example.com", name: "Alice"})
CREATE (:Person {email: "alice@example.com", name: "Alice Smith"}) // ERROR: already exists
// MERGE safely finds-or-creates with O(1) lookup via the auto-index
MERGE (p:Person {email: "alice@example.com"})
ON CREATE SET p.name = "Alice Smith", p.createdAt = datetime()
ON MATCH SET p.lastSeen = datetime()Avoid Kitchen-Sink Entity Nodes
Stuffing every attribute into a single node type creates "god nodes" that are hard to query, hard to evolve, and become supernodes. If a node has 20+ properties or connects to 10+ relationship types, it likely represents multiple domain concepts that should be decomposed.
Incorrect (monolithic node with everything inlined):
// 20+ properties spanning multiple domain concepts crammed into one node
CREATE (:User {
name: "Alice", email: "alice@corp.com", phone: "+1-555-0100",
addressStreet: "123 Main St", addressCity: "Portland", addressZip: "97201",
companyName: "Acme Corp", companyRole: "Staff Engineer", companyStartDate: "2021-03-01",
department: "Platform", managerId: "U-2002",
skill1: "Python", skill2: "GraphQL", skill3: "Neo4j",
cert1: "AWS Solutions Architect", cert2: "Neo4j Certified Professional",
emergencyContactName: "Bob", emergencyContactPhone: "+1-555-0200"
})Correct (decomposed into focused domain entities):
// Each node type represents one domain concept
CREATE (alice:User {name: "Alice", email: "alice@corp.com", phone: "+1-555-0100"})
CREATE (alice)-[:LIVES_AT]->(:Address {street: "123 Main St", city: "Portland", zip: "97201"})
CREATE (alice)-[:WORKS_AT {role: "Staff Engineer", since: date("2021-03-01")}]->(acme:Company {name: "Acme Corp"})
CREATE (acme)-[:HAS_DEPARTMENT]->(platform:Department {name: "Platform"})
CREATE (alice)-[:IN_DEPARTMENT]->(platform)
CREATE (alice)-[:REPORTS_TO]->(:User {name: "Bob"})
CREATE (alice)-[:HAS_SKILL]->(:Skill {name: "Python"})
CREATE (alice)-[:HAS_SKILL]->(:Skill {name: "GraphQL"})
CREATE (alice)-[:HAS_CERTIFICATION]->(:Certification {name: "AWS Solutions Architect"})Model Multi-Participant Events as First-Class Nodes
When an event involves multiple participants (sender, recipients, CCs), it MUST be a node because a relationship only connects two nodes. "Bob emailed Charlie" hides the Email event -- and the CC list, attachments, and thread that connect to it. The moment a third entity participates, a relationship cannot represent the event.
Incorrect (event collapsed into a relationship):
// Can't attach CC recipients, attachments, or thread relationships to a relationship
CREATE (bob:Person {name: "Bob"})-[:EMAILED {subject: "Q1 Report", date: "2024-03-15", body: "Please review..."}]->(charlie:Person {name: "Charlie"})Correct (event modeled as a first-class node):
// The Email node captures the full event with all participants
CREATE (bob:Person {name: "Bob"})-[:SENT]->(email:Email {subject: "Q1 Report", date: date("2024-03-15"), body: "Please review..."})-[:TO]->(charlie:Person {name: "Charlie"})
CREATE (email)-[:CC]->(dana:Person {name: "Dana"})
CREATE (email)-[:HAS_ATTACHMENT]->(file:File {name: "q1-report.pdf"})
CREATE (email)-[:IN_THREAD]->(thread:Thread {id: "thread-442"})When NOT to apply
Don't promote to a node if the action only involves two entities and carries no additional context beyond the relationship itself. For example, (alice)-[:FOLLOWS]->(bob) is fine as a relationship when there are no other participants or rich attributes to attach. Promote only when a third (or more) participant needs to connect to the same event.
See also: `entity-reify-actions` for actions with their own lifecycle (created, shipped, delivered, returned).
Separate Identity from Mutable State
When mutable attributes (address, job title, status) live directly on the identity node, you lose history. Separating identity (who/what) from state (current attributes) via relationships lets you track changes over time and answer "what was X's address in January?"
Incorrect (mutable state on the identity node):
// Overwriting loses previous values — no history
CREATE (:Patient {name: "Alice", address: "123 Main St", insurance: "BlueCross", primaryDoctor: "Dr. Smith"})
// When Alice moves:
MATCH (p:Patient {name: "Alice"})
SET p.address = "456 Oak Ave"
// Previous address "123 Main St" is gone foreverCorrect (identity separated from versioned state):
// Previous states preserved as separate nodes
CREATE (alice:Patient {id: "P-1001", name: "Alice", dob: date("1990-05-12")})
CREATE (alice)-[:HAS_STATE {from: date("2023-01-01"), to: date("2024-06-30")}]->(:PatientState {address: "123 Main St", insurance: "BlueCross", primaryDoctor: "Dr. Smith"})
CREATE (alice)-[:HAS_STATE {from: date("2024-07-01")}]->(:PatientState {address: "456 Oak Ave", insurance: "Aetna", primaryDoctor: "Dr. Jones"})
// Query: "What was Alice's address in March 2024?"
// MATCH (p:Patient {name: "Alice"})-[s:HAS_STATE]->(state)
// WHERE s.from <= date("2024-03-01") AND (s.to IS NULL OR s.to >= date("2024-03-01"))
// RETURN state.addressSee also: `scale-temporal-versioning` for optimizing current vs. historical state queries at scale.
Qualify Entities with Multiple Labels
Real-world entities have multiple facets. A person can be both an Employee and a Manager. Using a single label forces you to either duplicate the node or query by property. Multiple labels let you query by any facet: "all Managers" or "all Employees" or "all Manager-Employees."
Incorrect (single label with role property or duplicated nodes):
// Option A: role as property — can't query by role without filtering
CREATE (:Person {name: "Alice", role: "manager"})
// MATCH (p:Person {role: "manager"}) requires property scan
// Option B: duplicated nodes — data inconsistency risk
CREATE (:Manager {name: "Alice", email: "alice@corp.com"})
CREATE (:Employee {name: "Alice", email: "alice@corp.com"})
// Updating Alice's email requires finding both nodesCorrect (multiple labels on a single node):
// Queryable as Manager, Employee, or Person without duplication
CREATE (alice:Person:Employee:Manager {name: "Alice", email: "alice@corp.com"})
// MATCH (m:Manager) — finds Alice
// MATCH (e:Employee) — also finds Alice
// MATCH (p:Person:Manager) — finds all managers who are personsReify Lifecycle Actions into Nodes
When an action has its own lifecycle or needs to connect to follow-up events, it must be a node. A Purchase transitions through states (created, shipped, delivered, returned) and links to downstream events (refund, complaint, review). Modeling it as a relationship locks out the entire event chain. Verbs like "purchased", "reviewed", "transferred" carry rich context (amount, rating, reason) that doesn't fit on a relationship.
Incorrect (action collapsed into a relationship):
// Can't connect to ShippingAddress, link to a return/refund, or add line items
CREATE (:Customer {name: "Alice"})-[:PURCHASED {price: 29.99, date: "2024-03-15", paymentMethod: "card", shippingSpeed: "express"}]->(:Product {name: "Wireless Headphones"})Correct (action reified as a node):
// The Purchase node connects to all participants in the transaction
CREATE (alice:Customer {name: "Alice"})-[:MADE]->(purchase:Purchase {price: 29.99, date: date("2024-03-15"), status: "completed"})-[:OF]->(headphones:Product {name: "Wireless Headphones"})
CREATE (purchase)-[:PAID_WITH]->(card:CreditCard {last4: "4242"})
CREATE (purchase)-[:SHIPPED_TO]->(addr:Address {street: "456 Oak Ave", city: "Portland"})
CREATE (purchase)-[:FULFILLED_BY]->(warehouse:Warehouse {code: "PDX-01"})
// Returns and refunds can now link back to the Purchase
CREATE (refund:Refund {amount: 29.99, reason: "defective"})-[:FOR]->(purchase)See also: `entity-events` for multi-participant events (3+ entities connected to the same action).
Promote Shared Property Values to Nodes
When the same value (e.g., a city name, a skill, a tag) appears as a property on hundreds of nodes, you lose the ability to query "all people in London" efficiently and you duplicate storage. Promote to a node when the value is shared across 3+ entities OR when you need to traverse through it.
Incorrect (shared value duplicated as inline properties):
// No way to find all Londoners without a full property scan across every Person node
CREATE (:Person {name: "Alice", city: "London"})
CREATE (:Person {name: "Bob", city: "London"})
CREATE (:Person {name: "Charlie", city: "London"})
// MATCH (p:Person {city: "London"}) must scan all Person nodesCorrect (shared value promoted to a node):
// All Londoners found by traversing from the City node
CREATE (london:City {name: "London", country: "UK"})
CREATE (:Person {name: "Alice"})-[:LIVES_IN]->(london)
CREATE (:Person {name: "Bob"})-[:LIVES_IN]->(london)
CREATE (:Person {name: "Charlie"})-[:LIVES_IN]->(london)
// MATCH (:City {name: "London"})<-[:LIVES_IN]-(p) traverses only relevant edgesUse Specific Labels Over Generic Ones
A label like :Entity or :Node forces every query to filter by property. Specific labels (:Customer, :Product, :Order) let the database scan only relevant nodes. Labels are the primary access mechanism in property graphs.
Incorrect (generic label with type property):
// Queries must filter every node in the graph
CREATE (:Record {type: "Customer", name: "Alice", email: "alice@example.com"})
CREATE (:Record {type: "Product", name: "Widget", price: 29.99})
CREATE (:Record {type: "Order", orderId: "ORD-1001", total: 59.98})
// MATCH (n:Record {type: "Customer"}) scans ALL Record nodesCorrect (specific labels per domain concept):
// Queries target exactly the right subset of nodes
CREATE (:Customer {name: "Alice", email: "alice@example.com"})
CREATE (:Product {name: "Widget", price: 29.99})
CREATE (:Order {orderId: "ORD-1001", total: 59.98})
// MATCH (c:Customer) scans only Customer nodesUse Bipartite Structure for Many-to-Many with Context
Many domains have two primary entity classes with rich connections between them: students and courses, doctors and patients, users and products. The bipartite pattern keeps entity types separate and lets relationships carry context (grade, diagnosis, rating). Collapsing the two types into one node type loses semantic clarity.
Incorrect (single generic label with type property):
// A single :Person label for both doctors and patients loses type safety
// Generic relationship types obscure the domain model
CREATE (:Person {name: "Dr. Smith", type: "doctor", specialty: "Cardiology"})
CREATE (:Person {name: "Jane Doe", type: "patient", dob: date("1985-06-15")})
CREATE (d)-[:RELATED_TO {type: "treats", since: date("2023-06-01")}]->(p)
// Querying requires filtering by type property — error-prone and slow
MATCH (d:Person {type: "doctor"})-[:RELATED_TO {type: "treats"}]->(p:Person {type: "patient"})
RETURN d.name, p.name
// Nothing prevents a "patient" from having a "treats" relationship to a "doctor"Correct (distinct labels with typed relationships):
// Separate labels enforce domain constraints and enable clear queries
CREATE (dr:Doctor {name: "Dr. Smith"})
CREATE (pt:Patient {name: "Jane Doe", dob: date("1985-06-15")})
CREATE (cardio:Specialty {name: "Cardiology"})
CREATE (hypertension:Condition {name: "Hypertension"})
CREATE (dr)-[:TREATS {since: date("2023-06-01"), primaryCare: true}]->(pt)
CREATE (dr)-[:SPECIALIZES_IN]->(cardio)
CREATE (pt)-[:DIAGNOSED_WITH {diagnosedOn: date("2023-06-01"), severity: "Stage 2"}]->(hypertension)
// Bipartite queries are clean and type-safe
MATCH (d:Doctor)-[:SPECIALIZES_IN]->(:Specialty {name: "Cardiology"})
MATCH (d)-[:TREATS]->(p:Patient)-[:DIAGNOSED_WITH]->(c:Condition)
RETURN d.name, p.name, c.nameUse Fan-Out Pattern for Event Streams and Activity Feeds
Social feeds, notification systems, and audit logs produce high-volume event streams. The fan-out pattern connects each actor to their latest event, then chains events via :PREVIOUS relationships. This avoids scanning all events to build a user's timeline.
Incorrect (flat relationship to every event):
// Every event directly connected to the user — building a feed
// requires sorting ALL events by timestamp
CREATE (alice:User {name: "Alice"})
CREATE (alice)-[:PERFORMED]->(:Event {type: "post", content: "Hello!", ts: datetime("2024-03-15T10:00:00")})
CREATE (alice)-[:PERFORMED]->(:Event {type: "like", targetId: "post-42", ts: datetime("2024-03-15T10:05:00")})
CREATE (alice)-[:PERFORMED]->(:Event {type: "comment", content: "Great!", ts: datetime("2024-03-15T10:10:00")})
// ... thousands of events
// Getting last 10 events requires scanning and sorting all of Alice's events
MATCH (u:User {name: "Alice"})-[:PERFORMED]->(e)
RETURN e ORDER BY e.ts DESC LIMIT 10Correct (linked event chain with LATEST_EVENT pointer):
// Each user points to their most recent event; events chain backwards
CREATE (alice:User {name: "Alice"})
CREATE (e3:Event {type: "comment", content: "Great!", ts: datetime("2024-03-15T10:10:00")})
CREATE (e2:Event {type: "like", targetId: "post-42", ts: datetime("2024-03-15T10:05:00")})
CREATE (e1:Event {type: "post", content: "Hello!", ts: datetime("2024-03-15T10:00:00")})
CREATE (alice)-[:LATEST_EVENT]->(e3)
CREATE (e3)-[:PREVIOUS]->(e2)
CREATE (e2)-[:PREVIOUS]->(e1)
// Getting last N events follows the chain — no scanning or sorting
MATCH (u:User {name: "Alice"})-[:LATEST_EVENT]->(latest)-[:PREVIOUS*0..9]->(event)
RETURN event.type, event.tsModel Hierarchies with Category Nodes and Depth Relationships
Domain taxonomies (product categories, org structures, geographic regions) are natural trees. Model each level as a node with :PARENT_OF or :CHILD_OF relationships. Add a :Root label to the top node. For deep hierarchies, consider shortcut :ANCESTOR_OF relationships for O(1) ancestry queries.
Incorrect (hierarchy flattened into properties):
// Flat properties destroy the tree structure — can't traverse or aggregate
CREATE (:Product {
name: "iPhone 15 Pro",
category: "Electronics",
subcategory: "Phones",
subsubcategory: "Smartphones"
})
// Finding all products under "Electronics" requires scanning every product
// and checking string values — no tree traversal possible
MATCH (p:Product {category: "Electronics"})
RETURN p
// Misses products where someone typed "electronics" (case mismatch)Correct (hierarchy modeled as a tree of nodes):
// Each level is a node; relationships encode parent-child structure
CREATE (root:Category:Root {name: "Electronics"})
CREATE (phones:Category {name: "Phones"})
CREATE (smartphones:Category {name: "Smartphones"})
CREATE (accessories:Category {name: "Accessories"})
CREATE (root)-[:PARENT_OF]->(phones)
CREATE (phones)-[:PARENT_OF]->(smartphones)
CREATE (root)-[:PARENT_OF]->(accessories)
CREATE (:Product {name: "iPhone 15 Pro"})-[:IN_CATEGORY]->(smartphones)
// Drill-down: all products under Electronics at any depth
MATCH (:Category {name: "Electronics"})-[:PARENT_OF*]->(sub)<-[:IN_CATEGORY]-(p)
RETURN p.name, sub.name AS directCategory
// Roll-up: full ancestry path for a product
MATCH (p:Product {name: "iPhone 15 Pro"})-[:IN_CATEGORY]->(c)<-[:PARENT_OF*0..]-(ancestor)
RETURN [node IN collect(ancestor) | node.name] AS breadcrumbUse Intermediary Nodes for Multi-Entity Relationships
Property graphs only support binary relationships (node-to-node). When a real-world event involves 3+ participants (a person, a role, a company, a time period), you need an intermediary node. This is the most important structural pattern in graph modeling.
Incorrect (multi-entity data crammed onto a single relationship):
// Employment involves person + role + company + department + time period
// A single relationship can only connect two of these
CREATE (p:Person {name: "Alice"})
CREATE (c:Company {name: "Acme Corp"})
CREATE (p)-[:WORKS_AT {
role: "CTO",
department: "Engineering",
startDate: date("2023-01-01"),
salary: 180000
}]->(c)
// Can't connect this employment to a Department node or track role changes
// Can't model Alice being promoted from VP to CTO at the same companyCorrect (intermediary node connects all participants):
// The Employment node reifies the relationship into a first-class entity
CREATE (p:Person {name: "Alice"})
CREATE (c:Company {name: "Acme Corp"})
CREATE (d:Department {name: "Engineering"})
CREATE (emp:Employment {role: "CTO", startDate: date("2023-01-01"), salary: 180000})
CREATE (p)-[:HAS_ROLE]->(emp)
CREATE (emp)-[:AT]->(c)
CREATE (emp)-[:IN_DEPARTMENT]->(d)
// Role changes are just new Employment nodes
// Can query "who has worked in Engineering?" by traversing from Department
MATCH (d:Department {name: "Engineering"})<-[:IN_DEPARTMENT]-(emp)<-[:HAS_ROLE]-(p)
RETURN p.name, emp.role, emp.startDateUse Linked Lists for Ordered Sequences
When order matters (playlist tracks, workflow steps, version history), encoding order as an index property breaks on insertions and requires re-indexing. A linked list with :NEXT relationships preserves order naturally and supports O(1) insertions.
Incorrect (order encoded as index properties):
// Index properties must be renumbered on every insert or delete
CREATE (pl:Playlist {name: "Road Trip"})
CREATE (t1:Track {title: "Bohemian Rhapsody"})
CREATE (t2:Track {title: "Hotel California"})
CREATE (t3:Track {title: "Stairway to Heaven"})
CREATE (pl)-[:HAS_TRACK {position: 1}]->(t1)
CREATE (pl)-[:HAS_TRACK {position: 2}]->(t2)
CREATE (pl)-[:HAS_TRACK {position: 3}]->(t3)
// Inserting a track at position 2 requires updating positions 2, 3, ...
// In a 1000-track playlist, that's 999 relationship updatesCorrect (linked list with NEXT relationships):
// Order is encoded in the chain — insertions update only two relationships
CREATE (pl:Playlist {name: "Road Trip"})
CREATE (t1:Track {title: "Bohemian Rhapsody"})
CREATE (t2:Track {title: "Hotel California"})
CREATE (t3:Track {title: "Stairway to Heaven"})
CREATE (pl)-[:FIRST]->(t1)
CREATE (t1)-[:NEXT]->(t2)
CREATE (t2)-[:NEXT]->(t3)
CREATE (pl)-[:LAST]->(t3)
// Insert "Free Bird" between t1 and t2: delete t1-[:NEXT]->t2,
// create t1-[:NEXT]->newTrack-[:NEXT]->t2 — only 2 relationship changes
// Get ordered tracks:
MATCH (pl:Playlist {name: "Road Trip"})-[:FIRST]->(first)
MATCH path = (first)-[:NEXT*0..]->(track)
RETURN track.title, length(path) AS positionApply Timeline Trees for Temporal Data
When events must be queried by time range (logs, transactions, sensor readings), scanning all events is O(n). A timeline tree (Year -> Month -> Day -> Event) lets you jump directly to the right time slice. Use when you have thousands of time-series events.
Incorrect (flat events with timestamp properties):
// All events at the same level — finding events in a time range
// requires scanning every single event node
CREATE (:Order {orderId: "ord-1001", date: date("2024-03-15"), total: 89.99})
CREATE (:Order {orderId: "ord-1002", date: date("2024-03-16"), total: 45.50})
// ... thousands more orders
// Finding all orders in March 2024 scans every Order node
MATCH (o:Order)
WHERE o.date >= date("2024-03-01") AND o.date < date("2024-04-01")
RETURN oCorrect (timeline tree partitions events by time):
// Timeline tree lets you jump directly to the right time slice
CREATE (y:Year {value: 2024})
CREATE (m3:Month {value: 3})
CREATE (d15:Day {value: 15})
CREATE (d16:Day {value: 16})
CREATE (y)-[:HAS_MONTH]->(m3)
CREATE (m3)-[:HAS_DAY]->(d15)
CREATE (m3)-[:HAS_DAY]->(d16)
CREATE (d15)-[:HAS_EVENT]->(:Order {orderId: "ord-1001", total: 89.99})
CREATE (d16)-[:HAS_EVENT]->(:Order {orderId: "ord-1002", total: 45.50})
// All orders in March 2024 — jumps directly to the month node
MATCH (:Year {value: 2024})-[:HAS_MONTH]->(:Month {value: 3})-[:HAS_DAY]->(d)-[:HAS_EVENT]->(o:Order)
RETURN d.value AS day, o.orderId, o.total
ORDER BY d.valueUse Appropriate Data Types for Properties
Storing dates as strings ("2024-03-15") prevents range queries and sorting. Storing booleans as strings ("true") wastes 4x storage. Storing numbers as strings prevents arithmetic. Use native types: date(), datetime(), duration(), boolean, integer, float.
Incorrect (everything stored as strings):
// String properties prevent range queries, arithmetic, and proper sorting
CREATE (:Order {
orderId: "ord-1001",
createdAt: "2024-03-15T10:30:00",
isActive: "true",
price: "29.99",
quantity: "5"
})
// Inconsistent date formats break lexicographic sorting:
// "9/15/2024" > "10/1/2024" lexicographically (because "9" > "1"), but October is later
// Even with consistent formats, strings prevent date arithmetic:
// o.createdAt + duration("P30D") — impossible with a string
MATCH (o:Order)
WHERE o.createdAt > "2024-01-01"
RETURN oCorrect (native data types used):
// Native types enable range queries, arithmetic, and correct sorting
CREATE (:Order {
orderId: "ord-1001",
createdAt: datetime("2024-03-15T10:30:00"),
isActive: true,
price: 29.99,
quantity: 5
})
// Temporal comparison works correctly with datetime type
MATCH (o:Order)
WHERE o.createdAt > datetime("2024-01-01T00:00:00")
AND o.price * o.quantity > 100
RETURN oAvoid Property Arrays When You Need Relationships
Storing connections as arrays (e.g., skills: ["Python", "Go", "Rust"]) prevents you from querying "who else has Python?", adding metadata to each skill (proficiency level), or connecting skills to certifications. Arrays are opaque blobs; relationships are traversable.
Incorrect (connections stored as property arrays):
// Arrays are opaque — can't traverse, index, or enrich individual items
CREATE (:Person {
name: "Alice",
skills: ["Python", "Go", "Rust"],
interests: ["hiking", "chess"]
})
// Finding who shares a skill with Alice requires scanning ALL Person nodes
// and comparing array contents — no graph traversal possible
MATCH (a:Person {name: "Alice"}), (other:Person)
WHERE any(s IN a.skills WHERE s IN other.skills) AND other <> a
RETURN otherCorrect (connections modeled as relationships to nodes):
// Each skill is a node — traversable, queryable, enrichable with metadata
CREATE (alice:Person {name: "Alice"})
CREATE (python:Skill {name: "Python"})
CREATE (go:Skill {name: "Go"})
CREATE (rust:Skill {name: "Rust"})
CREATE (alice)-[:HAS_SKILL {level: "expert", since: date("2018-01-01")}]->(python)
CREATE (alice)-[:HAS_SKILL {level: "intermediate"}]->(go)
CREATE (alice)-[:HAS_SKILL {level: "beginner"}]->(rust)
// Finding shared skills is a natural traversal — no scanning
MATCH (:Person {name: "Alice"})-[:HAS_SKILL]->(s:Skill)<-[:HAS_SKILL]-(other)
RETURN other, collect(s.name) AS sharedSkillsAvoid Embedding Foreign Keys as Properties
In relational databases, foreign keys link tables. In graph databases, relationships ARE the links. Storing managerId: "123" as a property on an Employee node duplicates the graph's native capability and forces queries to do property lookups instead of traversals.
Incorrect (foreign keys stored as properties):
// Foreign keys force expensive property lookups to resolve connections
CREATE (:Employee {name: "Alice", managerId: "emp-42", departmentId: "dept-7"})
// Finding Alice's manager requires scanning all employees by ID
MATCH (e:Employee {name: "Alice"})
MATCH (mgr:Employee {employeeId: e.managerId})
RETURN mgrCorrect (relationships replace foreign keys):
// Relationships are the native connection mechanism in graphs
CREATE (alice:Employee {name: "Alice"})
CREATE (mgr:Employee {employeeId: "emp-42", name: "Bob"})
CREATE (dept:Department {deptId: "dept-7", name: "Engineering"})
CREATE (alice)-[:REPORTS_TO]->(mgr)
CREATE (alice)-[:BELONGS_TO]->(dept)
// Finding Alice's manager is a single traversal — no property scan
MATCH (:Employee {name: "Alice"})-[:REPORTS_TO]->(mgr)
RETURN mgrPromote Frequently-Queried Values to Nodes
If you frequently query "find all X with property value Y" (e.g., "all users in London", "all products with tag 'electronics'"), that value should be a node. Traversing from a :City node is instant; scanning every :User's city property is linear.
Incorrect (shared value repeated as property on every node):
// Every product duplicates the category string — finding all electronics
// requires scanning every Product node's category property
CREATE (:Product {name: "iPhone 15", category: "Electronics"})
CREATE (:Product {name: "Galaxy S24", category: "Electronics"})
CREATE (:Product {name: "MacBook Pro", category: "Electronics"})
// ... thousands more
// O(n) scan across all products
MATCH (p:Product {category: "Electronics"})
RETURN pCorrect (shared value promoted to its own node):
// One Category node, connected to all relevant products
CREATE (cat:Category {name: "Electronics"})
CREATE (p1:Product {name: "iPhone 15"})-[:IN_CATEGORY]->(cat)
CREATE (p2:Product {name: "Galaxy S24"})-[:IN_CATEGORY]->(cat)
CREATE (p3:Product {name: "MacBook Pro"})-[:IN_CATEGORY]->(cat)
// O(log n) index lookup + O(k) traversal
MATCH (c:Category {name: "Electronics"})<-[:IN_CATEGORY]-(p)
RETURN pKnow When Data Belongs on Relationship vs. Node
Data that describes the connection (when, how much, in what role) belongs on the relationship. Data that describes the entity itself (name, type, inherent attributes) belongs on the node. Data that needs its own connections belongs on an intermediary node. Decision heuristic: (1) Does this data describe the connection? Put it on the relationship. (2) Does this data describe the entity? Put it on the node. (3) Does this data need connections to other entities? Promote to an intermediary node.
Incorrect (course data collapsed onto the enrollment relationship):
// Course attributes don't describe the enrollment — they describe the course itself
// This makes it impossible to query course details independently
CREATE (s:Student {name: "Alice"})
CREATE (d:Department {name: "Computer Science"})
CREATE (s)-[:ENROLLED_IN {
courseName: "CS101",
courseCredits: 3,
grade: "A",
semester: "Fall 2024"
}]->(d)
// Can't answer "which courses offer 3 credits?" without scanning all relationshipsCorrect (enrollment data on relationship, course data on node):
// Connection-specific data (grade, semester) on the relationship
// Entity data (name, credits) on the Course node
CREATE (s:Student {name: "Alice"})
CREATE (c:Course {name: "CS101", credits: 3})
CREATE (d:Department {name: "Computer Science"})
CREATE (s)-[:ENROLLED_IN {grade: "A", semester: "Fall 2024"}]->(c)
CREATE (c)-[:OFFERED_BY]->(d)
// Course details are independently queryable
MATCH (c:Course {credits: 3})-[:OFFERED_BY]->(d)
RETURN c.name, d.nameDesign the Model for Your Most Critical Traversals First
The #1 principle of graph modeling: know your queries before you design your schema. List the top 5-10 queries your application will run, then design the model so the most frequent queries traverse the fewest hops. A model that looks clean on a whiteboard but requires 6-hop traversals for common operations is a bad model.
Incorrect (entity-relationship design without considering queries):
// Designed from an ER diagram — "recommend products" requires 6 hops:
// Customer -> Order -> OrderLine -> Product -> Category -> Product
MATCH (c:Customer {id: "c1"})-[:PLACED]->(o:Order)
-[:CONTAINS]->(ol:OrderLine)-[:FOR_PRODUCT]->(p:Product)
-[:IN_CATEGORY]->(cat:Category)<-[:IN_CATEGORY]-(rec:Product)
WHERE rec <> p
RETURN DISTINCT rec.name
// Every recommendation query crawls through orders, line items, and categoriesCorrect (model designed around the critical "recommend products" query):
// After identifying "product recommendations" as a critical query,
// add direct relationships that reduce hops:
CREATE (c:Customer {id: "c1"})-[:VIEWED]->(p1:Product {name: "Running Shoes"})
CREATE (c)-[:PURCHASED]->(p2:Product {name: "Trail Shoes"})
CREATE (p1)-[:SIMILAR_TO]->(p3:Product {name: "Hiking Boots"})
CREATE (p2)-[:SIMILAR_TO]->(p3)
// Recommendation query is now 2 hops:
MATCH (c:Customer {id: "c1"})-[:VIEWED|PURCHASED]->(p:Product)
-[:SIMILAR_TO]->(rec:Product)
WHERE NOT (c)-[:PURCHASED]->(rec)
RETURN DISTINCT rec.nameDenormalize for Read-Heavy Paths
In read-heavy workloads (dashboards, feeds, search results), copying a few key properties onto nodes that are always co-fetched avoids extra hops. For example, storing authorName on a :Post node avoids traversing to the :Author node on every timeline render. This is a conscious trade-off: don't denormalize if the source data changes frequently or if the read path is not a proven bottleneck.
Incorrect (N+1 traversal pattern for every timeline render):
// Rendering a feed of 50 posts requires 50 extra traversals to get author names:
MATCH (post:Post)-[:AUTHORED_BY]->(author:Author)
WHERE post.publishedAt > datetime() - duration("P7D")
RETURN post.title, post.body, author.name, author.avatarUrl
ORDER BY post.publishedAt DESC
LIMIT 50
// Each post triggers a hop to the Author node — multiplied across millions of feed rendersCorrect (denormalized display fields on the Post node):
// Store frequently co-fetched display fields directly on the Post node:
CREATE (p:Post {
title: "GraphQL at Scale",
body: "...",
publishedAt: datetime("2024-11-15T10:00:00Z"),
authorName: "Alice Chen", // denormalized for display
authorAvatar: "/img/alice.jpg" // denormalized for display
})-[:AUTHORED_BY]->(a:Author {name: "Alice Chen", avatarUrl: "/img/alice.jpg"})
// Feed query needs zero extra hops:
MATCH (post:Post)
WHERE post.publishedAt > datetime() - duration("P7D")
RETURN post.title, post.body, post.authorName, post.authorAvatar
ORDER BY post.publishedAt DESC
LIMIT 50
// Canonical AUTHORED_BY relationship remains for mutations and author-centric queries
// Update denormalized fields when Author properties changeUse Relationship Properties to Filter Traversals
When relationship properties carry filtering criteria (date ranges, roles, weights), the database can skip entire branches without loading the target node. This is especially effective for time-scoped queries: "find Alice's current employer" filters on the WORKS_AT relationship's endDate property, avoiding loading every company Alice has ever worked at.
Incorrect (loads all target nodes then filters by node property):
// Find Alice's current employer — loads every Company node first, then filters:
MATCH (a:Person {name: "Alice"})-[:WORKS_AT]->(c:Company)
WHERE c.isCurrent = true
RETURN c.name
// Problem: "isCurrent" is on the Company node, but a Company isn't inherently "current" —
// Alice's employment status is about her relationship to the company.
// Also loads all historical employers before filtering.Correct (filters on relationship properties to prune early):
// Filter on the relationship — the database skips historical employments immediately:
MATCH (a:Person {name: "Alice"})-[w:WORKS_AT]->(c:Company)
WHERE w.endDate IS NULL
RETURN c.name, w.startDate, w.role
// Only current employment relationships (endDate IS NULL) are traversed.
// Historical relationships are pruned at the relationship level,
// their target Company nodes are never loaded.Add Shortcut Relationships for Frequent Multi-Hop Queries
When a query frequently traverses the same multi-hop path (e.g., "all colleagues who worked at the same company in the same year"), adding a precomputed shortcut relationship trades storage for query speed. The shortcut is redundant data — maintain it via application logic or triggers. Document shortcuts as derived data so future developers know the relationship is computed, not source-of-truth.
Incorrect (every "find colleagues" query traverses the full path):
// Finding colleagues requires traversing through Employment nodes every time:
// Person -> Employment -> Company <- Employment <- Person
MATCH (a:Person {name: "Alice"})-[:HAD_ROLE]->(e1:Employment)
-[:AT_COMPANY]->(c:Company)<-[:AT_COMPANY]-(e2:Employment)
<-[:HAD_ROLE]-(colleague:Person)
WHERE e1.startYear <= e2.endYear AND e2.startYear <= e1.endYear
AND colleague <> a
RETURN colleague.name, c.name AS company
// Expensive at social-network scale — runs on every page load of "People you may know"Correct (precomputed shortcut with canonical path preserved):
// Precompute the shortcut relationship via a batch job:
MATCH (a:Person)-[:HAD_ROLE]->(e1:Employment)
-[:AT_COMPANY]->(c:Company)<-[:AT_COMPANY]-(e2:Employment)
<-[:HAD_ROLE]-(b:Person)
WHERE e1.startYear <= e2.endYear AND e2.startYear <= e1.endYear
AND a <> b
MERGE (a)-[:COLLEAGUE_OF {company: c.name, period: e1.startYear + "-" + e2.endYear, _derived: true}]->(b)
// Hot query is now 1 hop:
MATCH (a:Person {name: "Alice"})-[r:COLLEAGUE_OF]->(colleague:Person)
RETURN colleague.name, r.company, r.period
// The canonical Employment path remains for correctness and auditTest Your Model Against Real Queries Before Deploying
Create a small test dataset (50-100 nodes) and run your top 10 queries against it. If a query requires awkward workarounds, the model is wrong — fix it now. Use PROFILE to check execution plans and verify traversal patterns match expectations. A model that can't answer your questions with clean Cypher needs redesign before it reaches production.
Incorrect (deploy a whiteboard design, discover problems in production):
// Schema designed on a whiteboard, deployed directly.
// In production, "find mutual friends" requires 4 collections and UNWIND:
MATCH (a:Person {name: "Alice"})-[:KNOWS]->(f:Person)
WITH collect(f) AS aliceFriends
MATCH (b:Person {name: "Bob"})-[:KNOWS]->(f:Person)
WITH aliceFriends, collect(f) AS bobFriends
UNWIND aliceFriends AS af
WITH af, bobFriends
WHERE af IN bobFriends
RETURN af.name
// Awkward — the model forces collecting and intersecting instead of traversing.
// This could have been caught with a 10-node test dataset.Correct (test early with sample data and PROFILE):
// Step 1: Create a small test dataset
CREATE (alice:Person {name: "Alice"})
CREATE (bob:Person {name: "Bob"})
CREATE (carol:Person {name: "Carol"})
CREATE (dave:Person {name: "Dave"})
CREATE (alice)-[:FRIEND_OF]->(carol)
CREATE (bob)-[:FRIEND_OF]->(carol)
CREATE (alice)-[:FRIEND_OF]->(dave)
CREATE (bob)-[:FRIEND_OF]->(dave)
// Step 2: Run the query — clean 2-hop traversal confirms the model works
PROFILE
MATCH (a:Person {name: "Alice"})-[:FRIEND_OF]->(mutual)<-[:FRIEND_OF]-(b:Person {name: "Bob"})
RETURN mutual.name
// PROFILE output shows: NodeByLabelScan -> Expand -> Expand -> Filter
// Clean traversal, no UNWIND hacks, no collection gymnastics.
// If the PROFILE showed CartesianProduct or EagerAggregation, refactor the model.Choose Semantically Meaningful Relationship Direction
Relationship direction should reflect the domain's natural flow: who acts on whom, what contains what, what depends on what. While Cypher can traverse in either direction, a consistently-directed graph is readable as English: "Alice MANAGES Bob", not "Bob MANAGES Alice" (unless Bob manages Alice).
Incorrect (inconsistent or inverted direction):
// Mixed direction conventions across the same graph — confusing and error-prone
CREATE (:Employee {name: "Alice"})-[:WORKS_FOR]->(:Company {name: "Acme"})
CREATE (:Company {name: "Globex"})-[:EMPLOYS]->(:Employee {name: "Bob"})
// Some edges flow employee->company, others company->employee
// Query authors must guess the direction for each relationship typeCorrect (consistent direction following natural domain flow):
// Always flow from actor to target, from specific to general
CREATE (alice:Employee {name: "Alice"})-[:WORKS_AT]->(acme:Company {name: "Acme"})
CREATE (bob:Employee {name: "Bob"})-[:WORKS_AT]->(globex:Company {name: "Globex"})
CREATE (alice)-[:MANAGES]->(bob)
CREATE (alice)-[:AUTHORED]->(article:Article {title: "Graph Modeling Guide"})
// Traverse either direction in queries when needed:
// MATCH (c:Company)<-[:WORKS_AT]-(e) RETURN c.name, collect(e.name)Follow UPPER_SNAKE_CASE for Relationship Types
Neo4j and the Cypher ecosystem use UPPER_SNAKE_CASE for relationship types by convention. This distinguishes them visually from labels (CamelCase) and properties (camelCase). Inconsistent naming causes query bugs and confuses collaborators.
Incorrect (mixed naming styles):
// Each developer picks a different convention — chaos
CREATE (:Doctor)-[:treatedPatient]->(:Patient) // camelCase
CREATE (:Doctor)-[:TreatedPatient]->(:Patient) // PascalCase
CREATE (:Doctor)-[:treated_patient]->(:Patient) // lower_snake_case
CREATE (:Doctor)-[:TREATED-PATIENT]->(:Patient) // UPPER-KEBAB-CASE
// Queries fail silently when the wrong case is used:
// MATCH ()-[:TREATED_PATIENT]->() returns nothing if the actual type is "treatedPatient"Correct (consistent UPPER_SNAKE_CASE with active verb forms):
// Clear naming convention: UPPER_SNAKE_CASE with active verbs
CREATE (dr:Doctor {name: "Dr. Smith"})-[:TREATED]->(patient:Patient {name: "Alice"})
CREATE (dr)-[:PRESCRIBED]->(rx:Medication {name: "Amoxicillin"})
CREATE (patient)-[:ADMITTED_TO]->(ward:Ward {name: "Cardiology"})
CREATE (patient)-[:HAS_INSURANCE]->(ins:InsurancePlan {provider: "BlueCross"})
// Labels: CamelCase — Doctor, Patient, Medication
// Relationships: UPPER_SNAKE_CASE — TREATED, PRESCRIBED, ADMITTED_TO
// Properties: camelCase — name, providerAvoid Redundant Reverse Relationships
Cypher traverses relationships in any direction. Adding both (:A)-[:FOLLOWS]->(:B) AND (:B)-[:FOLLOWED_BY]->(:A) doubles storage and creates an update consistency problem. Only create reverse relationships when they carry genuinely different semantics.
Incorrect (redundant reverse relationship):
// Double storage, must keep both in sync on every write
CREATE (alice:User {name: "Alice"})-[:FOLLOWS]->(bob:User {name: "Bob"})
CREATE (bob)-[:FOLLOWED_BY]->(alice)
// Deleting the follow requires removing BOTH relationships
// If one is missed, the graph becomes inconsistentCorrect (single directional relationship, traverse either way):
// Single relationship, query in either direction
CREATE (alice:User {name: "Alice"})-[:FOLLOWS]->(bob:User {name: "Bob"})
// Who does Alice follow?
// MATCH (alice:User {name: "Alice"})-[:FOLLOWS]->(following) RETURN following
// Who follows Bob?
// MATCH (bob:User {name: "Bob"})<-[:FOLLOWS]-(follower) RETURN followerException: When the reverse relationship carries different properties or semantics, such as a logistics graph where :SHIPS_TO and :RECEIVES_FROM track different metadata (shipping cost vs. receiving dock).
Put Data on Relationships Only When It Describes the Connection
Relationship properties should describe the connection itself (when it started, its strength, its role), not the entities it connects. Properties like since, weight, role belong on relationships. Properties like name, email, age belong on nodes. If you need to connect the relationship to other entities, it should be a node.
Incorrect (entity data placed on the relationship):
// Company data lives on the relationship — duplicated across every employee edge
CREATE (:Person {name: "Alice"})-[:WORKS_AT {
companyAddress: "123 Main St",
companyPhone: "555-0100",
companyIndustry: "Technology",
role: "Engineer",
since: "2023-01-15"
}]->(:Company {name: "Acme Corp"})
// If the company moves offices, every WORKS_AT relationship must be updatedCorrect (data placed where it belongs):
// Connection data on the relationship, entity data on the nodes
CREATE (:Person {name: "Alice"})-[:WORKS_AT {
role: "Engineer",
since: date("2023-01-15"),
department: "Platform"
}]->(:Company {
name: "Acme Corp",
address: "123 Main St",
phone: "555-0100",
industry: "Technology"
})
// Company address updated in one place, relationship describes only the connectionOne Relationship Type per Semantic Meaning
Using the same relationship type for different meanings (e.g., :HAS for both "Company HAS Employee" and "Order HAS LineItem") makes queries return wrong results. Each relationship type should have one clear meaning across the entire graph.
Incorrect (overloaded relationship type):
// "HAS" means three completely different things
CREATE (:Company {name: "Acme"})-[:HAS]->(:Employee {name: "Alice"})
CREATE (:Order {id: "ORD-1001"})-[:HAS]->(:LineItem {product: "Widget", qty: 2})
CREATE (:Person {name: "Alice"})-[:HAS]->(:Skill {name: "Python"})
// MATCH (n)-[:HAS]->(target) returns employees, line items, AND skills
// Queries become unpredictable and require label filtering to disambiguateCorrect (one type per semantic meaning):
// Each relationship type has one unambiguous meaning across the graph
CREATE (:Company {name: "Acme"})-[:EMPLOYS]->(:Employee {name: "Alice"})
CREATE (:Order {id: "ORD-1001"})-[:CONTAINS]->(:LineItem {product: "Widget", qty: 2})
CREATE (:Person {name: "Alice"})-[:HAS_SKILL]->(:Skill {name: "Python"})
// MATCH (o:Order)-[:CONTAINS]->(li) returns only line items, never employees or skillsUse Specific Relationship Types Over Generic Ones
A relationship type like :RELATED_TO or :CONNECTS forces every query to check a property to understand the relationship's meaning. Specific types (:MANAGES, :REPORTS_TO, :AUTHORED) let the database follow only relevant edges and make queries self-documenting.
Incorrect (generic relationship with type property):
// Must filter by property on every traversal
CREATE (alice:Person {name: "Alice"})-[:RELATED_TO {type: "manages"}]->(bob:Person {name: "Bob"})
CREATE (alice)-[:RELATED_TO {type: "mentors"}]->(charlie:Person {name: "Charlie"})
// Finding who Alice manages requires filtering:
// MATCH (alice)-[r:RELATED_TO {type: "manages"}]->(report) — scans all RELATED_TO edgesCorrect (specific relationship type per semantic meaning):
// Traversal only follows management edges
CREATE (alice:Person {name: "Alice"})-[:MANAGES]->(bob:Person {name: "Bob"})
CREATE (alice)-[:MENTORS]->(charlie:Person {name: "Charlie"})
// MATCH (alice)-[:MANAGES]->(report) — skips all non-MANAGES edges entirelyPrefer Typed Relationships Over Generic + Property Filter
This rule is about performance. Creating a generic relationship with a type property forces every query to filter: WHERE r.type = "friend". The database must scan every edge of that type and check the property -- it cannot use the relationship type index to skip irrelevant edges. Separate relationship types let the engine skip entire edge sets. The cost is more relationship types in your schema, but the benefit is faster queries.
Incorrect (generic relationship with type property):
// Must filter on every query — scans all KNOWS edges
CREATE (alice:Person {name: "Alice"})-[:KNOWS {type: "friend", since: "2020-01-01"}]->(bob:Person {name: "Bob"})
CREATE (alice)-[:KNOWS {type: "colleague", since: "2022-06-15"}]->(charlie:Person {name: "Charlie"})
CREATE (alice)-[:KNOWS {type: "neighbor", since: "2023-03-01"}]->(dana:Person {name: "Dana"})
// Finding Alice's friends:
// MATCH (alice)-[r:KNOWS {type: "friend"}]->(friend) — filters all KNOWS edgesCorrect (distinct relationship types):
// Each relationship type is traversed independently — no filtering needed
CREATE (alice:Person {name: "Alice"})-[:FRIEND_OF {since: date("2020-01-01")}]->(bob:Person {name: "Bob"})
CREATE (alice)-[:COLLEAGUE_OF {since: date("2022-06-15")}]->(charlie:Person {name: "Charlie"})
CREATE (alice)-[:NEIGHBOR_OF {since: date("2023-03-01")}]->(dana:Person {name: "Dana"})
// MATCH (alice)-[:FRIEND_OF]->(friend) — only traverses friend edges, skips all othersSee also: `rel-specific-types` for the semantic naming argument. `anti-generic-relationships` for avoiding truly generic types like RELATED_TO.
Use APOC or Batched Queries for Schema Refactoring
Running a single Cypher query to refactor millions of nodes (promoting a property to a node, splitting a label, restructuring relationships) will attempt to hold the entire change in one transaction, exhausting heap memory. Use APOC's apoc.periodic.iterate or Neo4j 5+'s CALL {} IN TRANSACTIONS to process changes in controlled batches. This is especially critical when creating new nodes and relationships from existing data.
Incorrect (single transaction — out of memory on large datasets):
// Promoting city property to a City node for 5M Person records
// This creates 5M City nodes + 5M relationships in ONE transaction
MATCH (p:Person)
WHERE p.city IS NOT NULL
CREATE (c:City {name: p.city})
CREATE (p)-[:LIVES_IN]->(c)
REMOVE p.city
// Transaction log grows to gigabytes, heap exhausted, query killed
// Partial work is rolled back — nothing is saved
// Same problem with relationship restructuring
MATCH (p:Person)-[r:WORKS_AT]->(c:Company)
CREATE (e:Employment {since: r.since, until: r.until, role: r.role})
CREATE (p)-[:HAS_EMPLOYMENT]->(e)
CREATE (e)-[:AT_COMPANY]->(c)
DELETE r
// 2M employees x 3 new entities each = 6M creates in one transactionCorrect (batched processing — controlled memory usage):
// Using APOC periodic iterate — processes 1000 nodes per batch
CALL apoc.periodic.iterate(
"MATCH (p:Person) WHERE p.city IS NOT NULL AND NOT (p)-[:LIVES_IN]->() RETURN p",
"MERGE (c:City {name: p.city})
CREATE (p)-[:LIVES_IN]->(c)
REMOVE p.city",
{batchSize: 1000, parallel: false}
)
// Processes 1000 Person nodes at a time, commits each batch
// If interrupted, completed batches are saved
// Neo4j 5+ native batching — no APOC required
MATCH (p:Person) WHERE p.city IS NOT NULL AND NOT (p)-[:LIVES_IN]->()
CALL (p) {
MERGE (c:City {name: p.city})
CREATE (p)-[:LIVES_IN]->(c)
REMOVE p.city
} IN TRANSACTIONS OF 1000 ROWS
// Commits every 1000 rows — predictable memory usage
// Reports progress: "Added X nodes, created Y relationships"Monitor and Detect Emerging Supernodes
Supernodes don't always start dense — they grow over time as data accumulates. A :Tag node for "javascript" might be fine with 1K articles but become a supernode at 1M. A :Warehouse node in a logistics system handles 100 daily shipments initially but 100K during peak season. Build monitoring into your data pipeline to detect high-degree nodes before they cause query timeouts and memory pressure in production.
Incorrect (no monitoring — supernodes discovered only when queries fail):
// No degree monitoring in place
// Tag "javascript" silently grows to 2M relationships over 18 months
CREATE (:Article {title: "Async/Await Guide"})-[:TAGGED]->(:Tag {name: "javascript"})
// ... repeated 2,000,000 times
// First sign of trouble: production query times out after 30 seconds
MATCH (t:Tag {name: "javascript"})<-[:TAGGED]-(a:Article)
WHERE a.publishedAt > date("2024-01-01")
RETURN a.title ORDER BY a.publishedAt DESC LIMIT 20
// Loads 2M relationships into memory just to filter and return 20
// Team discovers the problem during an incident — reactive, not proactive
// Fix requires emergency schema refactoring under pressureCorrect (periodic monitoring detects emerging supernodes early):
// Monitoring query — run daily or weekly via scheduled job
// Detect any node with degree above warning threshold
MATCH (n)
WITH labels(n) AS nodeLabels, n, size((n)--()) AS degree
WHERE degree > 10000
RETURN nodeLabels, n.name, degree
ORDER BY degree DESC
LIMIT 20
// Breakdown by relationship type — identifies which edge type is growing
MATCH (n)
WHERE size((n)--()) > 10000
WITH n, labels(n) AS nodeLabels
UNWIND nodeLabels AS label
CALL {
WITH n
MATCH (n)-[r]-()
RETURN type(r) AS relType, count(r) AS relCount
}
RETURN labels(n), n.name, relType, relCount
ORDER BY relCount DESC
// Threshold-based alerting:
// - WARNING at 10,000 relationships (review needed)
// - CRITICAL at 100,000 relationships (partition immediately)
// Prevention: during schema design, ask for every node type:
// "Can this node accumulate unbounded relationships?"
// If yes, plan the partitioning strategy BEFORE data grows
// e.g., Tag nodes -> partition by year: (:TagYear {tag: "javascript", year: 2024})Plan for Label and Relationship Type Evolution
Unlike relational databases with formal ALTER TABLE migrations, graph databases don't have built-in schema migration tools. Relationship types and labels are immutable identifiers — you cannot rename :WORKS_AT to :EMPLOYED_BY in-place. Plan for evolution: add new labels and relationships alongside old ones, migrate data in batches, update application code to read from both, then remove the old structure. Never do a big-bang rename.
Incorrect (big-bang rename — breaks queries during rollout):
// Attempt to rename WORKS_AT to EMPLOYED_BY in a single deployment
// Step 1: Application code changes all queries from WORKS_AT to EMPLOYED_BY
// Step 2: Deploy application — but data still uses WORKS_AT
// Result: All queries return empty results until data is migrated
// During migration window, some servers use old code, some use new
// Old servers: MATCH (p)-[:WORKS_AT]->(c) — works until data is migrated
// New servers: MATCH (p)-[:EMPLOYED_BY]->(c) — returns nothing yet
// Queries are broken for the duration of the migrationCorrect (gradual migration — zero downtime):
// Phase 1: Create new relationships alongside old ones
MATCH (p:Employee)-[r:WORKS_AT]->(c:Company)
CREATE (p)-[:EMPLOYED_BY {since: r.since, role: r.role}]->(c)
// Both WORKS_AT and EMPLOYED_BY now exist
// Phase 2: Update application to read from BOTH, write to NEW only
// Queries during transition:
MATCH (p:Employee)-[:WORKS_AT|EMPLOYED_BY]->(c:Company)
RETURN p.name, c.name
// Works regardless of which relationship exists
// Phase 3: Verify data consistency
MATCH (p:Employee)-[old:WORKS_AT]->(c)
WHERE NOT (p)-[:EMPLOYED_BY]->(c)
RETURN count(p) // should be 0 — all migrated
// Phase 4: Remove old relationships (after application no longer reads them)
MATCH ()-[r:WORKS_AT]->()
DELETE r
// Phase 5: Remove WORKS_AT from application query paths
// Now only EMPLOYED_BY exists in both data and codeMitigate Supernodes with Fan-Out or Partitioning
A supernode is a node with thousands to millions of relationships — a popular celebrity's followers, a global :Country node connected to every citizen, a :Tag like "javascript" linked to millions of articles. Traversing all relationships of a supernode is slow and memory-intensive, and it blocks concurrent queries. Detect supernodes early and mitigate with partitioning or intermediate fan-out nodes.
Incorrect (all relationships on a single node — supernode bottleneck):
// 100M FOLLOWS relationships on one Celebrity node
// Any query touching Taylor's node loads millions of edges into memory
(:Celebrity {name: "Taylor Swift"})<-[:FOLLOWS]-(:User)
// Even counting followers is expensive
MATCH (:Celebrity {name: "Taylor Swift"})<-[:FOLLOWS]-(f)
RETURN count(f) // scans 100M relationships
// Worse: a query for mutual followers between two supernodes
MATCH (a:Celebrity {name: "Taylor Swift"})<-[:FOLLOWS]-(u)-[:FOLLOWS]->(:Celebrity {name: "Beyonce"})
RETURN u.name // cartesian explosion of two supernodesCorrect (fan-out partitioning distributes relationships across intermediate nodes):
// Strategy 1: Partition by attribute (region)
// Followers are grouped into segments — queries target specific segments
CREATE (:Celebrity {name: "Taylor Swift"})-[:FAN_SEGMENT]->(:FanSegment {region: "US-West"})
CREATE (:Celebrity {name: "Taylor Swift"})-[:FAN_SEGMENT]->(:FanSegment {region: "US-East"})
CREATE (:Celebrity {name: "Taylor Swift"})-[:FAN_SEGMENT]->(:FanSegment {region: "EU"})
// Each segment has manageable relationship counts
MATCH (:FanSegment {region: "US-West"})<-[:MEMBER_OF]-(u:User)
RETURN count(u) // only scans one segment
// Strategy 2: Time-partitioned relationship types
// Instead of generic :FOLLOWS, partition by time period
CREATE (u:User)-[:FOLLOWS_2024_Q1]->(c:Celebrity {name: "Taylor Swift"})
// Query only recent followers
MATCH (u)-[:FOLLOWS_2024_Q4]->(c:Celebrity {name: "Taylor Swift"})
RETURN u.name // traverses only Q4 edges
// Detection query — run periodically to find emerging supernodes
MATCH (n)
WITH labels(n) AS label, n, size((n)--()) AS degree
WHERE degree > 10000
RETURN label, n.name, degree ORDER BY degree DESC LIMIT 10Separate Current State from Historical State
As entities change over time (address changes, role changes, status transitions), you need both "what is X's current state?" (fast, frequent) and "what was X's state on date Y?" (slower, occasional). Mixing current and historical data on the same node makes current-state queries traverse historical noise, and storing history as array properties makes temporal queries nearly impossible.
Incorrect (history stored as arrays on the same node):
// All address history crammed into one node as array properties
CREATE (:Patient {
name: "Alice",
currentAddress: "456 Oak Ave",
previousAddresses: ["123 Main St", "789 Elm St"],
currentDoctor: "Dr. Smith",
previousDoctors: ["Dr. Jones", "Dr. Lee"]
})
// Can't answer: "Who was Alice's doctor on 2022-06-15?"
// No temporal information attached to previous values
// Can't answer: "Which patients lived at 123 Main St in 2022?"
// Would need to scan every Patient's previousAddresses array
MATCH (p:Patient)
WHERE "123 Main St" IN p.previousAddresses
RETURN p.name // no date filtering possible — when did they live there?Correct (state separated into versioned nodes with temporal relationships):
// Current state is a direct, typed relationship — fast to query
CREATE (alice:Patient {name: "Alice", patientId: "P-1001"})
CREATE (alice)-[:CURRENT_ADDRESS]->(:Address {street: "456 Oak Ave", city: "Portland"})
CREATE (alice)-[:CURRENT_DOCTOR]->(:Doctor {name: "Dr. Smith"})
// Historical states linked with temporal metadata
CREATE (alice)-[:PREVIOUS_ADDRESS {from: date("2020-03-01"), until: date("2023-06-15")}]->
(:Address {street: "123 Main St", city: "Seattle"})
CREATE (alice)-[:PREVIOUS_DOCTOR {from: date("2019-01-01"), until: date("2022-12-31")}]->
(:Doctor {name: "Dr. Jones"})
// Current-state query — fast, no historical noise
MATCH (p:Patient {patientId: "P-1001"})-[:CURRENT_ADDRESS]->(a)
RETURN a.street, a.city
// Time-travel query — who was Alice's doctor on 2022-06-15?
MATCH (p:Patient {patientId: "P-1001"})-[r:PREVIOUS_DOCTOR]->(d)
WHERE r.from <= date("2022-06-15") AND r.until >= date("2022-06-15")
RETURN d.name // "Dr. Jones"
// Which patients lived at 123 Main St in 2022?
MATCH (p:Patient)-[r:PREVIOUS_ADDRESS|CURRENT_ADDRESS]->(a:Address {street: "123 Main St"})
WHERE r.from <= date("2022-12-31") AND coalesce(r.until, date("9999-12-31")) >= date("2022-01-01")
RETURN p.nameSee also: `entity-identity-state` for the foundational principle of separating identity from mutable state.
Related skills
FAQ
What does graph-schema do?
graph-schema: A skill for development. This provides functionality for development workflows.
When should I use graph-schema?
When you need to use graph-schema for development tasks, or when graph-schema: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
graph-schema.