System Design · Data Models
Graph Database Concepts
Fraud wants “accounts within three transfers of this device.” Recursive SQL joins explode into self-join soup. A with makes each hop a walk of stored neighbors. That is the real offer: cheap local hops, not “graphs are free at any depth.”
This note is the cost model: , , , partitions, and the split between OLTP graph queries and . Relational engines can store edges as tables; the question is which hop pattern you must make boring.
One multi-hop query
Index to a Person/Device, not the whole graph.
FOLLOWS, not every neighbor.
Degree × depth is the bill.
Remote hops turn into distributed BFS.
What actually happens
A stores nodes (records with a and properties) and edges (a , direction, and properties). With , the node record points at its incident edges. The first hop does not scan an edges table for from_id—it follows those pointers.
A query is a : start set → expand → filter → expand. Memory and CPU track the , not the number of pattern letters in Cypher. A on the path multiplies work. Sharding introduces : the pointer may be an RPC.
stores triples and answers SPARQL with covering triple indexes. runs global algorithms in supersteps. Mixing those with a latency-sensitive neighborhood API on one unplanned store is how graph projects stall.
Storage: adjacency versus edge tables
Relational edge table hop:
SELECT to_id FROM edges
WHERE from_id = $id AND type = 'FOLLOWS'
→ B-tree lookup on (from_id, type)
Fine for low degree; still an index probe per node in the frontier
Index-free adjacency hop:
node_record.out_edges['FOLLOWS'] → linked records / packed neighbor ids
→ sequential walk of that node's typed list
Constant factors win when you chain many hops on modest degrees
Both still pay O(degree). Neither makes hop-3 at degree 1e4 cheap.Packed neighbor arrays (CSR-style, common in analytics graphs) are excellent for scans and terrible for high-churn OLTP edge inserts unless you leave slack or accept rewrites. Pointer-rich object graphs are the opposite: easy local mutation, worse cache behavior for bulk algorithms. Engines pick a side; you should know which you bought.
Properties on edges need a place to live. Fat edge payloads turn a neighborhood walk into a wide-row read. Keep traversal filters on cheap typed edges; store bulky payloads on nodes or in a side KV store keyed by edge id.
Traversal and frontier growth
Read the diagram definition
flowchart TD
S["Start: Person id=42"] --> E1["Expand FOLLOWS out"]
E1 --> F1["Frontier hop-1"]
F1 --> Filt["Filter: active = true"]
Filt --> E2["Expand FOLLOWS out again"]
E2 --> F2["Frontier hop-2"]
F2 --> Dedup[Dedup + depth cap]
Dedup --> Super{"Hit supernode?"}
Super -->|yes| Budget["Apply degree budget / skip"]
Super -->|no| Out[Return paths / nodes]
Budget --> OutEstimate before you promise an API. Average out-degree d, depth k, weak filters: frontier ≈ d^k before duplicate detection. Real social graphs have clustering, so the exponent is milder—until a sits in the middle.
Friend-of-friend:
start degree 300, hop-2 unique ≈ 300 * 300 * clustering
clustering 0.1 → ~9,000 nodes, maybe tens of thousands of edge visits
Easy on one machine if adjacency is local
Fraud 1..4 TRANSFERRED_TO:
d=20, k=4 → 160,000 paths if tree-like
one mule account with d=50,000 → that hop dominates
Engine needs: maxDepth, maxNodes, relationship type, property filters
applied during expansion, not after materializing all paths
Variable-length (1..*) without a cap:
equivalent to asking the database to search until RAM diesbelong behind budgets. Shortest-path and bidirectional search help when both endpoints are known. “Find any interesting structure out there” is graph compute, not a web request.
Supernodes
A is a modeling problem first. If everyone connects to (:Country {name: US}), you built a star. Split edges by type and time: LIVES_IN_2026, or store the country as a property on the person instead of a hub node when you never traverse through it.
When the supernode is inherent (a celebrity, a popular listing), the engine must not expand all edges in one operator. Techniques: relationship grouping (edges sharded by type and by neighbor id range), approximate sampling for recommendations, precomputed neighborhoods for the hot vertex, and query planners that rewrite MATCH (c)-[]-(n) away from starting at c.
Detect
Track degree histograms. Alert when a vertex’s expansion dominates query CPU. Product graphs almost always have a power-law tail.
Contain
Type the edges, cap expansion, cache hot ego-graphs, and keep bulky metadata off the adjacency list so a degree walk stays a compact id list.
Partitioning and hop locality
Read the diagram definition
flowchart LR
subgraph M1["Machine A"]
A[User 42]
N1[Neighbors]
end
subgraph M2["Machine B"]
X[Celebrity]
N2[Millions of edges]
end
A -->|"local hop"| N1
N1 -->|"remote hop"| X
X -->|"explosion"| N2A tries to keep edges inside a machine. Social graphs cluster; hash(userId) ignores that and maximizes the cut. Replicating a (vertex-cut) avoids shipping the hub but creates consistency work when its edges change.
Many production “graph” features stay single-shard: partition by tenant or by user and only traverse inside that subgraph. Global 3-hop on a billion-edge graph is a different product. If you need it, budget a distributed traversal layer (or precompute) instead of assuming the OLTP graph cluster will absorb it.
| Placement | Local hops | Pain |
|---|---|---|
| Single machine / replica set | All | Capacity ceiling; HA still needed |
| Hash vertices | ~1/N of neighbors | Deep queries become scatter BFS |
| Community / tenant cut | Most intra-tenant | Cross-tenant edges and imbalance |
| Vertex-cut supernodes | Hubs replicated | Update fan-out on the hub |
OLTP graph versus graph compute
Online traversals have a start vertex, a small depth, and a latency SLO. has “all vertices,” iterative supersteps, and checkpointed shuffle. PageRank, community detection, and embedding pipelines belong on the compute side; they should write derived properties or tables that the OLTP graph or a KV cache can serve.
Pattern matching (Cypher, Gremlin) sits in between: a selective pattern can be OLTP; a poorly filtered pattern is an unbounded compute job accidentally issued by a product API. Treat query budgets like SQL timeouts, and log expansion counts the way you log rows scanned.
OLTP:
MATCH (p:Person {id:$id})-[:FOLLOWS]->(f)
-[:FOLLOWS]->(fof)
WHERE f.active AND fof.active
RETURN fof.id LIMIT 50
Budget: tens of ms, start indexed, types specified
Compute:
foreach vertex: send rank/degree to neighbors
iterate until delta < eps
write pagerank property to serving store
Do not:
MATCH (n)-[*]-(m) RETURN m
on the production cluster at noonWhen a graph engine is the right store
Strong fits: authorization graphs, fraud rings, network topology, knowledge graphs with bounded hops, recommendations that are actually path queries. Weak fits: tabular reporting, append-only telemetry, documents that are trees but are always fetched by primary key (a document store already nests trees).
You can implement a graph on relational tables with recursive CTEs or on a KV store with adjacency lists. That is valid when hop depth is 1–2 and degree is modest. Switch when the hop depth and mixed relationship types make adjacency a first-class layout, or when the query language’s pattern matcher is the product.
is a better fit when identity is a URI, predicates are shared vocabularies, and inference matters. Property graphs are a better fit when developers think in labeled nodes and typed edges with ad hoc properties. Translating between them is a project.
Worked micro-estimate
Social OLTP cluster:
200M people, avg 200 FOLLOWS edges
edge record 24 B packed → 200M * 200 * 24 B ≈ 960 GB edges
plus node properties 200M * 500 B ≈ 100 GB
Replication 3× → ~3 TB disk, RAM for hot ego-graphs only
Query: 2-hop FOLLOWS from one user, degree 200, clustering 0.15
visits ~ 200 + 200*200*0.15 ≈ 6,200 edges
local pointer walk ~ few ms if cached
hash-sharded 20 ways: expect many RPCs; p99 dominated by slow shard
Supernode (celebrity, 20M followers):
expanding inbound FOLLOWS once = 20M edge visits
not a web request; precompute or type-split (FOLLOWED_BY is not free either)
Budget RAM for:
adjacency of recently active users
not the whole 960 GB unless you paid for itTrade-offs to weigh
Native graph engine versus edges in SQL/KV
Depth and expressiveness versus bounded latency
Hash partitions versus locality-aware cuts
Fat properties on edges versus compact adjacency
OLTP serving versus graph compute
Choosing and explaining graph databases
A complete explanation connects the mechanism to a requirement. Describe what improves when you introduce it, which new failure cases become possible, and how you would know that the trade-off is worthwhile.
Explain the core idea
A property graph stores typed nodes and edges; index-free adjacency makes a local hop a neighbor-list walk instead of a global join.
Query cost is frontier size times per-edge work, made worse by supernodes and remote partitions.
Use a graph engine when multi-hop typed patterns are the product; otherwise an edge table or nested documents may be enough.
When it belongs
Fraud, authz, recommendations, or topology questions that are naturally paths.
Recursive SQL becomes the bottleneck and hop depth is greater than one or two with mixed types.
You must explain why “just 3 hops” is not O(1).
How it fails
Supernode expansion blowing CPU and RAM.
Unbounded variable-length matches as user-facing queries.
Hash partitioning turning every deep query into a distributed BFS.
Running full-graph algorithms on the OLTP cluster.
Trade-offs to weigh
Native adjacency versus staying in SQL for operational simplicity.
Partition locality versus rebalance simplicity.
Rich edge properties versus compact walks.
Common misunderstandings
Claiming graph databases make any relationship query constant time.
Drawing a graph on the whiteboard and stopping at “we’ll use Neo4j” without a hop budget.
Confusing RDF triple stores with property-graph OLTP, or OLTP graphs with Pregel compute.
Modeling every attribute as a node and creating accidental supernodes.
Wrap-up
Graph stores earn their keep when the cheap question is “who is connected, through these edge types, within this budget.” Adjacency, typed relationships, and start indexes make hops local. Frontiers, supernodes, and cuts decide whether that stays true at product scale.
Key takeaway
Hops are pointer walks only while neighbors are local, typed, and bounded. Estimate the frontier, isolate supernodes, and keep global algorithms off the request path.