We closed module 2 with biblioredb in very good shape: seven tables, foreign keys that prevent orphans, management reports that add up. That is the core of the system and it is going to stay that way. But as soon as BiblioRed's management approved the new members' portal, four needs appeared that the relational schema does not accommodate comfortably: readers want to publish reviews, the catalog wants to show covers, synopses and tags for materials that are no longer only books, the team wants to record every search made in the finder, and marketing wants to recommend titles based on what similar readers have read.
Back in lesson 01-02 we already hinted at the decision: that part of the system would go to MongoDB. In this lesson we justify the why properly. We are going to see what "NoSQL" really means —and what it does not mean—, which four characteristics every database in this family shares, how it scales horizontally through partitioning and replication, what price you pay for it, and we will take our first practical steps with MongoDB by creating BiblioRed's reviews collection.
By the end you will know why this movement exists, not just how a query is written in it. And —just as important— you will be able to say when it is not a good idea to use it.
Contents
- Where BiblioRed's relational schema gets stuck
- What "NoSQL" means and what it does not mean
- Characteristic 1: flexible schema (schema-on-read)
- Characteristic 2: aggregate orientation
- Characteristic 3: horizontal versus vertical scaling
- Characteristic 4: distribution
- How it really scales (I): partitioning or sharding
- How it really scales (II): replication
- What you pay in exchange
- First steps with MongoDB: installation and
mongosh - MongoDB's hierarchy compared with the relational one
- BSON, JSON and the
_idfield - First operations:
insertOne,insertMany,find - When NOT to use NoSQL
- Common mistakes and tips
- Exercises
- Conclusion
- Where BiblioRed's relational schema gets stuck
Before talking about technology, let's look at the real problem. There are three concrete situations.
Situation A: the enriched catalog
BiblioRed no longer lends only books. The catalog holds books, DVDs, magazines and audiobooks, and every type of material has metadata of its own:
| Type | Specific metadata |
|---|---|
| Book | ISBN, publisher, page count, binding |
| DVD | director, duration, picture format, subtitles, age rating |
| Magazine | ISSN, issue, volume, frequency |
| Audiobook | narrator, duration, codec, file size |
With the relational model there are three classic ways out, and none of them is pleasant:
- One wide table with every column.
materialswould haveisbn,issn,director,narrator,duration,pages,codec… and in each row most of them would beNULL. In a magazine row, 70% of the columns are dead weight. The schema stops describing reality and becomes a union of incompatible realities. - One table per type.
books,dvds,magazines,audiobooks. Clean on paper, but any search query ("everything by Jules Verne") needs aUNIONof four tables, and adding a fifth type of material —comics, planned for 2027— means a new table and touching every query. - Entity-attribute-value (EAV). An
attributes (material_id, key, value)table withvalueas text. Flexible, yes, but you lose typing, you lose constraints, and a complete record requires pivoting twenty rows. It is the solution most often regretted in the history of database design.
Situation B: the review service changes every month
The first version of reviews was: text and a score from 1 to 5. In the first quarter the product team asked for, in this order:
- adding free-form tags written by the reader (
"historical fiction","gift idea"); - allowing readers to vote whether a review was helpful, storing who voted;
- allowing a librarian to reply to a review;
- adding spoiler: yes/no and hiding the text by default when it is one.
In the relational world, each of those changes is an ALTER TABLE or a new table, with its migration, its deployment window and its coordination with the application team. Four schema changes in three months on a table that does not yet have a stable shape.
Situation C: the activity log
Every time a member searches the catalog, opens a record or filters by author, the portal wants to log the event. The team's estimates:
Seventeen million rows a year, written continuously, which are almost never read row by row (they are read in aggregate: "most frequent searches of the month"), which need no foreign keys and which lose almost all their value after two years. Putting them in the same transactional database as the loans means growing the indexes, stretching the backups and competing for the cache of a database that has to answer fast at the front desk.
None of the three situations is a failure of the relational model: it is a poor fit. The relational model shines with homogeneous data, heavily related to each other and with strict integrity rules —exactly what loans are—. These three situations are something else.
- What "NoSQL" means and what it does not mean
The name is, honestly, bad. It was born in 2009 as the hashtag of a technical meetup in San Francisco and it stuck. The reading that has ended up prevailing is "not only SQL": not instead of SQL, but in addition to SQL.
What it does mean in practice:
- A set of databases that do not use the relational model of tables, rows and columns as their main structure.
- Systems designed from day one to be distributed across several machines.
- Alternative data models: documents, key-value pairs, column families, graphs.
What it does not mean, and it is worth dismantling this right away:
| Myth | Reality |
|---|---|
| "NoSQL means there is no query language" | MongoDB has its query language and its aggregation framework; Cassandra uses CQL, which looks a great deal like SQL; Neo4j uses Cypher. Some even accept SQL directly. |
| "NoSQL means there is no schema" | It means the schema is not enforced by the server, not that it does not exist. The schema always exists: it lives in the application code. We will see this in detail in section 3. |
| "NoSQL replaces relational databases" | In the vast majority of real architectures they coexist. That is exactly what is going to happen at BiblioRed. |
| "NoSQL is more modern, therefore better" | They are tools with different fits. A document database for managing loans with fines would be a bad decision, and it will still be one in 2030 just as it is today. |
| "NoSQL has no transactions" | That was true of many products for years. MongoDB has had multi-document transactions since 2018. We will come back to this in lesson 03-04. |
A useful way of seeing it: NoSQL databases deliberately give up some of the relational model's guarantees in exchange for something concrete —scaling, flexibility or efficiency in one kind of query—. The question you should always ask is not "is it modern?", but "what does this product give up, and is the trade worth it to me?".
- Characteristic 1: flexible schema (schema-on-read)
It is the difference you notice most on day one.
- Schema-on-write (relational): the schema is defined before writing. The server rejects any data that does not fit. It is the
CREATE TABLEof lesson 02-02 and the constraints of 02-06. - Schema-on-read (document): you write whatever, and interpretation happens on reading. It is the application that knows which fields it expects.
Look at it with BiblioRed's catalog. Two documents from the same collection:
{
"_id": "MAT-0331",
"type": "book",
"title": "The Map of Time",
"isbn": "9788401339097",
"publisher": "Ediciones Vallmar",
"pages": 612
}{
"_id": "MAT-0742",
"type": "dvd",
"title": "Mediterranean Cartographies",
"director": "Aina Ferriol",
"duration_min": 94,
"subtitles": ["es", "ca", "en"]
}They coexist in the catalog collection with no NULLs, no UNION, no new table. When comics arrive in 2027, you insert documents with "type": "comic" and fields illustrator and volume_number. No ALTER TABLE, no migration, no deployment window.
Now the small print, which matters a great deal:
The schema has not disappeared. It has moved house: from the
CREATE TABLEto the application code. And there, nobody checks it automatically.
If one developer writes "duration" in some documents and "duration_min" in others, MongoDB happily accepts both and the error shows up months later, in a report that returns half the data. That is why in lesson 03-03 we will look at schema validation ($jsonSchema), which lets you voluntarily recover part of that safety net.
| Schema-on-write | Schema-on-read | |
|---|---|---|
| Who validates | The database server | The application |
| When it fails | On writing, immediately | On reading, perhaps months later |
| Cost of changing | ALTER TABLE + migration |
Writing the new field |
| Heterogeneous documents | Difficult (nulls or EAV) | Natural |
| Consistency guarantee | High and automatic | Whatever the team puts in |
- Characteristic 2: aggregate orientation
This concept is the one that really explains NoSQL, and it is underrated because it sounds abstract. Let's take it slowly.
An aggregate is a set of data that the application treats as a unit: it is read together, written together and, normally, deleted together.
At BiblioRed, a review record is an aggregate: when the portal shows a review, it shows at the same time its text, its score, its tags, who wrote it and how many helpful votes it has. It never shows "the tags" on their own.
In the relational model, that aggregate is scattered across several tables because normalization demands it (we will study it formally in module 5). Rebuilding it requires a JOIN:
SELECT r.text, r.score, m.name, t.tag
FROM reviews r
INNER JOIN members m ON m.member_id = r.member_id
LEFT JOIN review_tags t ON t.review_id = r.review_id
WHERE r.book_id = 331;Three tables, a JOIN and an explosion of rows (one row per tag) that the application has to fold back together in memory. In the document model, the aggregate is the document:
{
"_id": "REV-1001",
"book_id": 331,
"book_title": "The Map of Time",
"member": { "member_id": 14, "name": "Marta Alsina" },
"score": 5,
"text": "A novel that plays with time without making the reader dizzy.",
"tags": ["historical fiction", "science fiction", "recommended"],
"helpful_votes": 7,
"date": "2026-03-14"
}A single read, a single object, zero JOINs. That is the core of the proposition.
And here comes the consequence that almost nobody explains at the start: if the data is read together, it can also be stored together on disk. And if it is together on disk, it can be moved whole to another machine. The aggregate is the natural unit of distribution, and that is why aggregate orientation and horizontal scaling are the same idea seen from two angles.
It is also the natural boundary of atomicity: in MongoDB, writing a complete document is atomic with no need for a transaction. Everything that fits inside the aggregate is updated in one piece. Everything left outside is not.
- Characteristic 3: horizontal versus vertical scaling
When a system falls short there are two roads.
- Vertical scaling (scale up): a bigger machine. More CPU, more RAM, faster disks. It is the first thing anyone does and it works surprisingly well for a long time.
- Horizontal scaling (scale out): more machines, each with a part of the work.
| Vertical | Horizontal | |
|---|---|---|
| How you grow | You replace the server | You add servers |
| Ceiling | It exists and it is hard: the biggest machine in the catalog | Practically unlimited |
| Cost | Grows more than linearly (twice the CPU costs quite a bit more than twice) | Roughly linear |
| Complexity | Low: the application never notices | High: data has to be distributed and coordinated |
| Single point of failure | Yes | No, if there is replication |
| Downtime to grow | Usually yes | No |
The classic relational model scales vertically with ease, and horizontally with difficulty: a JOIN between two tables living on different machines requires moving data over the network, and a transaction touching several machines requires two-phase commit protocols, which are slow and fragile. NoSQL databases eliminate the two problematic operations by design —the server-side JOIN and the general distributed transaction— and in exchange they distribute without friction.
A warning against the exaggeration of sales presentations: BiblioRed has 12,000 members and 17 million events a year. That fits comfortably in a single well-configured PostgreSQL machine. The reason BiblioRed is going to use MongoDB is not volume, it is the heterogeneity of the catalog and the rate of change of the review service. Being honest about the real motive is part of the craft.
- Characteristic 4: distribution
The fourth characteristic is a consequence of the third: these systems were designed on the assumption that they are going to live on several machines, not as an extension bolted on afterwards.
That implies three things that are taken for granted in NoSQL and that in a classic relational database are projects:
- Adding a node is a routine operation, not a migration.
- A node going down is not the service going down, if there are replicas.
- Data is placed automatically: the system decides which node each piece of data goes to and rebalances by itself.
The two mechanisms that make this possible are partitioning and replication. They are different, they solve different problems and they are used at the same time. Let's take them in turn.
- How it really scales (I): partitioning or sharding
Partitioning (or sharding) means splitting the data set across several nodes, so that each node stores only a part. Goal: spread out volume and spread out write load.
The key piece is the shard key: the field whose value decides which shard each document lives in.
flowchart TD
APP["BiblioRed portal<br/>application"] --> R["Router / mongos<br/>looks up the shard map"]
R --> S1["Shard A<br/>member_id 1 - 4000<br/>~5.7M events"]
R --> S2["Shard B<br/>member_id 4001 - 8000<br/>~5.6M events"]
R --> S3["Shard C<br/>member_id 8001 - 12000<br/>~5.9M events"]
CFG["Config servers<br/>ranges -> shard"] -.-> R
How an operation works:
- Writing an event for member 15 → the router works out that 15 falls in shard A → it writes only there. The other two shards never even hear about it, and that is why writes spread out.
- Read filtered by
member_id: 15→ the router goes straight to shard A. That is a targeted query and it is fast. - Read without filtering by the key (for example, "events from the last day") → the router has to ask all three shards and merge the results. That is a scatter-gather query, and it is expensive.
Hence the most important practical rule of partitioning:
Choosing the shard key is choosing which queries will be fast. A bad key turns every query into a scatter-gather one and makes the system slower than a single machine.
What characterizes a good shard key:
| Criterion | Why it matters | Bad example at BiblioRed |
|---|---|---|
| High cardinality | Many distinct values = many possible shards | material_type (only 4 values: at most 4 shards) |
| Even distribution | Prevents one shard from receiving almost everything | branch_id if the Central branch concentrates 60% of the activity |
| No monotonic growth | An always-increasing key sends every new write to the last shard (hotspot) | raw event_date |
| Present in frequent queries | Otherwise every read is scatter-gather | a random _id when you always filter by member |
Two distribution strategies:
- By range: shard A stores
member_id1–4000, shard B 4001–8000. Advantage: range queries go to few shards. Risk: imbalance if the values are not well spread. - By hash: a hash function is applied to the key and the result decides the shard. Advantage: very even distribution. Drawback: range queries become scatter-gather, because contiguous values end up in different shards.
And a note of realism: BiblioRed is not going to partition anything. Its volumes fit amply on a single node. Partitioning is studied to understand NoSQL's architectural proposition and to recognize when it will be needed, not because it has to be switched on from day one. Switching it on without needing it adds operational complexity in exchange for nothing.
- How it really scales (II): replication
Replicating means keeping complete copies of the same data set on several nodes. Goal: surviving failures and, secondarily, spreading read load.
The dominant model is primary-secondaries. In MongoDB it is called a replica set:
flowchart TD
APP["Application"] -->|writes| P["PRIMARY<br/>accepts reads and writes"]
APP -.->|optional reads| S1
APP -.->|optional reads| S2
P -->|replicates the oplog| S1["SECONDARY 1<br/>complete copy"]
P -->|replicates the oplog| S2["SECONDARY 2<br/>complete copy"]
S1 <-->|heartbeat every 2 s| S2
The rules of the game:
- Every write goes to the primary. There is only one, and that is why there are no write conflicts.
- The primary records every change in an operation log (the oplog), and the secondaries apply it in the same order. They run a few milliseconds behind.
- The nodes send each other heartbeats every few seconds. If the primary stops answering, the secondaries elect a new primary by vote and the service carries on. This process is called failover and it usually takes between 5 and 15 seconds.
- For there to be a majority in the vote you want an odd number of nodes. Three is the minimum sensible configuration.
What BiblioRed gains from this: if the server hosting the reviews shuts down at three in the morning, the portal stops working for a few seconds and then carries on. With a single machine, it stops working until somebody turns up.
And here a nuance appears that is worth planting now, although we will develop it in lesson 03-04: if the application reads from a secondary, it may read slightly stale data. A review Marta Alsina has just published might not appear to Iván Pereda for a few milliseconds. That phenomenon is called eventual consistency, and it is the counterpart of distribution. In 03-04 we will study it alongside the CAP theorem and the contrast between ACID and BASE.
| Partitioning (sharding) | Replication | |
|---|---|---|
| What each node stores | Part of the data | All the data |
| Problem it solves | Volume and write load | Availability and read load |
| If a node goes down | Access to that part is lost | Nothing happens: there are copies |
| Key decision | The shard key | How many nodes and where you read from |
| Are they used together? | Yes: in production, each shard is itself a replica set |
- What you pay in exchange
None of these advantages is free. These are the four bills, and you have to look at them before signing.
9.1 There is no server-side JOIN
In module 2 we crossed seven tables in a single query. In a document database, if the information you need is in two collections, you have three options and all three have a cost:
- Duplicating the data inside the document (the usual choice).
- Running two queries from the application and combining them in memory.
- Using
$lookup, MongoDB's operation resembling aLEFT JOIN, which exists but is slow and is not meant to be used everywhere (in lesson 03-03 we will see it as an anti-pattern when it is overused).
9.2 There is no declarative referential integrity
The whole of module 2 closed by explaining that the database is the last line of defense. In MongoDB that line does not exist: you can store a review with book_id: 9999 without anyone objecting, even though that book does not exist. Responsibility passes entirely to the application.
9.3 Deliberate data duplication
Look at the review document in section 4: it stores "book_title": "The Map of Time" and "name": "Marta Alsina". That is information that also lives in books and in members. It is duplicated on purpose, so that the review can be rendered without having to fetch anything else.
And that duplication has consequences: if Marta gets married and changes her surname, there are 34 reviews of hers with the old surname. Do they all have to be updated? Sometimes yes (a display name) and sometimes no (the name at the time of the review is a legitimate historical fact). It is a conscious design decision, and we will deal with it thoroughly in 03-03.
9.4 Consistency moves to the application
Summarized in a table, so it is clear who does what:
| Responsibility | Relational | Document |
|---|---|---|
| That the types are correct | Server (CREATE TABLE) |
Application |
| That required fields are not missing | Server (NOT NULL) |
Application |
| That there are no duplicates | Server (UNIQUE) |
Server (unique index) or application |
| That the references exist | Server (FOREIGN KEY) |
Application |
| That the duplicated data is up to date | Not applicable (nothing is duplicated) | Application |
| That several writes are all or nothing | Server (transaction) | Inside the document: server. Across documents: explicit transaction |
The operational conclusion: NoSQL does not remove work, it moves it from the database to the code. If the team is disciplined and code reviews are taken seriously, the deal works out. If not, it works out very badly.
- First steps with MongoDB: installation and
mongosh
mongoshLet's get practical. You need a MongoDB server and its command-line client, mongosh.
Option A: Docker container (recommended for learning)
It is the cleanest route: it does not clutter the system and it is deleted whole when you are done.
# Download the image and start a server on port 27017
docker run -d --name mongo-biblioRed -p 27017:27017 mongo:7
# Check that it is running
docker ps --filter name=mongo-biblioRed --format "{{.Names}}\t{{.Status}}"Option B: local installation
# Debian / Ubuntu (after adding MongoDB's official repository)
sudo apt install -y mongodb-org
sudo systemctl start mongod
sudo systemctl status mongod --no-pager | head -3● mongod.service - MongoDB Database Server
Loaded: loaded (/lib/systemd/system/mongod.service; enabled)
Active: active (running)Option C: MongoDB Atlas
This is the manufacturer's own managed cloud service and it has a free tier that is enough for the course. They give you a connection string and you connect with:
Checking that the shell works
When you go in you will see something like this:
Current Mongosh Log ID: 66ab0f1c2d4e5f6a7b8c9d0e
Connecting to: mongodb://127.0.0.1:27017/
Using MongoDB: 7.0.11
Using Mongosh: 2.2.6
test>That test> is the prompt: you are in the test database. And here comes the nicest detail about mongosh: it is a complete JavaScript interpreter. You can declare variables, use loops and call functions. It is not a separate language like SQL: they are calls to object methods.
- MongoDB's hierarchy compared with the relational one
The mental correspondence you need is this one:
| PostgreSQL | MongoDB | Comment |
|---|---|---|
| Server / cluster | Server / deployment | A process listening on a port |
| Database | Database | Same concept |
| Table | Collection | A set of documents, with no enforced schema |
| Row | Document | A JSON-like structure, can nest |
| Column | Field | May be missing in some documents and present in others |
| Primary key | The _id field |
Mandatory and unique, generated if you do not supply it |
| Index | Index | Same concept and same purpose (module 6) |
JOIN |
$lookup |
It exists, but it is not the usual route |
| Schema (DDL) | (no mandatory equivalent) | Optional validation with $jsonSchema (03-03) |
flowchart LR
subgraph REL["PostgreSQL - biblioredb"]
T1["members table"] --> F1["row: member_id 14, Marta Alsina"]
T2["loans table"] --> F2["row: loan_id 902"]
end
subgraph DOC["MongoDB - bibliored"]
C1["reviews collection"] --> D1["document:<br/>{_id, member:{...}, tags:[...]}"]
C2["catalog collection"] --> D2["document:<br/>{_id, type, metadata:{...}}"]
end
A practical detail that surprises people coming from SQL: databases and collections create themselves. There is no CREATE DATABASE and no CREATE TABLE. It is enough to select a database and insert; MongoDB materializes it on the first document written.
bibliored does not appear. That is normal: it will appear after the first insert.
- BSON, JSON and the
_id field
_id fieldBSON
Documents are written looking like JSON, but MongoDB stores them internally in BSON (Binary JSON). The differences matter:
| JSON | BSON | |
|---|---|---|
| Format | Text | Binary |
| Numeric types | A single number type |
int32, int64, double, decimal128 |
| Dates | Do not exist (strings are used) | Native Date type |
| Binary data | No (you have to encode in base64) | BinData type |
| Traversal | The whole text has to be parsed | It carries lengths: it can skip fields |
| Size | More compact in plain text | Somewhat larger, but much faster to traverse |
Practical consequence: use the native types. A date stored as the string "2026-03-14" cannot be compared by range or grouped by month reliably; stored as an ISODate it can.
// Bad: the date is a string
{ date: "2026-03-14" }
// Good: the date is a BSON Date type
{ date: ISODate("2026-03-14T10:25:00Z") }Watch out too for the 16 MB per document limit. It sounds enormous —it is about 8,000 pages of text— but it is the limit that makes it unfeasible, for example, to put a popular book's 40,000 search events inside its document. We will come back to this limit in 03-03, because it is the criterion that governs the decision to embed or reference.
The _id field
Every document has an _id field that acts as the primary key:
- It is mandatory: if you do not supply it, MongoDB generates it.
- It is unique within the collection, with an automatically created index that cannot be dropped.
- It is immutable: it cannot be modified afterwards.
- It can be of any type:
ObjectId, string, number, even a document.
By default it is an ObjectId, a 12-byte identifier generated on the client (not on the server) that contains the creation timestamp, a process identifier and a counter. That makes it unique with no coordination between machines, which is exactly what a distributed system needs —unlike PostgreSQL's SERIAL, which demands a central counter—.
When you have a natural identifier with meaning of its own, use it as the _id: you save yourself an index. In BiblioRed's catalog we will use codes such as "MAT-0331".
- First operations:
insertOne, insertMany, find
insertOne, insertMany, findAt last we create BiblioRed's reviews collection. All the data is fictional.
Inserting one document
use bibliored
db.reviews.insertOne({
book_id: 331,
isbn: "9788401339097",
book_title: "The Map of Time",
member: { member_id: 14, name: "Marta Alsina" },
branch_id: 1,
score: 5,
text: "A novel that plays with time without making the reader dizzy. Highly recommended.",
tags: ["historical fiction", "science fiction"],
helpful_votes: 7,
spoiler: false,
date: ISODate("2026-03-14T10:25:00Z")
})Read it slowly, because there are three new things compared with an SQL INSERT:
- We have not created anything beforehand. Neither the
biblioreddatabase nor thereviewscollection. They exist as of this line. memberis a nested document. In SQL that would be two columns or a separate table; here it is an object inside the object.tagsis an array. The relational model does not allow multiple values in a cell —first normal form forbids it, and we will see it in 05-02—. The document model does, and that is one of its deepest differences.
Inserting several documents
db.reviews.insertMany([
{
book_id: 331,
isbn: "9788401339097",
book_title: "The Map of Time",
member: { member_id: 15, name: "Iván Pereda" },
branch_id: 2,
score: 3,
text: "It starts very well, but the final part dragged for me.",
tags: ["historical fiction"],
helpful_votes: 2,
spoiler: false,
date: ISODate("2026-03-22T18:40:00Z")
},
{
book_id: 412,
isbn: "9788401337208",
book_title: "The Pillars of the Earth",
member: { member_id: 16, name: "Nuria Bastos" },
branch_id: 3,
score: 5,
text: "A thousand pages that fly by. Building the cathedral is gripping.",
tags: ["historical fiction", "gift idea", "modern classic"],
helpful_votes: 12,
spoiler: false,
librarian_reply: {
name: "South Branch Team",
text: "If you liked it, we have the sequel available at the South branch.",
date: ISODate("2026-03-25T09:10:00Z")
},
date: ISODate("2026-03-24T12:05:00Z")
},
{
book_id: 412,
isbn: "9788401337208",
book_title: "The Pillars of the Earth",
member: { member_id: 14, name: "Marta Alsina" },
branch_id: 1,
score: 4,
text: "Very entertaining, although some characters are too flat.",
tags: ["historical fiction"],
helpful_votes: 4,
spoiler: true,
date: ISODate("2026-04-02T20:15:00Z")
}
]){
acknowledged: true,
insertedIds: {
'0': ObjectId('66ab15c15c9e1b2f3d4a6c83'),
'1': ObjectId('66ab15c15c9e1b2f3d4a6c84'),
'2': ObjectId('66ab15c15c9e1b2f3d4a6c85')
}
}Notice that the third document has a field, librarian_reply, that the others do not have. Nobody objected. That is schema-on-read in action: when the product team asked for librarian replies, there was no ALTER TABLE; documents with that field simply started being written.
Reading documents
find is the equivalent of SELECT. It takes a filter document: each field is a condition and they are combined with a logical AND.
[
{ _id: ObjectId('...c82'), book_id: 331, book_title: 'The Map of Time', score: 5, ... },
{ _id: ObjectId('...c83'), book_id: 331, book_title: 'The Map of Time', score: 3, ... },
{ _id: ObjectId('...c84'), book_id: 412, book_title: 'The Pillars of the Earth', score: 5, ... },
{ _id: ObjectId('...c85'), book_id: 412, book_title: 'The Pillars of the Earth', score: 4, ... }
][
{ _id: ObjectId('...c82'), member: { member_id: 14, name: 'Marta Alsina' }, score: 5, ... },
{ _id: ObjectId('...c83'), member: { member_id: 15, name: 'Iván Pereda' }, score: 3, ... }
]// Filter on a nested field: dot notation between quotes
db.reviews.find({ "member.member_id": 14 })[
{ _id: ObjectId('...c82'), book_title: 'The Map of Time', score: 5, ... },
{ _id: ObjectId('...c85'), book_title: 'The Pillars of the Earth', score: 4, ... }
][
{ _id: ObjectId('...c84'), book_title: 'The Pillars of the Earth', member: { member_id: 16, name: 'Nuria Bastos' }, ... }
]That last query deserves a moment's attention. In SQL, to query tags you would need a review_tags table, a JOIN and a DISTINCT. Here it is an equality filter on an array, and MongoDB automatically understands that it has to look inside. It is a perfect example of what aggregate orientation buys you.
The complete query operators ($gt, $in, $regex, projection, sorting, updates with $set and $push, and the aggregation pipeline) we will see in the next lesson, 03-02, alongside the other three NoSQL families.
- When NOT to use NoSQL
This section is the most honest one in the lesson and probably the most useful in your career. These are the signs that the right answer is a relational database:
- The data is heavily related and the queries are unpredictable. If tomorrow somebody might ask for "loans by members of the North branch, of books in Catalan published after 2015, whose author has another book on hold", you want SQL. That is exactly the terrain where the relational model has no rival.
- You need transactions over several entities as the rule, not the exception. Recording a loan touches
loansandcopiesat the same time and has to be all or nothing. It is a textbook relational case. - Integrity is a requirement, not a preference. Money, fines, legal histories, regulated data. If an orphan is unacceptable, you want the server to prevent it, not an
ifin the code. - The volume fits on one machine. Which is almost always. A single PostgreSQL instance on ordinary hardware handles hundreds of gigabytes and thousands of transactions per second without breaking a sweat. If that is your scale, horizontal scaling only brings you complexity.
- The team has no experience operating distributed systems. A badly operated cluster is less reliable than a well-operated machine. Technology does not make up for a lack of practice.
- Reporting and analysis are the main use. BI tools, dashboards and analysts speak SQL. Taking the data into a document model only to have to get it back out again is working against yourself.
- "Because it is what everyone is doing". It is the worst possible motive and, statistically, one of the most frequent.
The default advice, which we will repeat with more arguments in lesson 03-04: start relational and add NoSQL when you have a concrete reason you can write in one sentence. BiblioRed can write it: "the catalog is heterogeneous by material type, the reviews change shape every month and the activity log is a high volume of writes that needs no referential integrity". That is a reason. "We want to modernize" is not.
Common Mistakes and Tips
Mistake 1: believing NoSQL means "schemaless" and designing nothing.
The schema always exists; only who polices it changes. Write the expected schema of each collection in the project documentation from day one, even though the server does not require it. In 03-03 you will see how to get the server to police it too, with $jsonSchema.
Mistake 2: migrating the whole relational database to MongoDB "to unify things".
It is the decision that has produced the most regret in the last decade. BiblioRed does not move loans or members: that is where the relational model wins. Only what fits badly is moved.
Mistake 3: adopting NoSQL "for performance" without having measured. Many problems blamed on "the relational database being slow" are really a missing index or a badly written query. Before changing technology, measure and optimize what you have (module 6, lesson 03).
Mistake 4: storing dates and numbers as text strings.
{ date: "14/03/2026" } and { score: "5" } work when inserting and ruin any later comparison, sort or aggregation. Use ISODate(...) and real numbers. It is the most common mistake and the most expensive to fix after the fact.
Mistake 5: choosing the shard key with the first idea that comes to mind. It is the hardest decision in the whole system to reverse. Before choosing it, write down the application's five most frequent queries and check which ones would be targeted and which scatter-gather.
Tip 1: use a natural identifier as the _id when one exists.
"MAT-0331" is more readable than an ObjectId in error logs and it saves you an extra index.
Tip 2: name your fields with a convention and stick to it.
Choose snake_case or camelCase, write it in the team handbook and do not mix them. With no CREATE TABLE to keep order, the convention is your only defense against duration / duration_min / durationMin in the same collection.
Tip 3: in mongosh you have full JavaScript.
To generate test data, a loop is enough:
const docs = []
for (let i = 1; i <= 5; i++) {
docs.push({ event: "search", term: "verne", member_id: 14, order_: i })
}
db.test_activity.insertMany(docs)
db.test_activity.countDocuments()Tip 4: learn to read the acknowledged: true result.
It means the server has confirmed the write. When we look at writeConcern in 03-04, you will understand what level of confirmation lies behind that true and why it can be tuned.
Exercises
Exercise 1
BiblioRed wants to store an audiobook and a magazine in the catalog. Justify in three or four sentences why this fits better in a document collection than in the books table of module 2's relational schema, and then write the two inserts in mongosh against a catalog collection, using a natural _id in the style of "MAT-0801". The audiobook is a version of "The Map of Time" narrated by Àlex Roure, 14 h 20 min long, in MP3 format. The magazine is "Vallmar Cultural", ISSN 2604-1188, issue 42, volume 7, published monthly.
Exercise 2
For BiblioRed's activity log (17 million events a year, queried almost always as "activity of one specific member" and occasionally as "most frequent searches of the month"), assess these three shard key candidates and pick one, justifying the decision with the four criteria from section 7:
- (a)
event_date - (b)
event_type(possible values:search,record,filter,download) - (c)
member_id
Exercise 3
On the reviews collection you created in section 13, write the find queries that answer these three questions, and also say what the approximate SQL equivalent of each would be:
- All the reviews written by member 14.
- All the reviews flagged as spoilers.
- All the reviews from branch 3 about book 412.
Solutions
Solution 1
Justification. An audiobook and a magazine share only a handful of fields with a book (title, language, publication_year) and differ in everything else: narrator, duration and audio_format versus issn, issue, volume and frequency. In the books table you would have to add seven columns that would be NULL in the vast majority of rows, or create two new tables that would force a UNION in every search query. In a document collection, each material carries only the fields it makes sense for it to carry, and adding the "comic" type in 2027 will require no schema change and no migration.
use bibliored
db.catalog.insertMany([
{
_id: "MAT-0801",
type: "audiobook",
title: "The Map of Time",
language: "es",
related_work: { book_id: 331, isbn: "9788401339097" },
metadata: {
narrator: "Àlex Roure",
duration_min: 860,
audio_format: "MP3",
size_mb: 742
},
tags: ["historical fiction", "audio"],
created: ISODate("2026-05-11T09:00:00Z")
},
{
_id: "MAT-0802",
type: "magazine",
title: "Vallmar Cultural",
language: "ca",
metadata: {
issn: "2604-1188",
issue: 42,
volume: 7,
frequency: "monthly"
},
tags: ["local culture", "periodicals archive"],
created: ISODate("2026-05-11T09:04:00Z")
}
])Notice that insertedIds returns the strings we supplied ourselves, not ObjectIds: when you provide an _id, MongoDB respects it.
Solution 2
| Candidate | Cardinality | Distribution | Monotonicity | In the queries | Verdict |
|---|---|---|---|---|---|
(a) event_date |
High | Even in the long run | Always increasing | Only in the monthly query | Bad. Every write made today would land on the same shard: a permanent hotspot. It is the most classic partitioning mistake. |
(b) event_type |
Very low (4 values) | Very uneven (search would be the majority) |
No | Almost never filtered on | Bad. Four shards at most and one of them holding 60% of the data. Low cardinality is disqualifying. |
(c) member_id |
High (12,000 values) | Reasonably even | No | Yes: it is the filter of the frequent query | The chosen one. |
Decision: member_id. It meets the four criteria and, above all, the fourth: the usual query ("activity of member 14") becomes a query targeted at a single shard. The monthly frequent-searches query will indeed be scatter-gather, but it is occasional, it runs off peak and it is aggregate by nature, so its cost is acceptable.
An even better improvement would be a composite key { member_id: 1, event_date: 1 }: it distributes by member and, within each member, keeps events ordered by date, which speeds up queries of the type "this member's activity over the last month".
And the realistic reminder: with 17 million documents a year, BiblioRed does not need to partition yet. The exercise is there so you know which key you would choose the day it becomes necessary, not so you switch it on tomorrow.
Solution 3
[
{ _id: ObjectId('...c82'), book_title: 'The Map of Time', score: 5, ... },
{ _id: ObjectId('...c85'), book_title: 'The Pillars of the Earth', score: 4, ... }
][
{ _id: ObjectId('...c85'), member: { member_id: 14, name: 'Marta Alsina' }, spoiler: true, ... }
]An important detail about the third one: putting two fields in the filter document is equivalent to AND. There is no explicit $and operator in the normal case; conjunction is the default behavior.
Conclusion
In this lesson we have crossed the border between the course's two worlds:
- BiblioRed's relational schema does not fail, it stops fitting in three concrete cases: a catalog with different metadata per material type, a review service whose shape changes every month and an activity log of 17 million events a year that needs no referential integrity.
- "NoSQL" means not only SQL: databases that do not use the relational model as their main structure and that are born distributed. It does not mean "no query language", nor "no schema", nor "a replacement for relational".
- Flexible schema (schema-on-read): the schema does not disappear, it moves from the server to the application code, gaining agility and losing a safety net.
- Aggregate orientation: the unit of data that is read and written together is stored together. From that come both the absence of
JOINs and the ease of distributing. - Horizontal scaling instead of vertical: more machines rather than a bigger one, with a practically unlimited ceiling and roughly linear cost, in exchange for operational complexity.
- Partitioning: each node stores part of the data; the shard key must have high cardinality, even distribution, no monotonic growth and presence in the frequent queries. Replication: each node stores a complete copy; the primary accepts the writes, the secondaries follow the oplog and elect a new primary if the current one goes down.
- The price: no server-side
JOIN, no declarative referential integrity, deliberate data duplication and consistency moved to the application. NoSQL does not remove work: it moves it from the database to the code. - MongoDB in practice: server → database → collection → document; databases and collections that create themselves; BSON with native types (use
ISODate, not strings) and a 16 MB per document limit; the_id, mandatory, unique, immutable and generated on the client as anObjectId. WithinsertOne,insertManyandfindwe have already created and queried thereviewscollection, with nested documents, arrays and fields that only some documents have. - When not to use it: heavily related data with unpredictable queries, routine multi-entity transactions, integrity as a requirement, volumes that fit on one machine, a team with no mileage in distributed systems, analytical workloads… or fashion as the only argument.
In the next lesson, 03-02, Types of NoSQL Databases, we go down into the detail of the four families. We will come back to the landscape we only glimpsed in 01-02, but this time with real operation: complete queries and updates in MongoDB —including the first aggregation pipeline as the equivalent of the GROUP BY from lesson 02-05—, caching and expiration in Redis, wide rows and CQL in Cassandra, and multi-hop traversals in Cypher over Neo4j to solve the "readers like you also read" that BiblioRed's recommendation engine needs.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
