Neo4j Modeling: When a Relationship Property Should Become a Node
Relationship properties in Neo4j are compact until the fact needs its own relationships. Here's the heuristic for when to reify an edge into a node, with Cypher for both models and the planner implications.
15 Jun 2026, 06:30 UTC

You have a :PURCHASED relationship between Customer and Product with a date and an amount on it. It works — until someone asks "which orders were disputed?" or "show me the shipment for this purchase." A relationship property can't have its own relationships, and that's the moment the lightweight model starts costing you.
The thesis: keep facts as relationship properties while they're pure annotations of a traversal, but promote them to intermediate nodes as soon as the fact has its own identity, lifecycle, or more than a couple of attributes. The rest of this post shows what that looks like in Cypher and what each choice costs.
What relationship properties are good at
Neo4j relationships can carry properties, and for simple cases this is the right call. A weight on :CONNECTED_TO, a timestamp on :FOLLOWS, a role on :WORKS_WITH — these are read alongside the traversal and never referenced independently.
// Run in Neo4j Browser or via a driver; requires write access to the database
CREATE (c:Customer {id: 'c-1001', name: 'Ada'})
CREATE (p:Product {id: 'p-42', name: 'Keyboard'})
CREATE (c)-[:PURCHASED {date: date('2026-09-30'), amount: 129.99}]->(p);
Queries stay compact, and the graph stays small. The catch is structural: a relationship property can't be the endpoint of another relationship, and node indexes don't cover relationship properties the way they cover node properties. So a purchase can never be "the thing that was shipped" — it's just decoration on an edge.
The intermediate node refactor
The standard fix is to reify the fact: turn the edge into a node with its own label.
// Before
(c:Customer)-[:PURCHASED {date, amount}]->(p:Product)
// After
(c:Customer)-[:PLACED]->(o:Order {id, date, amount})-[:CONTAINS]->(p:Product)
Now Order can carry a uniqueness constraint and a range index on date, and it can participate in new relationships: (o)-[:DISPUTED_BY]->(:Dispute), (o)-[:FULFILLED_BY]->(:Shipment), (:Review)-[:ABOUT]->(o). None of that is expressible when the purchase is a property bag on an edge.
A migration for existing data looks like this — run it on a copy of production-like data first, in batches if the volume is large:
// Requires write privileges. Test on a staging copy; this rewrites structure.
MATCH (c:Customer)-[p:PURCHASED]->(prod:Product)
CREATE (c)-[:PLACED]->(o:Order {date: p.date, amount: p.amount})-[:CONTAINS]->(prod)
DELETE p;
Generate a real id per order (e.g., randomUUID() or an application-assigned key) rather than relying on the internal node id, and add a uniqueness constraint afterward. The risk: on a large graph this is a long write transaction, so batch it with IN TRANSACTIONS OF n ROWS (Neo4j 4.4+/5.x) and confirm the syntax against your deployed version.
Why the query planner cares
Take the question: "recommend products bought by customers who purchased after a date." With the property model, the planner must expand each customer's :PURCHASED relationships and filter on p.date:
MATCH (c:Customer)-[p:PURCHASED]->(prod:Product)
WHERE p.date > date('2026-09-01')
RETURN prod.name, count(*) AS recentBuys
ORDER BY recentBuys DESC LIMIT 10;
That's fine at low degree. It's painful if some customers are supernodes — nodes with tens of thousands of relationships — because every expansion is scanned and filtered. With the Order model and a range index on Order.date, the planner can start from recent orders directly and walk outward, turning a filter-after-expand into an index seek:
CREATE RANGE INDEX order_date IF NOT EXISTS FOR (o:Order) ON (o.date);
MATCH (o:Order)-[:CONTAINS]->(prod:Product)
WHERE o.date > date('2026-09-01')
RETURN prod.name, count(*) AS recentBuys
ORDER BY recentBuys DESC LIMIT 10;
Don't take this on faith: build a small sample graph with both shapes and prefix each query with PROFILE in Neo4j Browser. Compare db hits and rows. Also check your degree distribution before committing to either model:
// Read-only; safe to run anywhere
MATCH (n)-[r]-()
RETURN labels(n) AS labels, count(r) AS degree
ORDER BY degree DESC LIMIT 10;
If your top nodes have modest degree, relationship-property filtering is rarely your bottleneck.
The trade-off
Intermediate nodes aren't free. Every purchase becomes two relationships and a node instead of one relationship — more storage, more write amplification, and one extra hop in every traversal. If the attribute is only ever read along the edge (a weight, a timestamp you never filter on), promoting it is over-normalization and you'll pay the cost with no benefit.
The heuristic I'd actually use: a fact should be a node if it has its own identity (you'd ever link to it directly), its own lifecycle (states like placed → shipped → disputed), or more than two or three attributes. Otherwise leave it on the edge. The decision is reversible, but migration cost grows with data volume, so it's worth spending ten minutes on the lifecycle question at design time.
One caveat: index types and planner behavior for relationships versus nodes differ across Neo4j 4.x and 5.x. Confirm what your deployed version supports in the Cypher manual before designing around a specific index, and validate any migration script against production-like data before running it for real.
Actionable close
Pick one relationship type in your current model that carries properties. Ask: does anything ever need to point at this fact? If yes, plan the refactor now while it's cheap. If no, run the degree query above, and if your degrees are low, keep the lightweight model without guilt.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.