Beyond the table: alternative data models
Lesson 5 of 6
5Beyond the table: alternative data models
Four non-relational families, with the same data drawn in each. And at the end a real measurement: the same question over the same 300,000 relationships, 19.37 ms against 0.01.
What the picture shows: The same library data in four shapes: key-value, document, columns and graph, each good at its own question.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
A table is not the only way to keep information. Sometimes you keep whole files (documents), a quick address book (key-value), a friendship map (a graph), or a logbook ordered by time. Each shape makes one kind of question very fast and another kind slow.
Four non-relational families: document stores like MongoDB, key-value like Redis, graph like Neo4j, and time-series. The choice follows the access pattern: reading a whole entity, a lookup by key, following relations deeply, or aggregating over time.
Required fundamentals
Part three
You know how a class diagram becomes tables: class → table, multiplicity → foreign key.
The idea of a JOIN
That reading a row from two tables needs matching. That is all you need for the rest of this part.
Honesty about the question
This part does not ask “which database is best?” but “which shape fits my question?”. The first question has no answer.
Core concepts
The relational model normalises data so nothing repeats, then reassembles it at read time with joins. That is excellent when the questions are varied, and expensive when the question is one and repeats millions of times. An alternative is not “newer” but specialised for a particular question.
An enormous hash table: one key → a value the store does not understand. As fast as possible for “give me this key”, and completely unable to answer “give me everyone in Riyadh”. Its domain: sessions, caches, counters, feature flags.
Each record is an independent JSON tree, and the store understands its inside, so it can query within it. Its domain: the record you always read whole, a product page, a member profile, a shopping cart. Its price is duplication: data copied into a thousand documents goes stale.
Wide sparse rows, read by column rather than by row. Its domain: time series and measurements, millions of rows where two columns are wanted. Not “a faster table” but a different read direction.
The relationship is a first-class record: it has a type and data of its own. Its domain is the question whose subject is the relationship: friends of friends, shortest path, detecting fraud rings. Which is what we are about to measure.
The model follows the question, not the reverse. Whoever picks a store before writing down their ten most frequent questions is choosing by headline rather than by evidence.
How it works
Diagram 1, A decision tree that starts from the question, not a product name, and note the last leaf is relational
The same data in four shapes
Key-value: the store does not understand the value, so it cannot be asked about its inside.
# key-value: one key, one opaque blob. The store knows nothing about the inside.
session:9f3a1c -> {"user": 42, "expires": 1772000000, "cart": [7, 19]}
rate:42:minute -> 17
feature:dark -> "on"
GET session:9f3a1c # the only question it answers well
SET rate:42:minute 18Document: the same member, but the loans are inside, so their page is one read.
// document: a tree per record. Queryable inside, and self-contained.
{
"_id": 42,
"name": "Muath",
"city": "Riyadh",
"loans": [
{ "barcode": "D-01", "title": "Dune", "due": "2026-03-15" },
{ "barcode": "E-07", "title": "Emma", "due": "2026-03-20" }
]
}
db.members.find({ "loans.due": { $lt: "2026-03-18" } }) // asks inside the treeColumn family: a row may carry 365 columns, and we read two of them.
# column family: rows are wide and sparse; you read the columns you need. # row key | columns (each row may have entirely different ones) member:42 | name=Muath city=Riyadh joined=2024-02-01 reading:42:2026 | 01-05=Dune 01-09=Emma 02-14=Solaris ... (365 possible) SELECT 01-05, 01-09 FROM reading WHERE key = 'reading:42:2026'; # two columns read off disk - not the whole row
Graph: note the loan has become a relationship type with a date, that is the association class from part three, drawn exactly as it is.
# graph: the relationship is a first-class record with its own type and data.
(Muath)-[:BORROWED {on: "2026-03-01"}]->(Copy D-01)-[:COPY_OF]->(Dune)
(Muath)-[:FOLLOWS]->(Sara)-[:FOLLOWS]->(Amal)
MATCH (m:Member {id: 42})-[:FOLLOWS*2]->(x) RETURN DISTINCT x
# "*2" = two hops. The same question is a self-join in SQL.| Model | Answers brilliantly | Cannot answer | Its price |
|---|---|---|---|
| Key-value | “Give me this key” | Any question about content | No relationships, no search |
| Document | “Give me this whole record” | Joins across documents | Duplication that goes stale |
| Column family | “These two columns from a million rows” | Varied questions | The key design constrains you |
| Graph | “What lies two or three relationships away?” | Huge aggregations | Depth explodes |
| Relational | Questions you did not anticipate | Enormous reads of one question | Joins cost something |
The exercise: a real measurement over 300,000 relationships
“A graph is faster for relationships” is said often and measured rarely. So we measured it. We built a follow network: 20,000 users, each following 15 others, for 299,989 relationships in total, and asked the same question three times:
# the experiment: 20,000 users, each following 15 others
edges = [(a, b), ...] # 299,989 pairs
def fof_table_scan(user): # a table with no index
first = {b for a, b in edges if a == user}
return {b for a, b in edges if a in first} - first - {user}
index = defaultdict(set) # the same data, keyed by follower
for a, b in edges:
index[a].add(b)
def fof_indexed(user): # a table with an index
out = set()
for f in index[user]:
out |= index[f]
return out - index[user] - {user}
def reach(user, depth): # the graph shape, asked recursively
seen, frontier = {user}, {user}
for _ in range(depth):
nxt = set()
for u in frontier:
nxt |= index[u] - seen
seen |= nxt
frontier = nxt
return frontier$ python3 models.py users: 20000 follow edges: 299989 table, full scan per hop 19.37 ms -> 225 results table, indexed on the follower column 0.01 ms -> 225 results graph traversal, 2 hops 0.01 ms -> 225 results same answer from all three: True depth 1 -> 15 users reached (0.00 ms) depth 2 -> 225 users reached (0.03 ms) depth 3 -> 3065 users reached (1.02 ms) depth 4 -> 14971 users reached (10.57 ms)
same answer from all three: True proves it. The comparison is fair.Diagram 2, Depth explosion: three hops reach 15% of the network, four reach 75%
Myth vs. reality
| The myth | The reality |
|---|---|
| “NoSQL means no schema.” | It means the schema lives in your code rather than in the store. And an implicit schema is more dangerous than an explicit one, because nobody checks it. |
| “A graph database is faster for relationships.” | We measured it: an index on the table gave the same time (0.01 ms). The win came from the access shape. |
| “Documents remove the need for normalisation.” | They move its cost from read time to update time. What was one join becomes a thousand updates. |
| “A column family is a faster table.” | It has a different read direction: by column, not by row. And it is worse than a table for varied questions. |
| “Key-value is primitive.” | It is the fastest thing for its question, and sessions and counters need nothing more. What is primitive is using it for a different question. |
| “Pick the store, then write the queries.” | That is the mother of all mistakes. Write your ten most frequent questions first, then pick the shape that serves them. |
Try it yourself
Your “friends of friends” query takes 19 ms on a table. What do you try first?
Because this is exactly what we measured: the index gave 0.01 ms, the same as the graph traversal. An index is one line; a migration is a project. Caching hides the problem and adds staleness, and reducing users is not an engineering answer.
A traversal query with no depth bound on our network. What happens in production?
Our measurement: 15 → 225 → 3,065 → 14,971 out of 20,000. The growth is exponential rather than linear, which is why the query looks sound against small development data and collapses against real data.
Quick review flashcards
Check your understanding
1. In one sentence: why did the index and the graph traversal give the same time?
Because they are the same structure: a hash from a user to the set of people they follow. A graph store builds that structure for you and gives you a query language for it, but it holds no faster mathematical secret.
2. Explain in detail where the 19.37 ms came from.
The scan function walks the list of 299,989 relationships twice: once to collect who the user follows, and once to collect who those people follow. That is roughly 600,000 comparisons in Python, and that is the number. The index turns the question from “scan every relationship” into “15 reads by key”, so the work becomes proportional to the size of the answer rather than the size of the data, which is the entire difference, and the same difference as between a full-scan join and an indexed join in any relational database.
3. (Spot the bug) A team moved session data from a key-value store to a relational database “for consistency”. What is wrong?
They replaced the optimal shape for their question with a general one. A session is one question by a known key: it needs no join, no transaction and no unanticipated query, but it does need a write on every request and automatic expiry, both far cheaper in a key-value store. “Consistency” is a real organisational goal, but its price here is load on the heaviest database in the system for temporary data that does not deserve durability.
4. One question is 95% of your reads and ten varied questions are 5%. How do you choose?
Do not choose one model. Keep the relational store as the source of truth, because it serves the varied and unanticipated questions, and give the dominant question a specialised shape: an index first, and if that is not enough, a materialised view or a key-value store in front. The rule: one model is the source of truth and the rest are derived and rebuildable. Whoever makes the specialised shape the source of truth loses the ability to answer a new question.
5. (Decision) Your manager read that a large company moved to a graph store and wants the same. Your answer?
Ask for evidence rather than a reference: which query is slow, how long does it take now, what is our target, and does it have an index? Then show what we measured in this part: the same question over 300,000 relationships fell from 19.37 ms to 0.01 ms by adding an index, the same time as the graph traversal. If it is still slow after the index, and our core questions really are about deep paths of variable depth, then migration has a case. What worked at a company of a different size with different questions is no evidence about our problem, and measuring on our own data costs an hour, while a migration costs a quarter.
Resources for this part
Sorted by kind: start with the official docs when you want the precise reference, or with an article or video when you want the explanation first.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs3
Done with this lesson?
Sign in to save your progress.