In lesson 01-02 we saw the four NoSQL families from very high up: what they are and what they are for, in a couple of paragraphs each. In the previous lesson we understood the reason behind the movement and took our first steps with MongoDB. Now it is time to come down to ground level.

"NoSQL" is not a technology: it is a label that groups together four radically different data models. A document database and a graph database resemble each other about as much as a spreadsheet and a subway map. Saying "we are going to use NoSQL" is as uninformative as saying "we are going to use a vehicle": the useful question is which one, and what for.

In this lesson we tour the four families with real, executable operation: document with MongoDB, key-value with Redis, column-family with Cassandra and graph with Neo4j. For each one we will see its data model, how it is queried, which products represent it, its strengths, its limits and —the thread running through the whole lesson— which piece of BiblioRed fits it. At the end, a comparison table and a decision tree for choosing.

Contents

  1. The four families at a glance
  2. Document databases: MongoDB
  3. Key-value databases: Redis
  4. Column-family databases: Cassandra
  5. Graph databases: Neo4j
  6. Specialized families this course does not revisit
  7. Comparison table of the four families
  8. Decision tree
  9. BiblioRed's final split
  10. Common mistakes and tips
  11. Exercises
  12. Conclusion

  1. The four families at a glance

The four can be ordered by the richness of the structure they understand: from the one that understands nothing about the value it stores to the one that understands the relationships between entities.

Family Unit of data What the engine "understands" Typical query
Key-value key → value pair Only the key. The value is opaque "Give me the value of this key"
Document JSON-like document The whole internal structure of the document "Give me the documents where score > 4"
Column-family Wide row in a column family The partition key and the ordering inside it "Give me the rows of this partition between these two dates"
Graph Node + relationship + properties The connections between entities "Give me what was read by those who read this"

And the four pieces of BiblioRed looking for a home:

  • Reviews and enriched catalog → rich, heterogeneous structure, queried by content → document.
  • Covers and portal sessions → access by key, very frequent, with expiration → key-value.
  • Activity log → very high write volume, queried by member and date range → column-family.
  • Recommendation engine → traversing relationships between readers and titles → graph.

A warning before we start, so this lesson is not misread: BiblioRed is not going to deploy four databases. It is going to deploy PostgreSQL and MongoDB. Redis, Cassandra and Neo4j appear here because they are the best way to understand what each family solves and because the day BiblioRed grows, it will recognize the moment. Confusing "understanding a family" with "installing it" is the fastest route to an ungovernable architecture.

  1. Document databases: MongoDB

2.1 Data model

The unit is the document: a JSON-like structure —stored in BSON, as we saw in 03-01— that allows nesting and arrays. Documents live in collections, which enforce no schema.

Three properties that define the family:

  1. The document is self-contained. Everything the application needs to display an entity fits inside it.
  2. The engine understands the interior. It can filter, sort, index and aggregate by any field, even a nested one or one inside an array. This is what separates a document database from a key-value store holding JSON: for the latter, the value is an opaque bag of bytes.
  3. Writing a document is atomic, with no need for a transaction.

Representative products: MongoDB (the dominant one and our reference), Couchbase, Amazon DocumentDB, RavenDB, Firestore. And an important note: PostgreSQL with the jsonb type does many of these things without ceasing to be relational; we will compare them in detail in lesson 03-04.

2.2 Query operators

We continue with the reviews collection we created in the previous lesson. A filter is a document; each field is a condition and several fields are combined with a logical AND. When the condition is not an equality, the value becomes a document with an operator starting with $.

Operator Meaning SQL equivalent
$eq Equal (implicit if you write the value directly) =
$ne Not equal <>
$gt / $gte Greater / greater or equal > / >=
$lt / $lte Less / less or equal < / <=
$in / $nin Is / is not in the list IN / NOT IN
$exists The field exists (or not) in the document no direct equivalent
$regex Matches a regular expression LIKE / ~
$and / $or / $not Logical combinations AND / OR / NOT
$size The array has N elements no direct equivalent
$all The array contains all these elements requires several EXISTS
// Reviews with a score greater than 3
db.reviews.find({ score: { $gt: 3 } })
[
  { _id: ObjectId('...c82'), book_title: 'The Map of Time', score: 5, member: { member_id: 14, name: 'Marta Alsina' }, ... },
  { _id: ObjectId('...c84'), book_title: 'The Pillars of the Earth', score: 5, member: { member_id: 16, name: 'Nuria Bastos' }, ... },
  { _id: ObjectId('...c85'), book_title: 'The Pillars of the Earth', score: 4, member: { member_id: 14, name: 'Marta Alsina' }, ... }
]
-- Relational equivalent
SELECT * FROM reviews WHERE score > 3;
// Reviews by two specific members
db.reviews.find({ "member.member_id": { $in: [15, 16] } })
[
  { _id: ObjectId('...c83'), member: { member_id: 15, name: 'Iván Pereda' }, score: 3, ... },
  { _id: ObjectId('...c84'), member: { member_id: 16, name: 'Nuria Bastos' }, score: 5, ... }
]
// Text search in the body of the review, case-insensitive
db.reviews.find({ text: { $regex: "cathedral", $options: "i" } })
[
  { _id: ObjectId('...c84'), book_title: 'The Pillars of the Earth',
    text: 'A thousand pages that fly by. Building the cathedral is gripping.', ... }
]
// Reviews that have a librarian reply
db.reviews.find({ librarian_reply: { $exists: true } })
[
  { _id: ObjectId('...c84'), librarian_reply: { name: 'South Branch Team', ... }, ... }
]

$exists has no equivalent in SQL, and for a deep reason: in a table, the column always exists; at most it holds NULL. In a collection, a field can simply not be there. It is the difference between "it has no value" and "this entity does not have that concept", and in the document world those are two different things.

// Reviews with BOTH tags at once
db.reviews.find({ tags: { $all: ["historical fiction", "gift idea"] } })
[
  { _id: ObjectId('...c84'), tags: [ 'historical fiction', 'gift idea', 'modern classic' ], ... }
]
// Combining with a logical OR: very good or heavily voted
db.reviews.find({ $or: [ { score: 5 }, { helpful_votes: { $gte: 10 } } ] })
[
  { _id: ObjectId('...c82'), score: 5, helpful_votes: 7, ... },
  { _id: ObjectId('...c84'), score: 5, helpful_votes: 12, ... }
]

2.3 Projection, sorting and limit

The second argument of find is the projection: which fields to return. 1 includes, 0 excludes.

db.reviews.find(
  { book_id: 412 },
  { _id: 0, "member.name": 1, score: 1, helpful_votes: 1 }
).sort({ helpful_votes: -1 }).limit(2)
[
  { member: { name: 'Nuria Bastos' }, score: 5, helpful_votes: 12 },
  { member: { name: 'Marta Alsina' }, score: 4, helpful_votes: 4 }
]
-- Relational equivalent
SELECT m.name, r.score, r.helpful_votes
FROM reviews r INNER JOIN members m ON m.member_id = r.member_id
WHERE r.book_id = 412
ORDER BY r.helpful_votes DESC
LIMIT 2;

Projection rules worth memorizing:

  • You cannot mix inclusions and exclusions in the same projection, with a single exception: _id: 0 can be combined with inclusions.
  • The _id is always returned unless you exclude it explicitly.
  • .sort(), .limit() and .skip() are chained on the cursor and are equivalent to ORDER BY, LIMIT and OFFSET. In sort, 1 is ascending and -1 descending.

2.4 Updates: $set, $push, $inc

Here a big difference from SQL appears: in MongoDB you do not rewrite the whole document, you apply update operators to parts of it.

Operator What it does
$set Assigns a value to a field (creates it if it does not exist)
$unset Removes a field from the document
$inc Increments (or decrements, with a negative) a number
$push Adds an element to an array
$addToSet Adds to an array only if it is not there already
$pull Removes from an array the elements meeting a condition
$currentDate Sets the server's current date
// A reader marks Nuria's review as helpful: counter +1
db.reviews.updateOne(
  { _id: ObjectId('66ab15c15c9e1b2f3d4a6c84') },
  { $inc: { helpful_votes: 1 } }
)
{
  acknowledged: true,
  matchedCount: 1,
  modifiedCount: 1
}
// Add a tag without duplicating it and record the edit
db.reviews.updateOne(
  { _id: ObjectId('66ab15c15c9e1b2f3d4a6c84') },
  {
    $addToSet: { tags: "middle ages" },
    $currentDate: { edited: true }
  }
)

db.reviews.findOne(
  { _id: ObjectId('66ab15c15c9e1b2f3d4a6c84') },
  { _id: 0, tags: 1, helpful_votes: 1, edited: 1 }
)
{
  tags: [ 'historical fiction', 'gift idea', 'modern classic', 'middle ages' ],
  helpful_votes: 13,
  edited: ISODate('2026-08-02T09:41:07.512Z')
}

Stop for a second on what has just happened. The edited field did not exist in any document in the collection and now it exists in one. No ALTER TABLE was needed, there was no lock and the other three documents never noticed. That is the flexible schema in its most practical form.

// Add a comment to a review's array of replies
db.reviews.updateOne(
  { _id: ObjectId('66ab14b25c9e1b2f3d4a6c82') },
  {
    $push: {
      comments: {
        member_id: 15,
        name: "Iván Pereda",
        text: "Agreed, the ending is the best part.",
        date: ISODate("2026-04-05T17:30:00Z")
      }
    }
  }
)
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
// updateMany: normalize a tag across the whole collection
db.reviews.updateMany(
  { tags: "historical fiction" },
  { $set: { "tags.$": "historical-fiction" } }
)
{ acknowledged: true, matchedCount: 4, modifiedCount: 4 }

The positional operator $ refers to the first element of the array that matched the filter. It is the way to modify one specific element without rewriting the whole array.

// Deleting
db.reviews.deleteOne({ _id: ObjectId('66ab15c15c9e1b2f3d4a6c85') })
{ acknowledged: true, deletedCount: 1 }

Beware of a detail that has caused real incidents: deleteMany({}) with an empty filter deletes the entire collection and asks for no confirmation. In SQL, DELETE FROM table does the same, but at least there is a transaction that can be undone with ROLLBACK. Here, unless you are inside an explicit transaction, there is none.

2.5 The first aggregation pipeline

Summary queries —what we did in lesson 02-05 with GROUP BY— are done in MongoDB with the aggregation framework: a pipeline of stages, where each stage receives documents, transforms them and passes them to the next.

Goal: the average score and the number of reviews for each book, ordered from best to worst.

-- What we would do in PostgreSQL (lesson 02-05)
SELECT book_id, COUNT(*) AS total, ROUND(AVG(score), 2) AS average
FROM reviews
GROUP BY book_id
HAVING COUNT(*) >= 1
ORDER BY average DESC;
db.reviews.aggregate([
  { $match: { spoiler: false } },
  { $group: {
      _id: "$book_id",
      title: { $first: "$book_title" },
      total: { $sum: 1 },
      average: { $avg: "$score" },
      votes: { $sum: "$helpful_votes" }
  }},
  { $sort: { average: -1 } }
])
[
  { _id: 412, title: 'The Pillars of the Earth', total: 1, average: 5, votes: 13 },
  { _id: 331, title: 'The Map of Time', total: 2, average: 4, votes: 9 }
]

Stage-by-stage translation, which is the best way to understand it:

Pipeline stage SQL equivalent What it does here
$match WHERE Keeps only the reviews without spoilers
$group GROUP BY Groups by book_id and computes the accumulations
$sort ORDER BY Sorts by average descending
$project The SELECT list Chooses and computes the output fields
$limit LIMIT Cuts the result
$unwind (has no equivalent) Turns each element of an array into a document of its own
$lookup LEFT JOIN Brings in documents from another collection

Two syntax conventions to fix in your mind:

  • "$field" with a leading dollar means "the value of that field", not the literal string. "$score" is the number; "score" would be the text.
  • In $group, _id is the grouping key, not an identifier. _id: "$book_id" is literally GROUP BY book_id. If you put _id: null, you group everything into a single row, which is SELECT AVG(...) FROM table with no GROUP BY.

One more example, with $unwind, which has no clean counterpart in SQL: the most used tags.

db.reviews.aggregate([
  { $unwind: "$tags" },
  { $group: { _id: "$tags", uses: { $sum: 1 } } },
  { $sort: { uses: -1, _id: 1 } },
  { $limit: 5 }
])
[
  { _id: 'historical-fiction', uses: 3 },
  { _id: 'gift idea', uses: 1 },
  { _id: 'middle ages', uses: 1 },
  { _id: 'modern classic', uses: 1 }
]

$unwind has "unfolded" Nuria's review, which had four tags, into four documents identical except for the tag. Then $group counts. It is exactly the work that in the relational model would be done by the intermediate review_tags table we avoided by embedding the array.

2.6 Strengths, limits and fit at BiblioRed

Strengths

  • Heterogeneous, nested structures with no auxiliary tables and no null columns.
  • The schema evolves without a migration.
  • Reading a complete entity in a single access.
  • A rich query language: filters, aggregation, indexes on any field.

Limits

  • Crossing collections ($lookup) is expensive and must not be the norm.
  • No declarative referential integrity.
  • The 16 MB per document limit bounds what you can embed.
  • Multi-document transactions exist, but they cost performance; the design should minimize the need for them.

Fit at BiblioRed: the reviews and the enriched catalog. They are self-contained, heterogeneous entities that are read whole and change shape frequently. It is the perfect fit, and that is why MongoDB is the NoSQL database BiblioRed is actually going to deploy.

  1. Key-value databases: Redis

3.1 Data model

The simplest model there is: a giant distributed dictionary. One key, one value. The engine knows nothing about the content of the value —to it, it is a string or a structure, but not something that can be filtered by its interior—.

That radical renunciation buys one thing: speed. Redis keeps all the data in memory and answers in microseconds, with typical figures of more than 100,000 operations per second on a modest machine.

Representative products: Redis (our reference), Valkey (its open source fork), Memcached (simpler still), Amazon DynamoDB (key-value with document capabilities), etcd (cluster configuration).

3.2 Basic operations and expiration

# Start a Redis in Docker and enter its client
docker run -d --name redis-biblioRed -p 6379:6379 redis:7
docker exec -it redis-biblioRed redis-cli
127.0.0.1:6379>
// Store and retrieve the ready-to-render record of a catalog material
SET catalog:MAT-0331:record "{\"title\":\"The Map of Time\",\"cover\":\"/img/0331.webp\"}"
GET catalog:MAT-0331:record
OK
"{\"title\":\"The Map of Time\",\"cover\":\"/img/0331.webp\"}"

The star operation of this family is expiration, and it deserves its own section because it is what sets it apart from everything else:

// Store with a 1-hour expiration (3600 seconds)
SET catalog:MAT-0331:record "{...}" EX 3600

// Check how much life it has left
TTL catalog:MAT-0331:record
OK
(integer) 3597
// Portal session: expires by itself after 30 minutes of inactivity
SET session:8f3a2b1c "{\"member_id\":14,\"name\":\"Marta Alsina\",\"branch\":1}" EX 1800

// Every request from the member renews the window
EXPIRE session:8f3a2b1c 1800
TTL session:8f3a2b1c
OK
(integer) 1
(integer) 1800

Why expiration is a first-class function. In a relational database, "deleting whatever has expired" is a scheduled job somebody has to write, watch over and run; and while it does not run, the expired data keeps taking up space and polluting queries. In Redis, expiration is a property of the key: the system removes it by itself, with no intervention. For a cache —where stale data is not an error, just useless— and for sessions —where the data has to disappear for security reasons—, that difference is the very reason for choosing the tool.

3.3 Data structures

Redis is not only strings. Its real value lies in its native structures, each with atomic operations of its own.

Structure What it is Use at BiblioRed
String A string or a number Cached catalog record, counters
List An ordered list, accessed at the ends Job queue: covers pending resizing
Set An unordered collection with no duplicates Materials available right now at the North branch
Sorted Set A collection ordered by score Ranking of the most viewed of the week
Hash A dictionary of fields inside a key Session data, field by field
Counter A String with atomic operations Today's views of a material
// HASH: the session, field by field, without rewriting the whole object
HSET session:8f3a2b1c member_id 14 name "Marta Alsina" branch 1 language es
HGET session:8f3a2b1c name
HGETALL session:8f3a2b1c
(integer) 4
"Marta Alsina"
1) "member_id"
2) "14"
3) "name"
4) "Marta Alsina"
5) "branch"
6) "1"
7) "language"
8) "es"
// COUNTER: atomic increment, with no race conditions
INCR views:MAT-0331:2026-08-02
INCR views:MAT-0331:2026-08-02
INCR views:MAT-0331:2026-08-02
GET views:MAT-0331:2026-08-02
(integer) 1
(integer) 2
(integer) 3
"3"

INCR is atomic: if a thousand members open the record at the same time, the counter ends up at exactly a thousand. Doing this in SQL requires UPDATE ... SET n = n + 1 with its row lock and its transactional cost; here it is a microsecond operation.

// SORTED SET: ranking of the most viewed materials of the week
ZINCRBY ranking:week:31 3 "MAT-0331"
ZINCRBY ranking:week:31 8 "MAT-0412"
ZINCRBY ranking:week:31 5 "MAT-0801"
ZREVRANGE ranking:week:31 0 2 WITHSCORES
(integer) 3
(integer) 8
(integer) 5
1) "MAT-0412"
2) "8"
3) "MAT-0801"
4) "5"
5) "MAT-0331"
6) "3"

A ranking that is always sorted, updated on the spot and queryable in constant time. The relational alternative is an ORDER BY COUNT(*) DESC over millions of rows every time somebody looks at the home page.

// SET: which materials are available right now at the North branch
SADD available:branch:2 "EJ-3081" "EJ-3084" "EJ-3090"
SISMEMBER available:branch:2 "EJ-3084"
SCARD available:branch:2
SREM available:branch:2 "EJ-3084"
SCARD available:branch:2
(integer) 3
(integer) 1
(integer) 3
(integer) 1
(integer) 2

3.4 Strengths, limits and fit at BiblioRed

Strengths: microsecond latency, atomic operations on structures, native expiration, a model that is trivial to understand and operate.

Limits: you can only query by key (there is no "give me everything with a score of 5"); the data lives in memory, so RAM is both the ceiling and the cost; persistence exists (snapshots and an operation log) but it is meant for recovery, not as a primary store.

Fit at BiblioRed: caching the catalog records and covers —avoiding a trip to MongoDB on every visit to a heavily visited page— and portal sessions —which have to expire by themselves—. Never as the source of truth: if Redis is emptied, BiblioRed must carry on working, only more slowly. That is the acid test of a well-conceived cache.

  1. Column-family databases: Cassandra

4.1 Data model

The name "columnar" is misleading. Here it does not mean "column-oriented for analytics" (that would be ClickHouse or Amazon Redshift), but column families: rows identified by a key, physically grouped together, where each row can have different and very numerous columns.

The two pieces you have to understand are:

  • Partition key: it decides which node the row lives on. All the rows with the same partition key are together on the same node and contiguous on disk.
  • Clustering key: it decides the order of the rows within the partition.
flowchart TD
    subgraph N1["Node 1"]
        P1["Partition member_id=14<br/>ordered by date DESC<br/>-> 8,400 contiguous events"]
    end
    subgraph N2["Node 2"]
        P2["Partition member_id=15<br/>ordered by date DESC<br/>-> 6,100 contiguous events"]
    end
    subgraph N3["Node 3"]
        P3["Partition member_id=16<br/>ordered by date DESC<br/>-> 9,700 contiguous events"]
    end
    Q["Query:<br/>activity of member 15<br/>over the last month"] --> N2

That physical contiguity is the whole secret: reading "the last 50 events of member 15" is a sequential disk read inside a single partition on a single node. There is no scattered lookup, no JOIN, no coordination between machines.

Representative products: Apache Cassandra (our reference), ScyllaDB (compatible and faster), Apache HBase, Google Bigtable (the 2006 paper that spawned the family), Amazon Keyspaces.

4.2 CQL: like SQL, different rules

Cassandra is queried with CQL, which is written almost like SQL. That familiarity is a friendly trap: the syntax is similar, the rules are not.

docker run -d --name cassandra-biblioRed -p 9042:9042 cassandra:5
docker exec -it cassandra-biblioRed cqlsh
-- A "keyspace" is the approximate equivalent of a database
CREATE KEYSPACE bibliored
WITH replication = { 'class': 'SimpleStrategy', 'replication_factor': 3 };

USE bibliored;

-- The catalog activity table
CREATE TABLE activity_by_member (
    member_id   int,
    event_date  timestamp,
    event_id    uuid,
    type        text,
    term        text,
    material_id text,
    branch_id   int,
    PRIMARY KEY ((member_id), event_date, event_id)
) WITH CLUSTERING ORDER BY (event_date DESC, event_id ASC);

Read that primary key carefully, because it contains the whole lesson:

  • (member_id), in parentheses of its own, is the partition key. All of a member's events live together.
  • event_date and event_id are the clustering keys: they order things within the partition, from most recent to oldest.
  • event_id is there to break ties: without it, two events in the same millisecond would overwrite each other.
INSERT INTO activity_by_member (member_id, event_date, event_id, type, term, branch_id)
VALUES (15, '2026-08-02 09:14:02', uuid(), 'search', 'jules verne', 2);

INSERT INTO activity_by_member (member_id, event_date, event_id, type, material_id, branch_id)
VALUES (15, '2026-08-02 09:14:31', uuid(), 'record', 'MAT-0331', 2);

SELECT event_date, type, term, material_id
FROM activity_by_member
WHERE member_id = 15
LIMIT 5;
 event_date                      | type   | term        | material_id
---------------------------------+--------+-------------+-------------
 2026-08-02 09:14:31.000000+0000 | record |        null |    MAT-0331
 2026-08-02 09:14:02.000000+0000 | search | jules verne |        null

(2 rows)

Now, the rules that break the expectations of anyone coming from SQL:

-- This FAILS
SELECT * FROM activity_by_member WHERE term = 'jules verne';
InvalidRequest: Error from server: code=2200 [Invalid query]
message="Cannot execute this query as it might involve data filtering and thus
may have unpredictable performance. If you want to execute this query despite the
performance unpredictability, use ALLOW FILTERING"

Cassandra refuses to run a query it cannot resolve efficiently. There is no free WHERE on any column, no JOIN, no subqueries, and ORDER BY can only follow the clustering order already defined. And that error, far from being an annoying limitation, is one of the product's best design decisions: it warns you in development that your query does not scale, instead of letting you find out in production with ten million rows.

4.3 Query-driven design

From the previous restriction comes the family's central principle:

In Cassandra you do not design a data model and then query it. You start from the list of queries and create one table per query, duplicating the data as many times as necessary.

If BiblioRed also needs "the most searched terms of a day", you do not add an index: you create another table, with a different partition key, fed by the same write.

CREATE TABLE activity_by_day (
    day         date,
    event_date  timestamp,
    event_id    uuid,
    member_id   int,
    type        text,
    term        text,
    PRIMARY KEY ((day), event_date, event_id)
) WITH CLUSTERING ORDER BY (event_date DESC, event_id ASC);

SELECT term, COUNT(*) FROM activity_by_day
WHERE day = '2026-08-02' AND type = 'search'
GROUP BY day
ALLOW FILTERING;

To somebody coming from module 5 —which we have not seen yet— this will look like heresy: it is pure and simple duplication. And it is, deliberately. In Cassandra disk is cheap, writes are dirt cheap and what is expensive is the inefficient read. Duplicating is the strategy, not the mistake. It is the most extreme expression of the principle of modeling from the queries that we will study thoroughly in lesson 03-03.

4.4 Strengths, limits and fit at BiblioRed

Strengths: extraordinarily fast and sustained writes; linear scaling (twice the nodes, roughly twice the capacity); no primary node —every node accepts writes, so there is no single point of failure—; built-in cross-datacenter replication; native per-row expiration (TTL).

Limits: rigid queries, tied to the key design; no JOINs and no free aggregations; massive duplication that the application has to keep consistent; running a cluster is not trivial; a new query may demand a new table and a reprocessing of the entire history.

Fit at BiblioRed: the activity log. Seventeen million events a year, continuous writes, reads by member and date range, with no need for referential integrity and with value that decays over time. It is the family's canonical use case.

That said, let's stay consistent with what we said in 03-01: 17 million documents a year do not justify deploying and operating a Cassandra cluster. MongoDB absorbs them without difficulty using the bucket pattern we will see in 03-03. Cassandra would come into play if BiblioRed went from a municipal network to a regional one with hundreds of millions of events. Knowing the criterion is what lets you make that decision the day it arrives.

  1. Graph databases: Neo4j

5.1 Data model

Three elements and that is it:

  • Nodes: the entities (a member, a book, an author). They carry one or more labels (:Member, :Book) and properties.
  • Relationships: the connections between nodes. They have a type (READ, WROTE), a direction and properties of their own.
  • Properties: key-value pairs on nodes and relationships.

The decisive difference from the relational model: in a relational database, a relationship is computed at query time by comparing foreign key values; in a graph database, the relationship is materialized as a physical pointer. Traversing it does not cost a lookup, it costs following a reference. That is called index-free adjacency, and it is the reason deep traversals are so fast.

Representative products: Neo4j (our reference), Amazon Neptune, ArangoDB, JanusGraph, Memgraph.

5.2 Cypher: MATCH, WHERE, RETURN

Cypher reads like a drawing. () is a node, -[]-> is a directed relationship.

docker run -d --name neo4j-biblioRed -p 7474:7474 -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/biblioRed2026 neo4j:5
docker exec -it neo4j-biblioRed cypher-shell -u neo4j -p biblioRed2026
// Create the member and book nodes
CREATE (m:Member {member_id: 14, name: 'Marta Alsina', branch: 1}),
       (i:Member {member_id: 15, name: 'Iván Pereda', branch: 2}),
       (n:Member {member_id: 16, name: 'Nuria Bastos', branch: 3}),
       (b1:Book {book_id: 331, title: 'The Map of Time', isbn: '9788401339097'}),
       (b2:Book {book_id: 412, title: 'The Pillars of the Earth', isbn: '9788401337208'}),
       (b3:Book {book_id: 508, title: 'The Shadow of the Lighthouse', isbn: '9788401338441'}),
       (b4:Book {book_id: 613, title: 'Vallmar Notebooks', isbn: '9788401339554'})
Added 7 nodes, Created 7 labels, Set 23 properties
// Create the reading relationships, with the rating as a property of the relationship
MATCH (m:Member {member_id: 14}), (i:Member {member_id: 15}), (n:Member {member_id: 16}),
      (b1:Book {book_id: 331}), (b2:Book {book_id: 412}),
      (b3:Book {book_id: 508}), (b4:Book {book_id: 613})
CREATE (m)-[:READ {score: 5, date: date('2026-03-14')}]->(b1),
       (m)-[:READ {score: 4, date: date('2026-04-02')}]->(b2),
       (i)-[:READ {score: 3, date: date('2026-03-22')}]->(b1),
       (i)-[:READ {score: 5, date: date('2026-05-08')}]->(b3),
       (n)-[:READ {score: 5, date: date('2026-03-24')}]->(b2),
       (n)-[:READ {score: 4, date: date('2026-06-01')}]->(b4)
Created 6 relationships, Set 12 properties
// A simple query: what Marta has read
MATCH (m:Member {name: 'Marta Alsina'})-[r:READ]->(b:Book)
RETURN b.title AS title, r.score AS score
ORDER BY r.score DESC
+-------------------------------------------+
| title                      | score        |
+-------------------------------------------+
| "The Map of Time"          | 5            |
| "The Pillars of the Earth" | 4            |
+-------------------------------------------+

5.3 The multi-hop traversal: the recommendation

This is where the family earns its keep. BiblioRed's business question is: "readers like you also read". Formally: starting from a member, go to the books they have read, from there to the other members who also read them, and from there to the books those members read and ours did not.

That is three hops through the graph.

flowchart LR
    M["Member<br/>Marta Alsina"] -->|READ| B1["Book<br/>The Map of Time"]
    M -->|READ| B2["Book<br/>The Pillars of the Earth"]
    I["Member<br/>Ivan Pereda"] -->|READ| B1
    I -->|READ| B3["Book<br/>The Shadow of the Lighthouse<br/>* RECOMMENDED"]
    N["Member<br/>Nuria Bastos"] -->|READ| B2
    N -->|READ| B4["Book<br/>Vallmar Notebooks<br/>* RECOMMENDED"]
MATCH (me:Member {member_id: 14})-[:READ]->(:Book)<-[:READ]-(other:Member)-[:READ]->(suggestion:Book)
WHERE NOT (me)-[:READ]->(suggestion)
  AND me <> other
RETURN suggestion.title       AS recommendation,
       COUNT(DISTINCT other)  AS similar_readers,
       COLLECT(DISTINCT other.name) AS who
ORDER BY similar_readers DESC
+---------------------------------------------------------------------+
| recommendation                 | similar_readers | who              |
+---------------------------------------------------------------------+
| "The Shadow of the Lighthouse" | 1               | ["Iván Pereda"]  |
| "Vallmar Notebooks"            | 1               | ["Nuria Bastos"] |
+---------------------------------------------------------------------+

Look at the first line of the MATCH: it is literally the drawing of the path. You read "I read a book that was read by another who read a suggestion" by following the arrows. And WHERE NOT (me)-[:READ]->(suggestion) is a path anti-pattern: it discards what Marta has already read, just as the anti-join of lesson 02-04 discarded rows with NOT EXISTS.

5.4 Why this is not a JOIN problem

The SQL equivalent of that query, over module 2's schema, would be roughly:

SELECT b2.title, COUNT(DISTINCT l2.member_id) AS similar_readers
FROM loans l1
INNER JOIN copies c1 ON c1.copy_id   = l1.copy_id
INNER JOIN copies c2 ON c2.book_id   = c1.book_id
INNER JOIN loans  l2 ON l2.copy_id   = c2.copy_id AND l2.member_id <> 14
INNER JOIN loans  l3 ON l3.member_id = l2.member_id
INNER JOIN copies c3 ON c3.copy_id   = l3.copy_id
INNER JOIN books  b2 ON b2.book_id   = c3.book_id
WHERE l1.member_id = 14
  AND NOT EXISTS (
      SELECT 1 FROM loans lx
      INNER JOIN copies cx ON cx.copy_id = lx.copy_id
      WHERE lx.member_id = 14 AND cx.book_id = b2.book_id
  )
GROUP BY b2.title
ORDER BY similar_readers DESC;

Six JOINs and a correlated subquery for three hops. It works, it is correct and with BiblioRed's data it will answer fast. The problem is the curve:

Hops In SQL In a graph
1 (what I have read) 1 JOIN, immediate Immediate
2 (who else read it) 3 JOINs, fast Immediate
3 (what they read) 6 JOINs, acceptable Fast
4 (and what their circle read) 9 JOINs, starts to hurt Fast
5+ Unfeasible in practice Still feasible

Every hop in SQL adds one or more JOINs, and the cost of each JOIN depends on the total size of the tables. In a graph, each hop follows pointers from the nodes you already have: the cost depends on the number of neighbors, not on the size of the database. That is why depth is free in a graph and extremely expensive in SQL.

Practical rule: up to two hops, SQL. From three onwards, and above all if the depth is variable, a graph.

5.5 Strengths, limits and fit at BiblioRed

Strengths: deep traversals at almost constant cost; the model is drawn the same way it is thought; relationships with properties of their own; excellent for recommendations, fraud detection, social networks, dependency analysis and family or organizational trees.

Limits: a bad choice for massive aggregations over all the nodes; horizontal scaling harder than in the other families (splitting a graph across machines without cutting relationships is a hard problem); a smaller ecosystem and talent pool; it is usually a secondary database fed from the main one.

Fit at BiblioRed: the recommendation engine. And with the same honesty as before: BiblioRed is not going to deploy it for now; with 12,000 members, a nightly SQL query that precomputes recommendations and leaves them in a MongoDB collection solves the problem with one fewer piece to operate. Neo4j comes in when the recommendations have to be interactive, personalized and of variable depth.

  1. Specialized families this course does not revisit

Beyond the big four there are three families that appear constantly in real architectures. We name them so you recognize them, without developing them.

Search engines (Elasticsearch, OpenSearch, Solr). Technically they are document stores, but their engine is built on an inverted index —a structure that goes from each word to the documents containing it—. That gives them relevance ranking, typo tolerance, match highlighting, synonyms and facets. If BiblioRed wanted a professional-grade catalog search, with suggestions as you type and correcting "Jules Verme" to "Jules Verne", this would be the tool. MongoDB has text indexes that cover the basics; a search engine covers the demanding cases.

Time series (InfluxDB, TimescaleDB, Prometheus). Optimized for data of the form (instant, measurement, value) with continuous writes and queries by window. They compress extraordinarily well because contiguous values resemble each other, and they bring interval aggregation and automatic expiration of old data. TimescaleDB is a PostgreSQL extension, which makes it especially convenient if you already have the relational database. BiblioRed's activity log could also live here.

Vector (Pinecone, Weaviate, Qdrant, pgvector). They store numeric vectors —representations of meaning generated by language models— and answer "what is most similar to this?" through nearest-neighbor search. They are the piece that would let BiblioRed answer "I want something like The Map of Time but shorter" without a single word matching. It is the youngest family and the fastest growing.

  1. Comparison table of the four families

Dimension Document Key-value Column-family Graph
Reference product MongoDB Redis Cassandra Neo4j
Unit of data BSON document key → value pair Wide row in a partition Node and relationship
Does the engine see the interior? Yes, completely No, opaque Yes, by columns Yes, nodes and edges
Language Queries + aggregation Commands (GET, SET…) CQL Cypher
Query by any field Yes No Only with the defined key Yes
Aggregations Yes (pipeline) Limited Very limited Yes, but not its strength
Writes Fast Very fast Extremely fast Moderate
Horizontal scaling Good Good Excellent and linear Difficult
Schema Flexible Nonexistent Fixed per table, flexible per row Flexible
Relationships between entities Manual references No No Its whole reason for being
Persistence Disk Memory (with dumps) Disk Disk
Native expiration Yes (TTL index) Yes, central Yes (per row) No
Fit at BiblioRed Reviews and catalog Cache and sessions Activity log Recommendations
Deployed at BiblioRed Yes Not for now Not for now Not for now

  1. Decision tree

flowchart TD
    A["What do I need to store?"] --> B{"Are the relationships<br/>between entities the main<br/>object of the query?"}
    B -->|Yes, 3+ hops| G["GRAPH - Neo4j<br/>recommendations, fraud, networks"]
    B -->|No| C{"Do I always access<br/>by a known key?"}
    C -->|Yes, and it is ephemeral| KV["KEY-VALUE - Redis<br/>cache, sessions, counters"]
    C -->|No| D{"Huge write volume<br/>with queries known<br/>in advance?"}
    D -->|Yes| CO["COLUMN-FAMILY - Cassandra<br/>events, telemetry, logs"]
    D -->|No| E{"Rich, heterogeneous<br/>structure queried<br/>by content?"}
    E -->|Yes| DOC["DOCUMENT - MongoDB<br/>catalogs, profiles, content"]
    E -->|No| REL["RELATIONAL - PostgreSQL<br/>the default answer"]

Pay attention to the last branch: when none of the four families fits clearly, the right answer is relational. That is not a consolation prize, it is professional judgment. We will develop it with more arguments in lesson 03-04.

  1. BiblioRed's final split

Bringing together what was decided in 01-02 with what we have seen here:

Piece of the system Ideal family BiblioRed's real decision Why
Members, loans, reservations, copies Relational PostgreSQL Integrity, transactions, unpredictable queries
Reader reviews Document MongoDB Changing structure, self-contained aggregate
Enriched catalog Document MongoDB Heterogeneous metadata per material type
Activity log Column-family MongoDB with the bucket pattern The volume does not yet justify a Cassandra cluster
Record and session cache Key-value Deferred There is no latency problem to solve yet
Recommendations Graph Nightly precomputation in SQL → MongoDB 12,000 members do not justify one more database

Two databases, four needs covered and a written criterion for when each deferral stops being reasonable. That deliberate mix of engines is called polyglot persistence; we will name it formally in lesson 03-04 and it will be the complete case study of 08-03.

Common Mistakes and Tips

Mistake 1: using a graph database because the domain "has relationships". Every domain has relationships; that is what the relational model is for. The graph wins when a variable-depth traversal is the main query. A couple of JOINs do not justify another database.

Mistake 2: using Redis as the primary store. It is memory: if the process goes down and persistence was not properly configured, data is lost. Rule: if emptying Redis loses information you cannot rebuild, you are using it wrongly.

Mistake 3: writing CQL as if it were SQL. The syntax is deceptive. In Cassandra there is no JOIN, WHERE only works on the key columns and ORDER BY is tied to the clustering order. If you find yourself writing ALLOW FILTERING to make a query work, the problem is not the query: it is the data model.

Mistake 4: confusing "columnar" with "analytical". Cassandra (column families) is meant for transactional operation at large scale. ClickHouse or Redshift (columnar storage) are meant for OLAP analysis, which we saw in 01-02. They share an adjective and not a purpose.

Mistake 5: using MongoDB's $lookup as if it were a normal JOIN. It works, but it gives away that the document design is not the right one. In the next lesson we will see it cataloged as an anti-pattern.

Tip 1: design your Redis keys with a hierarchical convention. entity:identifier:aspect, for example catalog:MAT-0331:record or session:8f3a2b1c. Without that discipline, in six months nobody will know what is inside a key called m331.

Tip 2: in Cassandra, write the list of queries before the CREATE TABLE. It is the reverse of the relational order and it is mandatory. An unforeseen query can cost you a new table and a reprocessing of the entire history.

Tip 3: try the four families in Docker. An afternoon spinning up Redis, Cassandra and Neo4j in containers and tinkering with twenty documents teaches more than any written comparison. And docker rm -f leaves everything as it was.

Tip 4: every database added is a database to operate. Backups, upgrades, monitoring, permissions, an expert on the team. Before adding the third one, ask yourself whether the problem is not solved with an index in the first.

Exercises

Exercise 1

BiblioRed wants a "Top rated of the month" page with, for each book, its average score and its number of reviews, considering only reviews from April 2026 and showing only the books with an average of 4 or higher. Write the MongoDB aggregation pipeline and its SQL equivalent, and explain which SQL clause each stage corresponds to.

Exercise 2

For each of these four new BiblioRed needs, choose the most suitable NoSQL family and justify it in two or three sentences:

  • (a) A counter of "copies available right now" per branch, queried on every load of the home page and which has to be extremely fast.
  • (b) A history of every status change of every copy (available, on_loan, in_repair, withdrawn), with around 300,000 changes a year, queried as "the history of this copy".
  • (c) Detecting informal book clubs: groups of members who repeatedly coincide in reading the same titles in the same weeks.
  • (d) Author records with a biography, photo, awards, external links and bibliography, where each author has a different set of data available.

Exercise 3

Design the Cassandra table that would answer this BiblioRed query: "show me the last 20 activity events of a specific branch, from most recent to oldest". Write the CREATE TABLE, state which is the partition key and which the clustering key, and answer: why can this table not also answer "the last 20 events of a specific member"?

Solutions

Solution 1

db.reviews.aggregate([
  { $match: {
      date: { $gte: ISODate("2026-04-01T00:00:00Z"),
              $lt:  ISODate("2026-05-01T00:00:00Z") }
  }},
  { $group: {
      _id: "$book_id",
      title:   { $first: "$book_title" },
      average: { $avg: "$score" },
      total:   { $sum: 1 }
  }},
  { $match: { average: { $gte: 4 } } },
  { $project: {
      _id: 0,
      book_id: "$_id",
      title: 1,
      average: { $round: ["$average", 2] },
      total: 1
  }},
  { $sort: { average: -1, total: -1 } }
])
[
  { title: 'The Pillars of the Earth', total: 1, book_id: 412, average: 4 }
]
SELECT r.book_id, b.title, ROUND(AVG(r.score), 2) AS average, COUNT(*) AS total
FROM reviews r
INNER JOIN books b ON b.book_id = r.book_id
WHERE r.date >= '2026-04-01' AND r.date < '2026-05-01'
GROUP BY r.book_id, b.title
HAVING AVG(r.score) >= 4
ORDER BY average DESC, total DESC;
Stage SQL clause Comment
$match (1st) WHERE Filters before grouping. Putting it first is essential: it reduces the volume the later stages process and it allows indexes to be used.
$group GROUP BY _id is the grouping key. $first retrieves the title, which is duplicated in every review —and that is why no JOIN with books is needed, unlike in the SQL—.
$match (2nd) HAVING Filters after grouping, on the computed result. The same stage plays two different roles depending on where it is placed: that is the logical order of execution from lesson 02-05.
$project The SELECT list Chooses and shapes the output; $round is SQL's ROUND.
$sort ORDER BY Final ordering.

Solution 2

(a) Counter of available copies → key-value (Redis). Access is always by a known key (available:branch:2), reads are very frequent, the value is tiny and a few seconds of lag are tolerable. INCR/DECR are atomic and answer in microseconds, whereas a COUNT(*) over copies on every home page load is an avoidable waste.

(b) History of copy statuses → column-family (Cassandra). It is a series of immutable events, with continuous writes and a query perfectly known in advance: by copy and ordered by date. Partition key copy_id, clustering key date descending. That said, and to stay consistent with the criterion of section 9: 300,000 changes a year is not much, and a PostgreSQL table with an index on (copy_id, date) would solve this perfectly well for many years.

(c) Detecting informal book clubs → graph (Neo4j). Looking for groups of members densely connected to each other through the titles they share is community detection, a classic graph problem that requires variable-depth traversals. In SQL it would be a repeated self-join of increasing cost; in Cypher it is a path pattern, and Neo4j even ships community algorithms already implemented.

(d) Author records → document (MongoDB). A rich, heterogeneous structure (one author has three awards and no photo, another five links and a long biography), read whole in one go to render the record and queried by its content ("authors born in Vallmar"). It is exactly the same argument that took the catalog to MongoDB, so it also reuses a database BiblioRed already operates.

Solution 3

CREATE TABLE activity_by_branch (
    branch_id   int,
    event_date  timestamp,
    event_id    uuid,
    member_id   int,
    type        text,
    term        text,
    material_id text,
    PRIMARY KEY ((branch_id), event_date, event_id)
) WITH CLUSTERING ORDER BY (event_date DESC, event_id ASC);

SELECT event_date, member_id, type, term, material_id
FROM activity_by_branch
WHERE branch_id = 2
LIMIT 20;
  • Partition key: (branch_id). All of a branch's activity lives together on the same node.
  • Clustering keys: event_date (descending, so that the last 20 are the first 20 of the partition) and event_id (to break ties between simultaneous events).

Why it does not work for querying by member. Because member_id is not part of the key: it is an ordinary column. Cassandra needs to know the partition key in order to know which node to go to; without it, it would have to ask every node and filter each entire partition, which is precisely what the engine refuses to do without ALLOW FILTERING.

And this illustrates the family's central rule: one table per query. If BiblioRed needs both views, it keeps both tables —activity_by_member and activity_by_branch— and writes each event into both. The duplication is not a flaw in the design: it is the design.

An additional warning a good designer would see: if a branch generates millions of events, its partition will grow without limit, and unbounded partitions are a known problem in Cassandra. The usual solution is a composite partition key that includes the period, ((branch_id, month), event_date, event_id), bounding each partition to a month. It is the same idea as the bucket pattern we will study in the next lesson.

Conclusion

We have toured the four NoSQL families with real operation:

  • Document (MongoDB): BSON documents nested in schemaless collections. Filters with $gt, $in, $regex, $exists, $all; projection with 1/0; sort, limit, skip; partial updates with $set, $inc, $push, $addToSet and the positional $ operator; and the aggregation pipeline as the equivalent of GROUP BY, where $match is WHERE before grouping and HAVING after it, $group groups with _id as the key and $unwind unfolds arrays. Fit: reviews and catalog.
  • Key-value (Redis): the engine does not see inside the value and in exchange it answers in microseconds. SET/GET/EXPIRE/TTL, plus native structures —lists, sets, sorted sets, hashes and atomic counters with INCR—. Expiration is a first-class function, and that is why it is the natural tool for caching and sessions. Never as the source of truth.
  • Column-family (Cassandra): wide rows grouped by partition key and ordered by clustering key; CQL is written like SQL but forbids JOINs, subqueries and free WHEREs —the ALLOW FILTERING error is a design warning, not a nuisance—. The principle is one table per query, with deliberate duplication. Fit: very high volume activity logs.
  • Graph (Neo4j): nodes, relationships with properties and direction, and Cypher, which is written by drawing the path. Index-free adjacency makes the cost of a hop depend on the number of neighbors and not on the size of the database: up to two hops, SQL; from three onwards, a graph. Fit: the "readers like you also read".
  • Specialized: search engines with an inverted index (Elasticsearch), time series (InfluxDB, TimescaleDB) and vector stores (pgvector, Qdrant) for semantic similarity search.
  • BiblioRed's real decision: PostgreSQL and MongoDB. Redis, Cassandra and Neo4j are deferred with a written criterion for when they would stop being so. Every database added is a database to operate.

We now know which families exist and how each one is operated. What is missing is the hardest part, and the one most often got wrong in practice: designing well. In lesson 03-03, Data Modeling in NoSQL, we will invert the order we learned in the relational world —we will stop modeling the domain and start modeling from the queries—, define the concept of the aggregate, settle the central decision of embedding versus referencing with explicit criteria, study the document patterns (extended reference, subset, bucket, outlier, computed field) and their anti-patterns, and deliver the final, justified design of BiblioRed's collections: reviews, catalog and activity.

© Copyright 2026. All rights reserved