Part I — Foundations
Data Models: Relational vs. Document vs. Graph
Every data model makes some queries easy and others awkward — the choice shapes how you're allowed to think about your data.
Every data model comes with built-in assumptions about how data relates to other data. Picking one is really picking which questions will be easy to ask later, and which ones you’ll fight the database to answer.
The same order, modeled three ways:
Normalized and joinable — great for ad-hoc queries across the data, at the cost of needing a join to reconstruct one order.
Relational
Data lives in tables of rows with a fixed schema. Relationships between records are expressed with foreign keys, and joined together at query time. Relational databases excel when your data is regularly structured and you need flexible, ad-hoc queries across relationships you didn’t anticipate when you designed the schema.
The cost is the object-relational impedance mismatch: your application’s in-memory objects are nested and graph-like, but SQL tables are flat. An “order” with a list of “line items” doesn’t map cleanly onto rows without joins or a separate table.
Document
Document databases (MongoDB, Couchbase) store self-contained JSON-like documents. If your data naturally has a tree structure — one order containing its line items, one blog post containing its comments — a document maps onto that structure directly, with no joins needed to fetch the whole thing.
The tradeoff shows up for many-to-many relationships: documents are great for one-to-many trees, but if two documents need to reference each other (a user references many groups, a group references many users), you’re back to doing application-side joins.
{
"_id": "order_8891",
"customer": { "name": "Priya Shah", "email": "priya@example.com" },
"lineItems": [
{ "sku": "TSHIRT-M", "qty": 2, "price": 19.99 },
{ "sku": "MUG-01", "qty": 1, "price": 9.99 }
]
}
Graph
When many-to-many relationships are the point of the data — social networks, recommendation engines, fraud-detection link analysis — graph databases (Neo4j and similar) make traversing arbitrary-depth relationships a first-class, efficient operation instead of an exploding chain of joins.
MATCH (me:Person {name: "Alex"})-[:FOLLOWS]->(:Person)-[:FOLLOWS]->(fof:Person)
WHERE NOT (me)-[:FOLLOWS]->(fof)
RETURN DISTINCT fof.name
Cost / benefit at a glance
Each model optimizes for a different shape of data and a different kind of question. The table is a starting point, not a verdict — the last row (“worst case”) is usually what decides real projects.
| Dimension | Relational | Document | Graph |
|---|---|---|---|
| Best-fit data shape | Regular, tabular, uniform rows | Self-contained trees / aggregates | Densely interconnected entities |
| One-to-many | Join to a child table | Nest it in the document — free | Edges to child nodes |
| Many-to-many | Native: join table + JOIN |
Painful: app-side joins or duplication | Native: that’s the whole point |
| Multi-hop relationships (“friends of friends of friends”) | Self-join per hop, degrades fast | Not really supported | Constant-cost traversal per hop |
| Schema changes | ALTER TABLE, migrations, planning |
Just write the new shape; handle old shapes on read | Add labels/properties freely |
| Ad-hoc queries you didn’t design for | Excellent — SQL + a good optimizer | Limited — you can only query along the shape you stored | Good along relationships, weaker for aggregate reporting |
| Aggregations / reporting | Excellent (GROUP BY, window functions) |
Weak to moderate (aggregation pipelines) | Weak — export to a warehouse instead |
| Transactions across many records | Mature, well understood | Improving, often single-document or limited scope | Varies; Neo4j has ACID, many others don’t |
| Horizontal write scaling | Historically hard (sharding is manual); improving with distributed SQL | Easy — documents shard cleanly by key | Hard — graphs resist partitioning because edges cross shards |
| Tooling / hiring / operational maturity | Deepest of the three | Good | Shallowest; smaller talent pool |
| Worst case | Object-relational impedance mismatch; join-heavy code | Many-to-many and cross-document consistency | Anything that isn’t traversal; scaling writes past one machine |
The costs that actually bite
Think about cost in four buckets, because they show up at different times in a project’s life:
- Development cost (now). How much friction between your code’s objects and the store? Document stores win when your aggregate maps 1:1 to a document. Relational wins when you have many independent entities that combine in varying ways. Graph wins when your code is already doing recursive relationship walks.
- Query cost (every day). The question you ask most often should be the cheapest. “Give me this one order and everything in it” is one document read vs. a five-table join. “How many orders per region per month” is one
GROUP BYvs. a fight. “Which accounts are within 3 hops of a known-fraud account” is one traversal vs. an exploding self-join. - Change cost (later). Schema-on-write makes you pay up front (migrations) but guarantees every reader sees a known shape. Schema-on-read defers the cost to every read path forever — cheap to add a field, but old documents linger and your code accumulates
if (doc.newField)checks. - Getting-it-wrong cost (worst). Migrating a live system between models is expensive and risky. The asymmetry: a relational schema with
JSONBcolumns can absorb document-style needs later; a document store rarely grows into comfortable many-to-many or reporting support. Starting relational keeps the most doors open.
Choosing in context
Default to relational
If you can’t clearly articulate why one of the others fits better, use a relational database (often with a JSONB/JSON column for the genuinely unstructured parts). It has the best query flexibility, the strongest consistency story, and the deepest tooling, and it rarely becomes the thing that blocks you.
Reach for document when the aggregate is the unit of work
Choose a document store when your data is naturally a set of independent trees, you read and write a whole tree at a time, and you rarely need to query across trees or join them.
- Fits: product catalogs (each product is a self-contained blob with wildly varying attributes), CMS / content entries, user-facing config and preferences, event or telemetry payloads, per-customer settings blobs, a game player’s inventory.
- Warning signs you picked wrong: you’re writing application code to join two collections; you’re duplicating the same nested data in many documents and then chasing consistency bugs when it changes; you need reports that group across all documents.
Reach for graph when relationships are the product
Choose a graph database when the connections carry as much meaning as the entities, and your core queries are traversals of varying or unknown depth.
- Fits: social networks (friends, follows, mutual connections), recommendation engines (“people who bought X also bought…”), fraud and anti-money-laundering (rings, shared devices/addresses, paths between accounts), network and infrastructure topology (which services depend on this host), identity and access graphs (nested groups and inherited permissions), knowledge graphs, supply-chain and bill-of-materials trees.
- Warning signs you picked wrong: most of your queries are single-entity lookups or aggregate reports; you need to scale writes across many machines; your “graph” is actually shallow (one or two fixed hops) and a couple of join tables would do.
Or use more than one (polyglot persistence)
Large systems rarely pick just one. A common pattern: relational as the system of record, a document store for a denormalized read model or user-generated content, a graph for the recommendation/relationship features, and a separate analytics warehouse for reporting — all fed from the same source of truth. The cost is operational (more systems to run, keeping copies in sync); the benefit is that each query hits a store that makes it cheap.
A quick decision checklist
Ask, in order:
- What’s the one query I’ll run most, and which model makes it a single cheap operation?
- Are many-to-many relationships central, or incidental? Central and deep → graph. Incidental → relational. Absent, and data is tree-shaped → document.
- Will I need ad-hoc queries and aggregate reporting I can’t predict today? Yes → relational.
- Do I need to scale writes beyond one machine soon? Yes → document shards easily; graph does not; relational needs distributed SQL or manual sharding.
- How uniform is the data? Highly uniform → relational. Every record different → document.
- If I’m wrong, how hard is the escape? Relational +
JSONBleaves the most exits.