Session 3 · Week 2 · Wed 10 June 2026 · deep dive

Relational vs Document vs Key-Value.

The quickest way to spot a beginner's design is a database picked before anyone explained how the data gets used. Tonight we flip that: first decide how the data is read and written, then the database almost picks itself.

Ehsan Gazar
From Senior to Staff · session 3 of 12
Where we're going

The run sheet.

~10mReframe + the four questionsinterrogate before you name a store
~10mThe six familieseach a different bet on the same questions
~20mRelational + ERD designhow to model, and the principles
~35mThe actual products, by familyPostgres, Mongo, Redis, Cassandra, …
~15mMatrix · decision flow · polyglotside by side, then when to mix
~10mA real systemfiles + a log + a derived graph
~10mLive exercise + ADR #1 bridgethe database decision
By the end of tonight

You'll be able to…

1Start from how the data is used, not from a database you already like.
2Name a real database for each job, Postgres, Mongo, Redis, Cassandra, and what each is good and bad at.
3Draw a clean data diagram (ERD) and say why it's laid out that way.
4Back your choice with the one rule it must protect, and know the cost of using more than one database.
The reframe

The data model follows the access pattern.

Nobody picks a store in a vacuum. You pick it from how the data is written and read, the invariants you must enforce, and the scale you must hit. Name those first and the shortlist writes itself.

💡 In plain English

Pick the container that fits how you'll grab your stuff: a filing cabinet you can search any way (relational), a folder of whole documents (document), or a wall of numbered lockers that are lightning-fast if you know the number (key-value).

How is it read?
by key? by range? by join? full-text?
ask first
What must stay true?
transactions, uniqueness, referential integrity
invariants
How big, how fast?
the scale that rules options in or out
limits
The interrogation

Four questions, asked out loud, before any store name.

Ask these four in order. By the fourth, the answer is usually obvious, and you can explain it instead of guessing.

read
How is it read?
by an id, by a range, by joining tables, by text search, or a whole object
write
How is it written?
one row at a time, a whole object, or just keep adding new entries
true
What must stay true?
no duplicate emails, no orphaned rows, all-or-nothing changes
scale
How big, how fast?
how many rows, how many writes per second, how fast it must respond

The trap is jumping straight to the fourth one. "We need scale" decides nothing until you've answered the first three.

The map

Six families. Each is a different bet on those four questions.

💡 In plain English

There are really only six kinds of database. Almost anything you meet is one of these six, so learn the families first and the named products slot in underneath.

1
Relational
tables you can join any way · the safe default
2
Document
store a whole object together · joins get awkward
3
Key-value
instant lookups by id · nothing else
4
Wide-column
built for gigantic write loads
5
Search / OLAP
text search and number-crunching, each its own tool
6
Graph
things and the links between them
Family 1 · Relational

Normalize once, query a hundred ways you didn't predict.

You keep each fact in one place, in tidy tables, and the database stitches them back together with joins when you ask. The big win: you can ask questions you never planned for. It only struggles when one table grows too big for a single machine.

erDiagram
  AUTHOR ||--o{ POST : writes
  POST ||--o{ COMMENT : has
  AUTHOR ||--o{ COMMENT : makes
  AUTHOR { uuid id PK }
  POST { uuid id PK }
  COMMENT { uuid id PK }
        
💡 In plain English

Like one spreadsheet workbook where every fact lives on exactly one sheet. It can enforce rules for you, like "no two users with the same email." It only struggles when one sheet gets bigger than a single computer can hold.

Modeling relational · the method

How to build an ERD, in order.

1Find the entities, the nouns.The real things you store: customer, order, product. Each becomes a box.
2Give each box a primary key.One column that uniquely identifies a row. Prefer a made-up id over something that can change, like an email.
3Draw the relationships, the verbs, and how many."a customer places many orders." Mark one-to-one, one-to-many, or many-to-many.
4Every many-to-many gets a middle box.It can't be a single line. Add a box (order_item) that holds an id pointing to each side.
5Don't repeat yourself: one fact, one place.No duplicated data; every column belongs to that row. Stops the same fact drifting out of sync.
Modeling relational · the notation

A normalized model, read in crow's-foot.

Same example, drawn properly. Note the M:N between products and orders is resolved by order_item, and the line ends tell you the cardinality at a glance.

erDiagram
  CUSTOMER ||--o{ ORDER : places
  ORDER ||--|{ ORDER_ITEM : contains
  PRODUCT ||--o{ ORDER_ITEM : "appears in"
  CUSTOMER {
    uuid id PK
    string email UK
  }
  ORDER {
    uuid id PK
    uuid customer_id FK
    timestamp placed_at
  }
  ORDER_ITEM {
    uuid order_id FK
    uuid product_id FK
    int qty
  }
  PRODUCT {
    uuid id PK
    string name
    int price_cents
  }
        

Reading the line ends: || exactly one · o{ zero or many · |{ one or many. PK = the row's unique id, FK = a pointer to another table's id, UK = must be unique. Only duplicate data on purpose later, when a specific read needs it.

Relational · the product

PostgreSQL: the boring, correct default.

Open-source, fiercely ACID, with SQL plus JSONB, full-text, and extensions (PostGIS for geo, pgvector for embeddings). It will carry you much further than you expect.

What it is
relational OLTP, strong types, transactions, extensible
Reach for it when
invariants, ad-hoc queries, "I'm not sure of the shape yet"
Avoid when
you've truly outgrown one primary for writes, or pure KV at huge scale
Gotcha
JSONB is handy but it is not a document database; index it deliberately

If you can't name a reason to leave Postgres, don't. Start here, earn the exit.

Relational · the product

MySQL / MariaDB: the other default.

The most-deployed relational engine, replication-first, everywhere. The honest choice mostly when it's already in your stack or your team knows it cold.

What it is
relational OLTP, InnoDB engine, battle-tested replication
Reach for it when
existing ecosystem, simple proven OLTP, read-replica scaling
Avoid when
you want rich types, advanced SQL, or extensions, Postgres wins there
Gotcha
weaker JSON and window-function story; mind storage-engine and collation quirks

Same family as Postgres. Pick on ecosystem and team, not on a benchmark blog.

Family 2 · Document

Store the aggregate you read, as one shape.

If a screen always loads an order with all its items, store them together and grab them in one read. No joins, and each document can have its own shape. The tricky part is data shared in two places: do you copy it in or link to it?

flowchart LR
  Q(["How is it read?"])
  Q -- "always together,
read as a unit" --> EMB["EMBED
order + items in one doc · 1 read · duplication"] Q -- "shared, changes
independently" --> REF["REFERENCE
store an id · app-side join · no duplication"] classDef e fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef r fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef a fill:#ffffff,stroke:#1F2937,color:#0E1726; class EMB e; class REF r; class Q a;
💡 In plain English

Like saving a whole order as one document instead of spreading it across tables. Copying shared data in makes reads fast but the copies can go stale; linking to it keeps one truth but means joining again.

Document · the product

MongoDB: the document default.

Stores flexible JSON-like documents, with proper indexes, an aggregation pipeline, and built-in sharding. Quick to change when your data shape is still moving.

What it is
document store, indexes + aggregation, horizontal sharding
Reach for it when
aggregate-shaped reads, evolving schema, fast product iteration
Avoid when
heavy cross-document joins or invariants that span many documents
Gotcha
"flexible schema" is not "no schema"; design around the read or you rebuild it

Multi-document transactions exist now, but if you need them everywhere, ask if you wanted relational.

Document / key-value · the product

DynamoDB: managed scale, zero ops, on AWS.

Amazon's fully managed key-value and document store: very fast reads at any size, and no servers to run. You shape your keys around the exact queries you'll make.

What it is
serverless KV/document, predictable latency, auto-scaling
Reach for it when
known access patterns, huge scale, no ops budget, AWS-native
Avoid when
ad-hoc queries or analytics; you can't predict the patterns yet
Gotcha
model keys first; a new query can mean a new index (GSI) or a migration

The discipline DynamoDB forces, patterns up front, is exactly tonight's lesson made mandatory.

Family 3 · Key-value

If you always know the key, nothing is faster.

Think of a giant dictionary spread across many machines. You give it a key, it hands back the value in basically one hop. The catch: you can only look things up by the exact key, no searching, sorting, or joining.

flowchart LR
  K(["get( session:8f2a )"]) --> H["hash(key) → node"]
  H --> N1["node A"]
  H -.-> N2["node B"]
  H -.-> N3["node C"]
  N1 --> V(["value · one hop"])
  classDef hot fill:#DCEBFE,stroke:#1F2937,color:#0E1726;
  classDef cold fill:#fff,stroke:#9CA3AF,color:#6B7280;
  class K,H,N1,V hot;
  class N2,N3 cold;
        
💡 In plain English

Like a wall of numbered lockers: instant if you know the number, useless if you don't. Perfect for sessions, caches, and counters, where you always already hold the key.

Key-value · the product

Redis: in-memory, sub-millisecond, more than a cache.

More than strings: it has lists, hashes, sorted sets, streams, and pub/sub. It lives in memory (RAM), which is why it's so fast, and also its main limit.

What it is
in-memory data-structure server, optional persistence
Reach for it when
cache, sessions, rate limits, leaderboards, queues, pub/sub
Avoid when
it's your only durable source of truth for large data sets
Gotcha
memory-bound; plan eviction policy and what a cold restart loses

Treat it as a derived store by default. The truth lives somewhere durable; Redis makes it fast.

Family 4 · Wide-column

Model around the query, then absorb enormous writes.

Data is grouped into partitions; inside each, rows are kept sorted. You design a separate table for each query and happily store the same data more than once. In return: huge write speeds and no single bottleneck.

Partition key
picks the machine · must be in every query
Clustering key
sorts rows within a partition · lets you read ranges
One table per query
data duplicated on purpose, one copy per question
The cost
you can't ask a question you didn't build a table for
💡 In plain English

Built to swallow a firehose of writes, like every event from millions of devices. The price: you can only ask questions you planned a table for in advance.

Wide-column · the product

Cassandra / ScyllaDB: masterless write firehose.

No single master, every machine takes writes, consistency you can dial per query, and it scales across regions. ScyllaDB is a faster rewrite with the same model.

What it is
wide-column, masterless, multi-region, tunable consistency
Reach for it when
massive write throughput, time-series, always-on multi-region
Avoid when
ad-hoc queries, strong cross-row invariants, or merely modest data
Gotcha
query-first tables, partition key in every read, no joins, expect duplication

Most teams that "need Cassandra" actually have a Postgres they haven't tuned. Earn this one.

Family 5 · The other two stores

Full-text and analytics are not your primary store's job.

Two jobs quietly need their own engine. Cramming them into your main database is a classic cause of slow, painful nights.

Search

Builds a word-to-documents index, so you get ranking, typo tolerance, and filters. It's a copy fed from your real database, never the original.

Analytics (OLAP)

Stores data by column, so "add up this giant table" only scans the columns it needs. Built for reports and dashboards, not per-request reads.

Why keep them apart

Their read and write shapes fight your main database. Mix them and one job starves the other of resources.

The tell

"This is a search or reporting job, so it gets its own copy, refreshed on a schedule I can name."

Search · the product

Elasticsearch / OpenSearch: relevance and logs.

A search engine with ranking, typo tolerance, filters, and aggregations. Also powers most "search your logs" setups. Always a copy of your real data, never the original.

What it is
distributed full-text search + analytics over an inverted index
Reach for it when
full-text search, faceted filtering, log/observability search
Avoid when
as a source of truth, or for transactional reads and writes
Gotcha
near-real-time, not strongly consistent; feed it, plan reindexing

If you ever query it for the truth, you've made it the source of truth by accident. Don't.

OLAP · the product

ClickHouse / BigQuery: billions of rows, fast aggregates.

Analytics engines that store data by column: scan only what a query needs, compress hard, and add up enormous tables in seconds. Easy to append to, painful to update.

What it is
columnar OLAP, massive scans, heavy compression
Reach for it when
dashboards, event analytics, ad-hoc aggregates over big history
Avoid when
per-request OLTP reads/writes or frequent row-level updates
Gotcha
load in batches from your event stream; updates and deletes are expensive

DuckDB is the same idea, embedded, when "big data" actually fits on one laptop.

Family 6 · Graph (Neo4j, Kuzu)

When the relationships are the point, traverse, don't join.

Things (nodes) and the links between them (edges) are first-class, so "friends of friends who bought X" is a short walk, not a pile of self-joins. Cypher is the usual query language.

What it is
nodes + typed relationships, index-free traversal, Cypher
Reach for it when
recommendations, fraud rings, lineage, "who connects to whom"
Avoid when
simple tabular reads, or high-volume flat write workloads
Gotcha
great for traversals, not a general OLTP store; often a derived store

Neo4j is the server-grade default; Kuzu is an embedded, file-based graph engine, the one we'll see in the real system shortly.

Side by side

The same four questions, six answers.

FamilyReads well byJoinsSchemaEnforces invariantsScales by
Relationalanything (ad-hoc)nativerigid, migratedyes, for youvertical, then hard
Documentaggregate / keyapp-sideflexiblein-doc onlyhorizontal
Key-valueexact keynoneopaque valuenoflat / linear
Wide-columnpartition + rangenoneper-tablenolinear writes
Search / OLAPtext / aggregateslimitedindexed / columnarno (derived)horizontal
Graphrelationshipstraversalflexibleedges onlyvertical-ish

No row is "best." Read down the columns, not across: each column is a question you should already have answered.

The shortlist, mechanically

Answer "how is it read?" and the store falls out.

💡 In plain English

Answer one question, "how is it mostly read?", and follow the arrow to a database. Then ask whether several patterns clash; if not, use one database and stop.

flowchart TB
  R{"How is it
primarily read?"} R -- "by exact key" --> KV["Key-value
+ wide-column if writes are huge"] R -- "as one whole aggregate" --> DOC["Document"] R -- "ad-hoc, joined,
with invariants" --> REL["Relational"] R -- "by relationships" --> GR["Graph"] R -- "full-text" --> SRCH["Search index
derived"] R -- "heavy aggregates" --> OLAP["OLAP / columnar
derived"] REL --> INV{"More than one
access pattern fights?"} DOC --> INV INV -- "yes" --> POLY["Polyglot:
1 source of truth + derived stores"] INV -- "no" --> ONE["One store. Stop here."] classDef rel fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef doc fill:#E6E8FF,stroke:#1F2937,color:#0E1726; classDef kv fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef der fill:#EEE6FF,stroke:#1F2937,color:#0E1726; classDef ask fill:#ffffff,stroke:#1F2937,color:#0E1726; class REL rel; class DOC doc; class KV,GR kv; class SRCH,OLAP,POLY der; class R,INV,ONE ask;
When one store isn't enough

One source of truth, many derived stores, kept in sync on purpose.

Polyglot persistence is not "use five databases." It is: pick one store that owns the truth, then add read-optimized derivations (a cache, a search index, an analytics copy) that you rebuild from it. Every extra store is a sync problem you now own.

💡 In plain English

Keep one master copy you can always trust. The others are convenient photocopies for specific jobs. When the master changes, the photocopies are briefly wrong, that lag is the price, so say how big it is.

Source of truth
writes go here · enforces invariants · the one you back up
authority
Derived stores
rebuildable from the source · search, cache, OLAP, graph
disposable
The sync tax
a pipeline, a staleness window, and a way to rebuild
you own this
A real system · my own knowledge repo

Polyglot in miniature: files, a log, and a derived graph.

My personal knowledge base is plain Markdown. No app, no server. Yet it runs three stores at once, each chosen from an access pattern, and it shows the whole lesson in one picture.

flowchart LR
  subgraph SOT["Source of truth"]
    MD["Markdown + YAML
notes, strategy, facts"] LOG["graph.jsonl
append-only decision log"] end MD --> BUILD["graph-build
derivation step"] LOG --> BUILD BUILD --> KUZU["Kuzu graph DB
derived · drop & rebuild"] KUZU --> QQ(["relational / graph queries
'all docs about a venture'"]) MD --> GREP(["grep
raw phrase match"]) classDef sot fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef der fill:#EEE6FF,stroke:#1F2937,color:#0E1726; classDef read fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class MD,LOG sot; class BUILD,KUZU der; class QQ,GREP read;

Markdown is the document source of truth. graph.jsonl is an append-only event log of decisions. Kuzu is a derived graph store, dropped and rebuilt from both, never authoritative.

Access pattern first, in practice

"Use Kuzu first, grep second." Same data, two stores.

The rule in the repo's own docs is an access-pattern decision written down. The question decides the tool, not familiarity.

The questionRead shapeStore
All docs about a venturegraph traversal / joinKuzu
Which decisions superseded whichrelationship walkKuzu
Tasks that slipped across filescross-file aggregateKuzu
Find this exact phrase / task idraw text scangrep

Grep can't answer "everything connected to this venture" without me re-deriving the joins by hand. So the relational questions get a graph store, the text questions get text search. Same bytes, the access pattern split the work.

The repeatable move

Access pattern → Invariant → Store → Sync cost.

name
Access pattern
"read is by exact key, writes are huge"
protect
The invariant
"no double-charge" or "none needed here"
pick
The store
"so: wide-column for the writes"
own
The sync cost
"ledger stays in Postgres, this is derived"

Four short sentences. Run them on any database choice and you sound like you decided it, because you did.

The traps

Four ways engineers pick the wrong store.

1
Familiarity-driven
"we use Postgres here" with no access pattern named.
2
Résumé-driven / hype
reaching for Cassandra at 10k rows because it's "web-scale."
3
One store, every job
forcing full-text or analytics into the OLTP database.
4
Polyglot too early
five stores and five sync problems before you needed one.

The fix for all four is the same: start from the access pattern and name the invariant.

Your turn · ~8 min

Pick a store from one feature's access pattern.

Take one feature from your app. Don't name a database yet. Answer the four questions out loud, let the answer fall out, and say the one rule the database has to protect.

1
Read & write shape?
by key, range, join, text, aggregate, relationship
2
Which invariant?
the one true thing this store must guard
3
One store, or polyglot?
and what sync cost did you just take on?

Drop your store + the invariant it protects in the chat. We'll react to a few.

Recap · then what's next

Access pattern first, store second, invariant always.

Relational for invariants and ad-hoc queries; document for aggregate reads; key-value and wide-column for scale with fewer guarantees; graph for relationships; search and OLAP as derived stores. Mix only when one store genuinely can't serve a pattern, and name the sync cost when you do.

Project 2 · ADR #1: Database Decision
Starts here, builds through Session 4 (data at scale). Due Jun 19.
next deliverable
Take-home
Write the access patterns for one feature, sketch its ERD, then the invariant, before naming any store.
bring it Friday