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.
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.
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).
Ask these four in order. By the fourth, the answer is usually obvious, and you can explain it instead of guessing.
The trap is jumping straight to the fourth one. "We need scale" decides nothing until you've answered the first three.
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.
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 }
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.
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.
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.
If you can't name a reason to leave Postgres, don't. Start here, earn the exit.
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.
Same family as Postgres. Pick on ecosystem and team, not on a benchmark blog.
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;
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.
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.
Multi-document transactions exist now, but if you need them everywhere, ask if you wanted relational.
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.
The discipline DynamoDB forces, patterns up front, is exactly tonight's lesson made mandatory.
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;
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.
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.
Treat it as a derived store by default. The truth lives somewhere durable; Redis makes it fast.
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.
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.
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.
Most teams that "need Cassandra" actually have a Postgres they haven't tuned. Earn this one.
Two jobs quietly need their own engine. Cramming them into your main database is a classic cause of slow, painful nights.
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.
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.
Their read and write shapes fight your main database. Mix them and one job starves the other of resources.
"This is a search or reporting job, so it gets its own copy, refreshed on a schedule I can name."
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.
If you ever query it for the truth, you've made it the source of truth by accident. Don't.
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.
DuckDB is the same idea, embedded, when "big data" actually fits on one laptop.
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.
Neo4j is the server-grade default; Kuzu is an embedded, file-based graph engine, the one we'll see in the real system shortly.
| Family | Reads well by | Joins | Schema | Enforces invariants | Scales by |
|---|---|---|---|---|---|
| Relational | anything (ad-hoc) | native | rigid, migrated | yes, for you | vertical, then hard |
| Document | aggregate / key | app-side | flexible | in-doc only | horizontal |
| Key-value | exact key | none | opaque value | no | flat / linear |
| Wide-column | partition + range | none | per-table | no | linear writes |
| Search / OLAP | text / aggregates | limited | indexed / columnar | no (derived) | horizontal |
| Graph | relationships | traversal | flexible | edges only | vertical-ish |
No row is "best." Read down the columns, not across: each column is a question you should already have answered.
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;
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.
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.
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.
The rule in the repo's own docs is an access-pattern decision written down. The question decides the tool, not familiarity.
| The question | Read shape | Store |
|---|---|---|
| All docs about a venture | graph traversal / join | Kuzu |
| Which decisions superseded which | relationship walk | Kuzu |
| Tasks that slipped across files | cross-file aggregate | Kuzu |
| Find this exact phrase / task id | raw text scan | grep |
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.
Four short sentences. Run them on any database choice and you sound like you decided it, because you did.
The fix for all four is the same: start from the access pattern and name the invariant.
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.
Drop your store + the invariant it protects in the chat. We'll react to a few.
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.