The idea: the same data in three shapes

A data model is the shape you give your data before you store it: rows in tables, nested documents, or vertices joined by edges. The shape decides which questions are easy. Martin Kleppmann's Designing Data-Intensive Applications (chapter 2) shows it with one LinkedIn profile: Bill Gates, user 251. This page stores that profile in all three models and runs the same requests on each, counting queries, rows and joins.

The résumé of user 251 three ways: as a users table with positions and education tables pointing back by user_id, as one JSON tree with positions and education nested inside, and as a graph with user, organization and school vertices joined by labelled edges.
One profile, three shapes: tables linked by IDs, one nested tree, or vertices linked by labelled edges.

The page has three tabs. Résumé compares tables with one document for the tree-shaped part of the profile. Many-to-many adds organizations, schools and recommendations as entities, so the data is no longer a tree. Graph query runs the book's Cypher query and its SQL translation on a small graph of people and places.

The relational model

In the relational model data is a set of tables. A value that appears once per user (first name, region) is a column of users. Things a user has many of (jobs, schools, contact details) go into their own tables, one row each, with a foreign key user_id pointing back. To show a profile you run one query per table, or one query that joins them all (Demo: one read vs six queries: 6 queries, 9 rows).

Application code usually works with objects, not rows, so a translation layer sits in between: an ORM such as Hibernate or ActiveRecord. The gap between the two shapes is the object-relational mismatch (see Hibernate Session and the N+1 query problem).

The document model

A résumé is mostly a tree: one user, many positions, many schools. A document database (MongoDB, CouchDB, RethinkDB, Espresso) stores the whole tree as one JSON document. One read returns everything, because it sits together on disk. That is locality. Adding a job is one write to one document, the same cost as one inserted row (Add Position).

Documents have no enforced schema. The structure is read when the data is read (schema-on-read), not checked when it is written (schema-on-write). If you split name into first_name and last_name, a document database lets old and new documents live side by side and the code handles both. A relational database needs an ALTER TABLE and an UPDATE first.

Many-to-one: IDs vs copied text

The profile stores region_id: "us:91", not the text "Greater Seattle Area". Many people live in one region: a many-to-one relationship. With an ID, the human-readable text is stored once and everything else points at it. The ID means nothing to people, so it never has to change. Removing such duplication is the idea behind normalization (see Database Normalization).

Renaming region us:91. Left: with an ID, one regions row changes and both users still point to it. Right: with copied text, user 251's document is updated while user 467's still holds the old name, so two names exist until the second write.
With an ID, a rename is one write. With copied text, every copy must change, and readers see both names until the last write lands.

Switch document region to as copied text and rename the region (Demo: renaming a copied value). Relational: 1 write. Documents holding the text: one write per profile, and between the writes a reader sees two names for one place. The reverse trade-off: with IDs, the document database has no join, so the application makes two extra look-ups for the region and the industry names.

RequestRelationalDocument, region as IDDocument, region copied
Load profile 2516 queries, 9 rows1 read + 2 look-ups1 read
Rename region us:911 row1 document2 documents (251, 467), stale in between
Add a position1 row1 document1 document

Many-to-many relationships

Features make data more connected. If an organization becomes an entity with its own page, a résumé must refer to it. If one user can recommend another, the recommendation must refer to its author, so the author's current name and photo can be shown. These are many-to-many relationships: many users worked at Microsoft, and a user worked at many places.

Relational databases handle them with join tables and joins: Show Résumé is 4 queries and 3 joins, all done inside the database. A document database now holds references (org_id, author_id). With weak or no join support, the application resolves them: 4 queries and 3 joins in application code (Demo: references need joins). Copying the author's name and photo into the recommendation saves a query, but the copy goes stale when the author changes their photo (Demo: a copied photo goes stale).

The reverse question, Who Worked at Microsoft?, shows the asymmetry of documents. The job list lives inside each user's document, so the organization doesn't know its people. The database must search every user document for org_id 2, or keep an index on positions.org_id. Tables have no built-in direction: the same join answers both questions. The debate is old: IBM's hierarchical IMS (1968) had the same trouble with many-to-many data, which led to the network (CODASYL) and relational models in the 1970s.

The graph model

When many-to-many links are the main thing in the data, a graph fits best. In a property graph (Neo4j, Titan) each vertex has an id, properties, and its outgoing and incoming edges. Each edge has a tail vertex, a head vertex, a label (WITHIN, BORN_IN, LIVES_IN) and properties. Any vertex may link to any other, and edges can be walked in both directions. You can even store a graph in two relational tables, vertices and edges, with an index on each end of the edge. The edges row read line in the Graph query tab shows the row each step reads.

The graph in the Graph query tab is the book's Figure 2-5. Lucy was born in Idaho and lives in London. Alain was born in Beaune and lives in London too. Places nest at different depths: a US state sits directly in a country, while a French city sits in a département, a région and then a country. A relational schema would need a fixed column per level. The graph doesn't care, and Add Carlos adds a city inside a state without any schema change.

Cypher vs recursive SQL

The book's query finds everyone who emigrated from the United States to Europe:

MATCH
  (person) -[:BORN_IN]->  () -[:WITHIN*0..]-> (us:Location {name:'United States'}),
  (person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'})
RETURN person.name

-[:WITHIN*0..]-> means "follow WITHIN edges zero or more times", like * in a regular expression. The number of joins isn't known in advance. SQL can express that only with a recursive common table expression (WITH RECURSIVE, since SQL:1999). The book's version needs four CTEs and about 30 lines. Run SQL grows each set one recursion round per step (Demo: recursive SQL).

Cypher is declarative: it says what to find, not how. The engine may start at every person and climb the place hierarchy (forward), or look up United States and Europe through an index on name and walk the incoming edges down (backward). Both return Lucy (Demo: forward vs backward). After Add Carlos, both return Lucy and Carlos, through paths of different lengths (Demo: a new person, deeper path).

The Lucy and Alain graph: Lucy BORN_IN Idaho WITHIN United States WITHIN North America; Lucy and Alain LIVES_IN London WITHIN England WITHIN United Kingdom WITHIN Europe; Alain BORN_IN Beaune WITHIN Côte-d'Or WITHIN Bourgogne WITHIN France WITHIN Europe. Lucy's two matching paths are highlighted.
Lucy matches both patterns. Alain's birthplace climbs to Europe, never to the United States, so he doesn't match.

Which model when

RelationalDocumentGraph
Fits bestmany-to-one and many-to-many, ad-hoc queriesself-contained trees read as a wholehighly connected data, variable-length paths
Localitya record spread over tablesone document, one reada vertex and its edges
Joinsin the database, by the optimizerweak or none: often in the appedge traversals
Schemaon write (enforced)on read (implicit)on read; any vertex links to any vertex
ExamplesPostgreSQL, MySQL, OracleMongoDB, CouchDB, RethinkDBNeo4j, Titan, Datomic (triples)

The models are converging. PostgreSQL and MySQL store and index JSON columns, and MongoDB's aggregation $lookup performs joins. For how one document database stores and finds its documents, see MongoDB internals. For turning an ER design into tables, see ER Model to Relational Tables.

What the page leaves out

The page leaves out the triple-store model and SPARQL, Datalog, and imperative graph languages such as Gremlin. It doesn't show indexes, storage layouts or how a real optimizer costs the forward and backward plans. The document side doesn't model MongoDB's $lookup joins, multikey indexes or transactions across documents. The relational side omits the summary and photo_id columns of the book's users table, as well as JSON or array columns.

On the mind map

This page on the Databases & Storage mind map · 42 pages in 8 branches · open it to see the whole map and test yourself in recall mode.