Engineering Atlas › System Design Fundamentals

Modelling relationships in graph databases

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The property graph model

A node has labels (Person, Account) and properties. A relationship has a type (FOLLOWS, TRANSFERRED_TO), a direction, a start and end node, and properties of its own, such as a timestamp or amount. RDF stores use a simpler triple model (subject, predicate, object) aimed at linked data and ontologies; property graphs dominate application use. Property graphs now have a standard query language: GQL was published as ISO/IEC 39075 in April 2024, the first new ISO database language since SQL (Wikipedia: GQL). Cypher is converging on it, so vendor-specific syntax is slowly giving way to portable patterns.

Storage: index-free adjacency

In a relational schema, finding a person's friends is a join: look up rows in an edges table via a B-tree index on src_id, then fetch the friend rows. Each hop costs O(log n) index work. In a native graph store, node and relationship records are fixed size, so a record id maps directly to a file offset. A node record holds a pointer to its first relationship; each relationship record holds pointers to the next relationship for both its start and end node, forming a doubly linked chain per node. Traversing one hop is following a few pointers, O(1) per edge, independent of graph size.

Non-native engines store adjacency lists in an underlying key-value store: key (node, edge type, direction) maps to a sorted list of neighbor ids. That is also efficient per hop and is far easier to shard.

Query execution

A Cypher query such as:

MATCH (me:Person {id: 42})-[:FRIEND]->(f)-[:FRIEND]->(fof)
WHERE NOT (me)-[:FRIEND]->(fof) AND fof <> me
RETURN fof.id, count(*) AS mutual ORDER BY mutual DESC LIMIT 10

starts with an index lookup for id: 42, expands FRIEND edges to get friends, expands again, filters, and aggregates. Cost is the number of edges expanded: with 300 friends each having 300 friends, that is about 90,000 expansions, fine in memory. A third hop is 27 million, which is why depth limits matter.

Scaling out

Graphs resist partitioning because most real graphs have no clean cuts: any split leaves many edges crossing machines, and each cross-machine hop is a network round trip. Options include replicating the whole graph to every server (a Neo4j cluster keeps full copies on its primaries and read-scaling secondaries), sharding by node id and accepting remote hops (TAO, many distributed graph stores), or partitioning by a natural boundary such as tenant. Sharding plus caching is how the largest graph runs: TAO serves over ten billion reads and tens of millions of writes per second (Cheng et al., VLDB 2021). LinkedIn went the other way and built LIquid, a graph database with a Datalog-style query language, which holds 270 billion edges and serves 2 million queries per second (LinkedIn Engineering).

Supernodes

A celebrity with 50 million followers, or a "country: US" node linked to every user, turns any traversal through it into a scan. Mitigations include indexing relationships by type and property, splitting the node into buckets, or modelling such attributes as properties instead of nodes.

Down the stack

Layer 1The contract

A native graph database such as Neo4j promises ACID transactions over nodes, relationships, and properties, and traversal cost proportional to the number of relationships visited rather than to the size of the graph. A relationship always has a valid start and end node: a plain DELETE of a node that still has relationships fails, and DETACH DELETE removes the node and its relationships together (Cypher Manual). Reads within a transaction see a consistent state. In a Neo4j 5 cluster, one primary is elected writer and pushes each commit synchronously to the other primaries through Raft, so 2F + 1 primaries tolerate F failures. Secondaries (the role older versions called read replicas) receive the transaction log asynchronously and may lag, unless the client passes a bookmark so its next transaction only runs on a server that has applied its previous writes (Neo4j clustering).

Layer 2The mechanism

Neo4j's classic record formats (standard and aligned) store each entity type in its own file of fixed-size records, so the record for id N lives at byte offset N times the record size. The figures widely cited for the standard format are 15 bytes per node record, 34 bytes per relationship record, and 41 bytes per property record. A node record holds an in-use flag, the id of its first relationship, the id of its first property, and inline label bits. A relationship record holds the start and end node ids, the relationship type, and four chain pointers: previous and next relationship for the start node, and previous and next for the end node. Each relationship therefore sits in two doubly linked lists, one per endpoint, so the engine can walk a node's relationships and unlink one in constant time.

Properties form a separate linked chain of property records, with long strings and arrays spilling into dynamic stores. For nodes with many relationships (above db.relationship_grouping_threshold, 50 by default), the record formats insert relationship group records that split the chain by type and direction, so expanding :LIKES on a busy node does not walk past its :FOLLOWS edges (Neo4j settings). This layout is no longer the default. Since 5.22, new Enterprise databases use the block format, which stores each node in a 128-byte block with small properties (under about 31 bytes) inlined, and spills larger data into separate dynamic stores (node dynamic records are capped at 8 KiB), aimed at the locality problem below. The standard and high_limit formats were deprecated in 5.23, and aligned remains the Community Edition default (Neo4j store formats). Commits are written to a transaction log first, and the store files are updated afterwards through Neo4j's own page cache.

Layer 3The machine

Index-free adjacency turns every hop into pointer chasing, and every pointer is a dependent load: the CPU cannot fetch record N+1 until it has read record N's pointer. If the record is in CPU cache, that is a few nanoseconds; in DRAM, about 100 nanoseconds; on SSD, roughly 100 microseconds per miss. A 34-byte relationship record fits in one 64-byte cache line, but consecutive records in a node's chain were created at different times and are scattered across the file, so the prefetcher rarely helps. This is why Neo4j's sizing advice centres on the page cache, configured separately from the JVM heap: size it to the data plus native indexes plus about 20% for growth (Lucene and vector indexes are budgeted separately) (Neo4j memory configuration).

Analytic graph engines take the opposite trade. They load the graph into compressed sparse row (CSR) arrays, where all neighbours of a node are contiguous integers, so expanding a node is a sequential scan the prefetcher and SIMD handle well. CSR is far faster to traverse but expensive to update, which is why it suits batch analytics rather than transactional writes.

Why it's built this way

  1. Graph queries are dominated by "follow this edge," so the store makes one hop a direct offset computation instead of an index search.
  2. Direct offsets need fixed-size records, so variable-length data (properties, strings) moves to separate chains.
  3. Edges must be walkable from both ends and deletable cheaply, so each relationship sits in two doubly linked lists.
  4. Pointer chasing is latency bound and suffers on disk, so performance depends on fitting the store in memory.
  5. Supernodes make chains long, so dense nodes get relationship groups split by type and direction.

Diagram

flowchart LR
    N1["Node 42 (Person)"] -->|"first rel ptr"| R1["Rel 900: FRIEND 42->7"]
    R1 -->|"next rel of 42"| R2["Rel 905: FRIEND 42->9"]
    R2 -->|"next rel of 42"| R3["Rel 917: LIKES 42->300"]
    R1 -->|"end node"| N7["Node 7"]
    R2 -->|"end node"| N9["Node 9"]
    R3 -->|"end node"| N300["Node 300 (Page)"]
    N7 -->|"first rel ptr"| R4["Rel 870: FRIEND 7->15"]

Worked example

A bank runs fraud detection on transfers: 80 million accounts, 2 billion transfer relationships over the last 12 months. The question at payment time is: "is the receiving account within 3 hops of any account flagged as fraudulent in the last 90 days?" with a 50 ms budget.

In a relational store, three self-joins on a 2-billion-row table with average out-degree 25 means up to 25 × 25 × 25 = 15,625 lookups, each a B-tree probe, often 100 ms to seconds. In a graph store with the hot subgraph in memory, each expansion is a pointer chase of roughly a microsecond, so 15,625 expansions take around 20 ms. Supernodes break this: a payment processor account with 4 million incoming edges would blow up the search, so the team marks known merchant hubs and stops expansion at them.

The storage cost is real. At roughly 34 bytes per relationship record and 15 bytes per node in Neo4j's older standard record format, before properties, 2 billion relationships need about 70 GB plus properties and indexes, so the team keeps 12 months of edges in the graph and archives older data to the warehouse.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Native graph database (Neo4j, Memgraph)Fast multi-hop traversal, expressive pattern queriesHarder to shard writes, specialized operationsDeep, variable-length traversals on a graph that fits a cluster
Adjacency lists on a sharded KV store (TAO-style)Massive scale, simple, cache-friendlyOnly one or two hops per request efficientlySocial graph reads at very high QPS
Relational with recursive CTEsNo new system, transactions, SQL toolingSlow beyond 2 to 3 hops on large tablesHierarchies, shallow relationships
Graph analytics engine (Spark GraphX, Pregel-style)Whole-graph algorithms like PageRankBatch latency, not for online queriesOffline scoring, community detection
RDF triple storeStandards, inference, linked dataVerbose model, less common in appsKnowledge graphs with ontologies

Pitfalls in production

Leadership lens

Check yourself

  1. Why does a hop in a native graph store not get slower as the graph grows?

    Show answer

    Each relationship record stores direct pointers to the next record, so a hop is a constant-time pointer chase instead of an index lookup whose cost grows with size.

  2. Why is sharding a graph harder than sharding key-value data?

    Show answer

    Most graphs have no clean partition, so traversals frequently cross shards and each crossing costs a network round trip.

  3. A 2-hop query is fast but the 3-hop version times out. What is the likely reason?

    Show answer

    Fan-out multiplies per hop, so the third hop can touch orders of magnitude more edges, especially if it passes through a supernode.

  4. When is an adjacency list in a key-value store better than a graph database?

    Show answer

    When queries are mostly one or two hops at very high QPS and the data must be sharded across many machines.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →