Search Tech Journey

Find topics, journeys and posts

6-month learning plan44 / 130
back to blog
data engineeringintermediate 55m read

S044 · NoSQL Landscape — KV, Document, Column, Graph

Four families, one truth: NoSQL means giving up something you had in Postgres for something you needed more. Learn which one to reach for and — more importantly — when not to.

🗃️DatabasesM04 · Databases & SQL· Session 044 of 130 90 min

🎯 Explain the four NoSQL families in one paragraph each, name a canonical database for each, and pick the right one (or reject NoSQL entirely) for a given workload.

Why this session exists

"NoSQL" is one of the most misleading terms in engineering — the databases in it share almost nothing except not being Postgres. There are actually four wildly different families (key-value, document, column-family, graph), each solving a different problem, each with a different failure mode. Half of every "we should use MongoDB" argument you've ever heard was really "we don't know Postgres well enough yet." The other half was solving a real problem MongoDB is genuinely good at. This session teaches you to tell them apart.

You will be able to
  • Name the four NoSQL families and one canonical database per family.
  • Explain in one sentence what each family gives up to gain what.
  • Reject ‘we need NoSQL because scale’ as a default answer and ask three follow-ups.
  • Read a Cassandra data model and predict which queries it can and cannot answer efficiently.
  • Sketch when a graph database beats a relational one — and when it doesn't.

Prerequisites

  • S042 — Transactions & ACID (so you know what most NoSQL is relaxing).
  • S043 — Query Planning (contrast with NoSQL's ‘denormalise for queries’ approach).


(a) Intuition · 5 min

Four different kinds of storage rooms
🌍 Real world

Imagine your kitchen has four separate storage rooms. The KV room is 10 000 identical numbered lockers — instant lookup by number, but you can only ask "what's in locker 743?" and nothing else. The document room has folders where each folder can hold anything (a recipe, a receipt, a photo) and you can search inside them. The column room is a warehouse of pallets where every item on a pallet has the same schema, indexed by which shelf/aisle. The graph room is a wall of pinned photos with strings between them — you don't search photos, you follow strings from photo to photo.

Each room is genuinely better than the others at exactly one thing. Choosing the wrong room means your kitchen is slow.

💻 Code world

KV = Redis, DynamoDB, Memcached — one key → one blob. Sub-millisecond, dumb, massive scale. Session tokens, feature flags, cache.

Document = MongoDB, CouchDB, Firestore — one key → a JSON tree you can query into. Great for objects with variable shape (product catalogues, CMS content).

Column-family = Cassandra, ScyllaDB, HBase, Bigtable — one row-key + column-key → cell. Predictable-shape, write-heavy, tunable consistency. Time-series, activity feeds, IoT.

Graph = Neo4j, JanusGraph, Amazon Neptune, TigerGraph — nodes + edges + properties. Optimised for "friends of friends of friends" style multi-hop traversal. Fraud rings, social graphs, knowledge graphs.

The trade you're making, per family

What each family gives up to gain what
  • KV — gives up rich queries. Gains ~O(1) lookup at any scale.
  • Document — gives up cross-document joins and strict schema. Gains flexible per-doc shape and easy horizontal partitioning by document key.
  • Column-family — gives up JOINs, secondary-index-first querying, and cross-partition transactions. Gains linear scale for known query patterns.
  • Graph — gives up horizontal partitioning at will (graphs are hard to shard). Gains multi-hop traversal that would kill a relational database.

A quick history so you know why the world looks like this

  1. 1979
    Relational becomes standard
    IBM System R, Oracle v2 ship. SQL wins for 25 years.
  2. 2003
    Google Bigtable paper
    Column-family for petabyte-scale sparse tables. Inspires HBase, Cassandra.
  3. 2007
    Amazon Dynamo paper
    Eventually-consistent KV over consistent hashing. Inspires Riak, Cassandra's replication.
  4. 2009
    MongoDB, Cassandra, Redis all in production
    ‘NoSQL’ term catches on. Overpromises begin.
  5. 2012
    Neo4j hits 1.0 GA
    Property graphs become mainstream; Cypher query language ships.
  6. 2015
    NewSQL emerges
    Spanner, CockroachDB, YugabyteDB — ‘we want SQL + horizontal scale + ACID’.
  7. 2022
    ‘Postgres is enough’ meme wins
    Postgres 15 ships JSONB, logical replication, partitioning. Kills 70 % of the NoSQL use case.

(b) Visual walkthrough · 15 min

The four families side by side

The mental model to hold

Relational (Postgres)

Model the world, query anything.

  • Normalize into 3NF then JOIN as needed
  • ACID by default
  • Vertical scaling — one big box
  • Best when queries evolve over time
Key-Value (Redis)

One key, one blob, blazing fast.

  • No queries, only GET/SET/DEL
  • Sub-ms latency at 100k+ QPS per node
  • Great for cache, session, rate-limit
  • Bad when you need any secondary lookup
Document (MongoDB)

One JSON blob per business object.

  • Query inside the JSON with rich operators
  • Sharded by document key
  • Weak on multi-document txns until 4.2
  • Best when each object stands alone
Column-family (Cassandra)

Wide-row, write-optimised, tunable consistency.

  • Model per query: one table per query
  • Linear write scale; every write is a local append
  • No JOINs, no ad-hoc queries
  • Best when write volume >> read variety
Graph (Neo4j)

Nodes + typed edges + properties.

  • Cypher language: MATCH (a)-[:KNOWS]->(b)
  • Constant-time traversal per hop
  • Hard to shard; usually single-master
  • Best when hops > 2 dominate the query

Cassandra data modelling: query-first, not entity-first

1
List the queries FIRST

e.g. ‘get last 20 messages between Alice and Bob’. This is the schema driver, not the entity list.

2
One table per query

Denormalise ruthlessly. Same fact may live in 3 tables — writes go to all 3.

3
Pick partition key so a query touches one partition

Partition key = the ‘folder’ that must live on one node. Cluster key = the ‘sort order within folder’.

4
Accept the write amplification

Cassandra loves writes. Rewriting the same fact 3× is cheap. Rewriting queries after schema is set is what's expensive.


Common misconception
✗ What most people think

"NoSQL means schemaless — you can just throw documents in and figure out the structure later."

✓ What is actually true

There is no such thing as schemaless data, only schema-on-read. The schema still exists; you have moved it from the database into every application that reads the data. Instead of one declaration the engine enforces, you now have an implicit schema duplicated across N services, enforced by none of them, and versioned by nobody.

Why the myth is so sticky

Because the early experience is genuinely better: no migrations, no ALTER TABLE, ship features fast. The cost is deferred, not avoided, and it arrives as defensive code — every reader checking whether a field exists, handling three historical shapes of the same document, and guessing what a missing value means. By then the data is in production and there is no single place to fix it, because there is no single place at all.

Prove it to yourself

The schema did not disappear; it moved into the reader:

# Three generations of the same document, all live in the collection
{'user_id': 1, 'name': 'A'}                                # v1
{'user_id': 2, 'name': 'B', 'email': 'b@x.com'}             # v2
{'user_id': 3, 'profile': {'name': 'C', 'email': 'c@x.com'}} # v3 - restructured

# Every reader now carries the schema, forever:
def get_email(doc):
    if 'profile' in doc:
        return doc['profile'].get('email')
    return doc.get('email')      # may be absent - and is that 'unknown' or 'none'?

# In SQL this is one migration and one NOT NULL constraint,
# enforced once, for every reader, permanently.
From first principles
Start with the question

Why does the CAP theorem force a choice, and why is "CP versus AP" a misleading way to describe real systems?

  1. 1
    A distributed system holds copies of data on multiple nodes connected by a network.
    forced by · replication is the only way to survive a machine failure and the only way to scale reads beyond one machine
  2. 2
    Networks partition — packets are dropped, links fail, nodes are unreachable. This is not a rare event to be engineered away; it is a permanent property.
    forced by · any physical network can fail, and at scale something is always failing somewhere
  3. 3
    During a partition, a node receiving a write cannot confirm that its peers have it, and a node receiving a read cannot confirm it has the latest value.
    forced by · coordination requires communication, and communication is exactly what has failed
  4. 4
    So that node has precisely two choices: answer with possibly-stale or unreplicated data (available, not consistent), or refuse to answer (consistent, not available).
    forced by · there is no third option — respond or do not respond
  5. 5
    Therefore P is not a choice you make; it is a condition imposed on you. The only real choice is what to do during a partition: C or A.
    forced by · you cannot opt out of the network failing
⇒ Therefore

Therefore "pick two of three" is a poor summary — you must tolerate partitions, so you are picking one of two, and only during the partition.

And note what this predicts: the far more useful framing is PACELC — during a Partition choose A or C, Else (normal operation) choose Latency or Consistency. That second clause is the one that governs your system 99.9% of the time, and CAP says nothing about it. Every quorum setting you tune — W + R > N, read-your-writes, bounded staleness — is a point on the latency/consistency curve during normal operation, which is where you actually live.

Mental modelQuery pattern picks the store

Do not choose a datastore by its category label. Choose it by the access pattern it makes cheap, because every store is fast at exactly one shape of access and mediocre or terrible at the rest.

Key-value is a hash map: instant if you know the key, useless otherwise. Document is a key-value whose values you can index into: good when one document is one aggregate you read whole. Wide-column is a sorted map on a partition key: brilliant for time-ordered rows within a partition. Graph is precomputed adjacency: cheap multi-hop traversal that would be n self-joins in SQL. Relational is the general-purpose one: it does everything adequately and nothing optimally.

  • Model the queries first, then the data. In NoSQL, denormalisation and duplication are the design, not a compromise — you store data once per access pattern.
  • A partition key that concentrates traffic creates a hot partition, and no amount of cluster size fixes it. Key design is the dominant performance decision.
  • "Eventually consistent" has no bound unless the system gives you one. Ask what the staleness window actually is and whether read-your-own-writes is guaranteed.
  • Losing joins and multi-key transactions is the real cost, and it is paid in application code — the complexity does not vanish, it relocates.
🔔 Fires when you see

Fire this model the moment you see: a proposal to migrate to NoSQL "for scale" without a stated access pattern · a single-node relational database at its write ceiling · time-series or event data keyed by entity · a session/cache store · a recommendation or fraud problem that is fundamentally about relationships · a document that is always read and written whole.

The tradeoff

A service needs to scale past what one relational instance can handle. Shard the relational database, adopt a NoSQL store, or scale up and optimise?

Scale up + optimise
+ you gain keeps transactions, joins and constraints; modern single machines handle far more than most teams assume, and indexing/query fixes often buy an order of magnitude for a week of work
− you pay a hard ceiling exists, vertical scaling gets expensive superlinearly, and it remains a single failure domain
pick when you have not yet profiled and fixed the top queries — nearly always the correct first move, and frequently the last one needed
Shard the relational database
+ you gain retains SQL, the existing tooling and team knowledge, and full ACID within a shard; scales writes roughly linearly with shard count
− you pay cross-shard joins and transactions become application problems, rebalancing is operationally painful, and the shard key is a decision that is extremely expensive to change later
pick when the data has an obvious tenant-like partition key (customer, region, account) and queries almost never cross it
Purpose-built NoSQL store
+ you gain horizontal scaling and failover are built in, and for the access pattern it was designed for, performance is far beyond a general-purpose engine
− you pay you give up joins, multi-key transactions and often strong consistency; the data model is frozen around today's query patterns, and a new access pattern can require a full re-model and backfill
pick when the access pattern is genuinely known, stable, and narrow — key lookups, time-series by entity, or traversals — and the scale requirement is real and measured
What a senior engineer actually does

The order matters: optimise, then shard, then adopt a specialised store — and only move down the list when you have measured that the previous step is exhausted. A great deal of NoSQL adoption is a response to an unindexed query, and migration is a vastly more expensive fix than an index.

What actually happens in mature systems is polyglot persistence: relational as the system of record because constraints and transactions are worth real money, with specialised stores alongside it for the specific patterns that justify them. The cost of that is keeping them in sync, which is why change data capture and event streaming exist — and why the next few sessions are about exactly that.


(c) Hands-on · 25 min

A minimum viable feel of all four families using local Docker. Save as nosql_tour.sh.

#!/usr/bin/env bash# nosql_tour.sh 5-minute tour of Redis, MongoDB, Cassandra, Neo4j.# Requires: docker. Uses default ports on localhost. Removes containers at end.set -euo pipefail log() { printf "\033[1;36m %s\033[0m\n" "$*"; }cleanup() { docker rm -f redis-demo mongo-demo cass-demo neo-demo >/dev/null 2>&1 || true; }trap cleanup EXIT log "1/4 Redis KV"docker run -d --rm

What each block does

Anatomy of the script

trap cleanup EXIT
Guarantees Docker cleanup even if a command fails mid-tour. Copy this pattern for every local-Docker script.
safety
Redis SET/GET/INCR/EXPIRE
The four verbs that solve 80 % of KV use cases: cache, counter, session, rate-limit.
kv
MongoDB insertMany + $elemMatch
Rich query into a nested array — the thing Postgres jsonb only got good at in 12+. Note: no JOIN.
doc
Cassandra PRIMARY KEY ((chat_id), sent_at)
Double parens = partition key alone. sent_at = clustering key. Every read by chat_id hits one partition on one node.
col
Cassandra CLUSTERING ORDER BY DESC
Physical order on disk = query order. ‘Get last 20 messages’ becomes a range scan of the head of the partition. No sort, no join.
col
Cypher MATCH … [:FRIEND*2..2]
Two-hop traversal in one line. Equivalent SQL is a 3-way self-JOIN that gets ugly at 4+ hops.
graph
Docker port mappings
6379 Redis / 27017 Mongo / 9042 Cassandra / 7474+7687 Neo4j. Memorise; these ports are legendary.
ops
Try itFeel query-first modelling in Cassandra

Extend the Cassandra section with a second table that lets you query "all messages sent by Alice, most recent first":

CREATE TABLE demo.messages_by_sender (
  sender  TEXT,
  sent_at TIMESTAMP,
  chat_id UUID,
  body    TEXT,
  PRIMARY KEY ((sender), sent_at)
) WITH CLUSTERING ORDER BY (sent_at DESC);

Then modify the app to INSERT into BOTH tables on every message. That is idiomatic Cassandra — writes are cheap, reads are the constraint.

💡 Hint · Add a second table `messages_by_sender` with `PRIMARY KEY ((sender), sent_at)` and INSERT into both on every message. That's the Cassandra way — no secondary index, denormalise into a query-specific table.

(d) Production reality · 15 min

War story Discord· 2017120 M messages/day at the time
🔥 What broke

Discord's first message store was MongoDB. It worked to ~100 M messages, then the working set exceeded RAM and reads slowed by 10×. Adding indexes didn't help because the queries were "last N messages in channel X" — Mongo's B-tree wasn't optimised for that access pattern.

🧯 The fix

They rewrote to Cassandra with schema PRIMARY KEY ((channel_id, bucket_id), message_id), where bucket_id groups messages into ~10-day windows. Every "read last N in channel" hits one partition, sequentially. They now handle billions of messages per day on the same cluster shape.

🎓 Lesson to steal
Choose the DB for your hot access pattern, not your entity model. Discord's win came from designing the partition key before writing app code.
Post-mortem
War story Uber· 2016described in ‘Why We Left Postgres for MySQL’ blog
🔥 What broke

Uber outgrew a single Postgres cluster for trip data. They moved to MySQL with a homegrown sharding layer (‘Schemaless’), then later to a mix of MySQL + Cassandra + Redis, each for a different access pattern.

🧯 The fix

The lesson wasn't ‘Postgres bad’ — it was ‘monolithic DB bad at Uber scale’. They ended up with 4 different databases in production, each tuned to a workload. The 2020 refactor unified some of these on YugabyteDB.

🎓 Lesson to steal
At extreme scale, one database is never enough. But at your scale, one Postgres is almost certainly enough. Don't cargo-cult Uber's architecture.
Post-mortem
War story Stack Overflow~200 M pageviews/month on one Postgres
🔥 What broke

The famous case in the opposite direction: Stack Overflow serves the entire site — questions, answers, votes, users, tags — from a single SQL Server (and mirror). No sharding, no NoSQL, no microservices. It handles 6 000 requests/second on well-tuned hardware.

🧯 The fix

Nothing to fix. The lesson: with careful indexes, connection pooling, and a good caching layer (Redis), a single well-run relational DB serves an internet-scale site. NoSQL was not required.

🎓 Lesson to steal
‘We need NoSQL because scale’ is the most abused sentence in engineering. Prove you've saturated Postgres first. You won't have.
Post-mortem

Where this shows up in the rest of the plan

Every future data session picks from this menu
S045 · Data modelling
Dimensional vs OBT vs Vault — the modelling half of NoSQL vs SQL.
S046 · Batch vs streaming
Kafka is a log — a fifth ‘NoSQL family’ if you squint.
S048 · Kafka
Consumer groups + partitions are the same partition-key idea as Cassandra.
S078 · Consistency & consensus
The CAP/PACELC theory behind Dynamo-style KV stores.
S110 · Caching
Redis is where 95 % of ‘KV’ actually lives in production.
S121 · System design — chat
Discord's Cassandra design revisited as an interview problem.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. The four NoSQL families in 60 seconds — name, canonical DB, what it gives up.
  2. ‘Why not just Postgres?’ — the JSONB argument in one sentence.
  3. Cassandra's core rule — ‘one table per query’ and why.

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.