Graph Data Modeling: When Relationships Are the Data
Some questions are easy to ask and brutally hard to answer in SQL. “Which of my customers know each other?” “Is this new account connected — through any chain of shared devices, addresses, or payment methods — to a known fraudster?” “What’s the shortest chain of introductions from me to this executive?” Each of these is a question about paths through relationships, and relational databases, despite the name, make paths expensive: every hop is another self-join, and query cost explodes with depth.
Graph databases invert the trade. Relationships are stored as first-class, directly-traversable objects, so following a connection costs the same whether it’s hop one or hop seven. This post covers how to model for them: the property graph model, the design process, patterns and pitfalls, and an honest account of when not to use a graph.
The Property Graph Model in Two Minutes
The dominant model (Neo4j, Amazon Neptune, Memgraph) has four elements:
- Nodes — the entities:
(:Person),(:Account),(:Device). - Labels — a node’s type(s); a node can carry several.
- Relationships — directed, named connections between nodes:
(:Person)-[:OWNS]->(:Account). Every relationship has a type and a direction (though you can traverse both ways). - Properties — key-value data on both nodes and relationships: a
:TRANSFERREDrelationship can carryamountandtimestamp.
That last point is the model’s quiet superpower. In relational design, a relationship with attributes forces a junction table; in a graph, the relationship simply carries its data:
(alice:Person {name:"Alice"})-[:TRANSFERRED {amount: 5000, at: "2026-06-12"}]->(bob:Person {name:"Bob"})When a Graph Beats a Relational Model
The honest test is not “is my data connected?” — all data is connected — but “do my queries traverse a variable or deep number of hops?” Compare the question “who are Alice’s friends-of-friends-of-friends?” in both worlds.
SQL, three self-joins deep and growing per level:
SELECT DISTINCT f3.friend_idFROM friendships f1JOIN friendships f2 ON f2.person_id = f1.friend_idJOIN friendships f3 ON f3.person_id = f2.friend_idWHERE f1.person_id = 42;Cypher (the graph query language), where depth is a parameter, not a rewrite:
MATCH (a:Person {id: 42})-[:FRIENDS_WITH*1..3]-(fof)RETURN DISTINCT fof;The graph engine follows stored pointers from node to node — no index lookups per hop, no join planning. Workloads where this dominates: fraud rings, social networks, recommendation engines (“customers who bought what you bought also bought…”), knowledge graphs, network/IT topology, supply-chain tracing, and access-control resolution (“does this user, through any role chain, have permission X?”).
If your queries are aggregations over millions of uniform rows (“total sales by region”), a graph is the wrong tool — columnar warehouses crush graphs at scan-and-aggregate work.
The Modeling Process: Nouns, Verbs, Questions
Graph modeling is closer to natural language than any other paradigm, which makes whiteboard sessions with domain experts remarkably productive:
- Write the questions first — as sentences. “Find accounts sharing a device with a flagged account.”
- Nouns become nodes: Account, Device, Person.
- Verbs become relationships: USES, OWNS, FLAGGED_AS.
- Check each question is a path through your sketch. If answering requires assembling data that isn’t connected, add the missing relationship.
The most important design instinct: things you’ll traverse through must be nodes, not properties. Suppose you model a person’s email as a property: (:Person {email: "x@y.com"}). Now “find people sharing an email” is a full scan comparing property values — the graph gives you nothing. Model the email as a node — (:Person)-[:HAS_EMAIL]->(:Email {addr}) — and shared emails become shared nodes: two OWNS arrows converging on one point, traversable in constant time. The rule generalizes: attributes are for filtering, nodes are for connecting. Whenever a value can link entities (emails, devices, addresses, skills, tags), promote it to a node.
A Worked Example: Fraud Ring Detection
Fraud teams discovered graphs early because fraud is structurally invisible in rows. Individually, each account looks fine; the fraud is the shape — many accounts quietly sharing infrastructure.
Model: (:Account)-[:USES]->(:Device), (:Account)-[:PAYS_WITH]->(:Card), (:Account)-[:REGISTERED_AT]->(:Address). The killer query — “find accounts within three hops of a flagged account through any shared identifier” — is a few lines of Cypher and runs in milliseconds, because it just walks pointers outward from the flagged node. The relational equivalent is a UNION of self-joins per identifier type per depth, and most teams simply never build it.
Patterns and Pitfalls
Qualify your relationships. RATED {stars: 4} beats separate LIKED/DISLIKED types when the value matters; but prefer specific relationship types (ACTED_IN, DIRECTED) over one generic RELATED_TO with a type property — traversal by relationship type is the engine’s fastest filter.
Time on relationships. Facts change: employment, ownership, friendship. Put from/to properties on relationships to keep history traversable: -[:WORKED_AT {from: 2019, to: 2023}]->. “Who worked together at Acme in 2021?” stays a path query.
Beware supernodes. A node with millions of relationships — a (:Country {name:"India"}) node every user connects to, or a celebrity in a social graph — turns every traversal that touches it into a scan of its relationship list. Mitigations: don’t model low-selectivity categories as nodes (make country a property or an index instead — connecting through it tells you almost nothing anyway), and for legitimate hubs, filter by relationship type/direction early or shard the hub.
Don’t graph your whole enterprise. The successful pattern is a graph for the relationship-shaped subdomain (fraud, recommendations, the knowledge graph), fed from systems of record that remain relational or document-based. Graphs earn their keep on traversal queries; they’re a poor system of record for bulk transactional data.
Getting Started Checklist
- Write your ten questions as sentences; confirm at least half involve variable-depth or multi-type traversal. If not, stop — use SQL.
- Sketch nodes (nouns) and relationships (verbs); walk every question as a path on the sketch.
- Promote any “connecting” attribute (email, device, tag) to a node.
- Add time properties to relationships that change.
- Load a realistic sample and profile your worst query for supernodes before committing.
Graph modeling rewards a specific kind of thinking: instead of asking “what are my entities?”, you ask “what do I want to walk between?” When the questions are about connection — who touches whom, what leads to what, how close is A to B — no other model comes close. When they aren’t, no amount of graph enthusiasm will beat a well-indexed join. Knowing which situation you’re in is the modeling skill this post was really about.