What you'll learn: By the end of this lesson you will be able to say what each of the four NoSQL families (key-value, document, wide-column and graph) actually understands about your data, name the kind of question each answers directly and the kind it answers badly, read the structural limits and design rules in the Redis, MongoDB, Cassandra and Neo4j documentation, and give each family an honest verdict for ShopStream, including "not needed".
A house has four kinds of storage. A key rack by the door holds a hook per label, and nobody asks what is hanging on a hook. A filing cabinet has one folder per customer, and everything about that customer is in the folder. A ledger gives each customer a page, with entries in date order down the page. A corkboard has pins and strings, and the strings are the point. Each is perfect for one question and awkward for the rest: finding the keys is instant, finding every folder that mentions a postcode is not.
Databases Basics introduced the four families, ShopStream's data model gave them verdicts, and SQL vs NoSQL: A Decision Framework gives six questions for choosing. This lesson looks inside each family: what the store understands, the limits its documentation states, and what it costs to ask a question it was not built for.
These products change quickly and no version is pinned here. Everything below was read on 2026-11-08 from the current documentation of Redis, MongoDB, Apache Cassandra and Neo4j; check the page for the version you run.
The core idea
A NoSQL family is defined by what the store understands about your data, and that decides which questions it can answer without reading everything. The more the store understands, the more it can do for you, and the more you must shape your data to fit.
| Family | What the store understands | Answers directly | Answers badly |
|---|---|---|---|
| Key-value | A key (in Redis, also the type of the value) | "What is the value for this key?" | "Which keys hold something with property X?" |
| Document | The fields inside a self-contained document | "Give me this whole record", "find documents by a field" | Many-way joins; data that grows without bound inside one document |
| Wide-column | A partition key, and a sort order inside the partition | "Rows of one partition, in order" | Any query that does not name the partition key |
| Graph | Nodes and typed, directed relationships | "Who is connected to whom, in a few hops?" | Open-ended traversals with no bound |
Three labelled illustrations run through the lesson: a minimal example (Redis commands), a realistic example (per-store offers as documents, courier pings as a wide-column table) and a failure example (a command that stalls a key-value store).
Key-value: the store that understands only the key
Redis describes its keys plainly: every object has a unique key, a string you pass to commands. Keys are binary-safe, so any byte sequence works, even the empty string. There are no namespaces, so you avoid collisions yourself, and the documentation's convention is a colon-separated schema such as object-type:id. It advises against very long keys (a 1024-byte key is "a bad idea") and against cryptic short ones. The maximum key size is 512 MB.
A value is not always opaque. Redis calls itself a data structure server: besides strings it offers hashes, lists, sets, sorted sets and streams, and a value cannot exceed 512 MB. It is still a key-value model, because every question starts from a key. You gain atomic operations on one key (the documentation says INCR never races: two clients incrementing one counter always both count), expiry (a key can carry a time to live) and direct access (SET and GET are listed as O(1)).
Minimal example
Illustrative commands in redis-cli form, not run here:
SET session:ab12 "shopper:42" EX 1800
INCR views:product:9001
TTL session:ab12
The first line stores a session that expires after 1,800 seconds. The second counts a product view; if the key does not exist, Redis starts from 0, so the first call returns 1. The third returns the seconds left on the session. Nothing here asks the store to look inside a value.
The cost: durability is a setting, and search is a walk
Persistence is a choice: RDB snapshots, an append-only file (AOF), both, or none ("sometimes used when caching"). With RDB, expect to lose the latest minutes after a crash; with AOF's default policy (fsync every second), about one second of writes. The documentation suggests using both for safety comparable to PostgreSQL. A cache can run with none; a system of record cannot.
The other cost is asking by anything but the key. KEYS walks the whole keyspace and, the documentation warns, blocks the server until it finishes. SCAN iterates in small steps, but an element may be returned several times, and the MATCH filter is applied after elements are fetched, so a rare pattern means many empty rounds. If you need "all keys for shopper 42", maintain a set of them, as the documentation suggests.
Document: the store that understands fields
MongoDB documents in one collection "are not required to have the same set of fields or data types", and schema validation is available when you want a fixed structure. The guiding rule on its data modeling page: data that is accessed together should be stored together. You link data either by embedding it inside the document or by referencing it from another collection.
The manual's limits page sets two ceilings: a BSON document is at most 16 mebibytes, and nesting is limited to 100 levels. For most designs you hit a different wall first.
Realistic example: per-store offers
The catalogue document in the data model lesson works because a product page reads its fields together. Now add a price and availability per store. ShopStream has 5,000 stores (a shared illustrative number). The tempting model embeds every offer in the product:
{
"productId": 9001,
"name": "Whole Milk, 1 L",
"offers": [
{ "storeId": 7, "priceCents": 129, "inStock": true },
{ "storeId": 8, "priceCents": 135, "inStock": false }
]
}
Apply the rule. Is it accessed together? A shopper sees one store, so each product read drags up to 5,000 offers that nobody looks at, and every price change rewrites a bigger document. The 16 MiB ceiling is far off; the problem is read amplification, not the limit. The referenced model gives each offer its own document:
{ "productId": 9001, "storeId": 7, "priceCents": 129, "inStock": true }
db.offers.find({ productId: 9001, storeId: 7 })
An index on the pair of fields keeps that lookup cheap. The inStock field is a display copy refreshed from inventory. The invariant that two shoppers cannot buy the last unit stays in PostgreSQL, as in the data model lesson.
name, attributes
offers: up to 5,000 entries"] end subgraph R["Referenced"] P2["product 9001
name, attributes"] O1["offer 9001 / store 7"] O2["offer 9001 / store 8"] end Q1["Shopper reads one store's price"] --> P1 Q2["Shopper reads one store's price"] --> O1 P2 -.- O1 P2 -.- O2
Figure 1. The same data, embedded and referenced. How to read this: the dashed lines are references. In the embedded model one read returns all offers although the shopper wants one. In the referenced model the read is exactly one small document. The price you pay is a second lookup when a page needs both.
Wide-column: the store that understands partitions and order
Cassandra's documentation describes a "partitioned wide-column storage model with eventually consistent semantics". The unit of design is the primary key, which has two parts. The partition key decides which nodes hold a row; all rows with the same partition key live on the same set of replicas. The clustering columns that follow it order the rows inside the partition. The data modeling page says modelling is query-driven, queries are best designed to read a single table, joins are not supported, and data is duplicated across tables (denormalisation) so each query has its own table.
Figure 2. One partition holds ordered rows; the partition key is the entry point. How to read this: the arrows through R1, R2, R3 are the clustering order inside a single partition. The solid query lands on one partition. The dashed query has nothing to route by, so it is not a direct question for this table.
Realistic example: courier pings
Illustrative CQL, not run here; its shape follows the loads table in the CQL documentation, which uses a composite partition key and CLUSTERING ORDER BY (mtime DESC):
CREATE TABLE courier_pings (
courier_id uuid,
day date,
ts timestamp,
lat double,
lon double,
PRIMARY KEY ((courier_id, day), ts)
) WITH CLUSTERING ORDER BY (ts DESC);
SELECT ts, lat, lon FROM courier_pings
WHERE courier_id = ? AND day = ? LIMIT 20;
The query returns the 20 newest pings for one courier on one day, already in order. Putting day in the partition key is a design decision, not a given: the documentation says partitions must be "just right, not too big nor too small", and a partition key shared by too many rows creates "a hotspot for both reading and writing". Splitting by day bounds how large one courier's partition can grow.
The write path suits append-heavy workloads: the storage engine documentation describes a commit log and an in-memory memtable, flushed to immutable SSTables, with background compaction (see the storage engines lesson). The trade is that every new question may need a new table, and the consistency you get per query is a setting to choose (the levels in CAP and PACELC).
Graph: the store that understands relationships
Neo4j stores a labeled property graph. Nodes are the entities and carry zero or more labels (such as Shopper). Relationships connect exactly two nodes: each has a start node, an end node, a direction and exactly one type. Both nodes and relationships carry key-value properties. The query language, Cypher, draws the pattern: round brackets for nodes, square brackets and an arrow for relationships.
Neo4j's documentation says a graph suits projects whose questions "revolve around the relationship between elements", and contrasts that with relational JOIN operations. The example below, illustrative and not run here, asks which other shoppers used a device that shopper 42 used:
MATCH (a:Shopper {id: 42})-[:USED]->(:Device)<-[:USED]-(b:Shopper)
RETURN b.id
That is two hops, written as the shape of the answer. Variable depth is where graphs differ most from tables, and also where they bite. The Cypher manual's quantified path patterns take a range, such as {1,10}, and the manual warns that an unbounded pattern "can end up matching very large numbers of paths", with execution time that increases exponentially as the graph grows. Its advice is a finite upper bound and a more specific pattern.
The question test below picks between the families.
Figure 3. The question test. How to read this: take the questions in order and stop at the first yes. It is a starting point, not a verdict: the framework lesson's six questions about invariants, queries, shape, relationships, load and operations still apply, and every outcome has an operating cost.
Trade-offs at a glance
| Key-value (Redis) | Document (MongoDB) | Wide-column (Cassandra) | Graph (Neo4j) | |
|---|---|---|---|---|
| You design | Key names, expiry, persistence | Embed or reference, validation | One table per query, partition size | Labels, relationship types, hop bounds |
| Gain | Direct access, atomic single-key operations | Whole record in one read, flexible shape | Ordered range per partition, append-style writes | Multi-hop questions as one pattern |
| Give up | Asking by anything but the key | Joins; unbounded arrays | Ad hoc queries; duplicated copies | Predictable cost of open-ended traversal |
| Documented limit to know | 512 MB keys and values | 16 MiB documents, 100 nesting levels | Partitions "not too big nor too small" | Unbounded patterns grow exponentially |
| ShopStream verdict | Used (sessions, counters, cache) | Used (catalogue, offers) | Not needed yet | Not needed yet |
In practice: one store of each family for ShopStream?
This is an educational scenario on the running example, ShopStream, and nothing in it describes a real company. The numbers are the project's shared illustrative assumptions.
This is a realistic scenario, not a case history.
The situation
At Stage 3, ShopStream keeps orders, payments and stock in PostgreSQL, the catalogue in a document store, and a Redis cache from Stage 2. A team member proposes adding a wide-column store for courier pings and a graph store for "also bought" recommendations, so that every family has a job. The shared numbers give 58 checkouts per second at peak and about 2,300 browsing requests per second. They contain no courier count and no ping rate, so there is no measured load to justify a new write path.
Starting architecture
Figure 4. Three stores in use and two proposed. How to read this: solid boxes already run. The dashed boxes are the proposal. Each added store is a system to back up, monitor and keep in step with the rest.
Options considered
| Option | Gain | Cost | Wrong when |
|---|---|---|---|
| A. One store of each family | Every family has a job | Two more systems, no measured need | Nobody can name the query each serves |
| B. Keep the three that earn their place | No new system | Pings and recommendations get no ideal store | Measured ping volume outgrows the current store |
| C. Everything in PostgreSQL | One system | Sessions, counters and catalogue reads compete with checkout | The catalogue needs independent scaling |
The decision
Option B. Redis stays for sessions and counters, MongoDB for catalogue and offers (referenced, per Figure 1). Courier pings stay in the time-series store of Stage 3 and are written as time-partitioned rows, a shape that maps onto Figure 2 if a wide-column store is ever needed. "Also bought" is a co-purchase count computed in analytics, because the question is one hop (products bought by the same shopper), not a deep traversal. Revisit when ping volume is measured and exceeds what the current store absorbs, or when a recurring question needs several hops at interactive latency, such as linking accounts that share devices.
Rollout and verification
Ship the offers collection with its compound index first and compare its reads against the embedded model in a shadow period. Watch product-page latency, document sizes and the cache hit ratio. Record which Redis key patterns the application uses, so nobody has to discover them with a keyspace walk.
The 2 a.m. failure
During a checkout slowdown an engineer connects to the production Redis and runs KEYS cart:* to see which carts are affected. The documentation says KEYS blocks the server until all keys are returned and "may ruin performance when it is executed against large databases". While it runs, every other command waits, session reads time out, and the API falls back to PostgreSQL for everything the cache normally answers.
Figure 5. Why a debugging command becomes an outage. How to read this: the key-value store executes one command at a time, and the keyspace walk occupies it. The cost lands on the database behind the cache.
First three checks: whether a long-running command is executing on the Redis server (the product documentation says how to inspect it), the API's timeout and fallback counters, and PostgreSQL's connection and query load. Mitigation: wait for the command to finish or have the client disconnect it, then shed load while the cache refills (see cache stampede and hot keys). Follow-ups: use SCAN with MATCH and COUNT and tolerate duplicates, keep an index set of cart keys for the question being asked.
Architect's notes
- Ask what the store understands. If the answer is "only the key", every other question needs a second structure.
- Write the query next to the key or table design so a later reader can check it.
- Read the limits page of any store you adopt: 512 MB, 16 MiB, 100 levels and "not too big nor too small" are design constraints.
- A second store means a second consistency story; see Microservices decomposition and data ownership.
- Decide persistence per use: a cache and a system of record are different promises.
- Put a revisit trigger in every "not needed" verdict.
Common mistakes
- Adopting a family for completeness instead of a named query.
- Embedding an array that grows with the business (all stores, all reviews, all events).
- Using
KEYSin production code, or treatingSCANresults as exact. - Querying a wide-column table without the partition key.
- Leaving a graph traversal unbounded.
- Running Redis as the only copy of important data without choosing a persistence setting.
Key takeaways
- The families differ in what the store understands: a key, fields, partitions and order, or relationships.
- Each answers one kind of question directly; everything else costs a scan, a second structure or a redesign.
- Key-value trades query power for direct, atomic single-key access; persistence is a setting you choose.
- Document design is embed-or-reference: store together what is read together and bounded.
- Wide-column design starts from the query and the partition key; graph queries need bounded patterns.
- "Not needed" is a valid verdict; give it a measurable revisit trigger.
Practice exercises
Exercise 1 (30 min). Take ShopStream's cart, product catalogue, courier pings, order history and fraud checks. For each, name the question, the family that answers it directly, and the verdict (used, optional, not needed).
Exercise 2 (45 min). Redesign the offers model so that "all stores that have product 9001 in stock" is also a direct query. Say whether you reference, duplicate, or use a search engine, and what you give up.
Exercise 3 (1 hour). For courier pings, argue for a partition key of (courier_id, day) versus (courier_id) alone. What does each do to partition size and to the query "last 20 pings"?
Exercise 4 (1 hour). Write a decision record for adding a graph store for fraud detection. Include the query, the hop bound, who operates it, and the signal that would make you say no.
Related reading
- Databases Basics: the families in one table, and ACID.
- SQL vs NoSQL: A Decision Framework: six questions for choosing.
- ShopStream Stage 3: the data model: the verdicts in context.
- Storage Engines, Sharding and Replication
- CAP Theorem and PACELC and Consistency Models
Official documentation
- Redis: Keys and values and Persistence
- MongoDB manual: Data modeling and Limits and thresholds
- Apache Cassandra: Data modeling and Storage engine
- Neo4j: What is a graph database and Variable-length patterns