We have built the two halves of BiblioRed's system: biblioredb in PostgreSQL, with seven tables, foreign keys and multi-table queries; and bibliored in MongoDB, with three collections designed from the queries the application needs to serve. Now it is time to put the two halves face to face and answer the question every professional eventually has to answer in front of somebody who decides a budget: when each one, and why?
Along the way we have been postponing three theoretical pieces, and this is their lesson. The CAP theorem —probably the worst explained concept in the whole discipline—, its refinement PACELC, and the contrast between ACID and BASE with what eventual consistency really means for the reader who has just published a review and cannot see it.
And there is one nuance that changes the entire conversation and that almost no old comparison captures: the border has blurred. PostgreSQL stores JSON documents and indexes them; MongoDB has had multi-document transactions since 2018. The choice in 2026 does not look like the one in 2012, and deciding with 2012's arguments is the most common way of getting it wrong.
We will finish with an honest decision guide, the most repeated myths dismantled, and the name of the architecture BiblioRed has arrived at: polyglot persistence.
Contents
- Comparison dimension by dimension
- The CAP theorem, properly explained
- PACELC: the necessary refinement
- ACID versus BASE
- What "eventually consistent" means for a BiblioRed reader
- Tunable consistency: quorum,
writeConcernandreadConcern - The border has blurred (I): PostgreSQL with
jsonb - The border has blurred (II): transactions in MongoDB
- The same query in both worlds
- Decision guide
- Myths dismantled
- Polyglot persistence: BiblioRed's architecture
- Common mistakes and tips
- Exercises
- Conclusion
- Comparison dimension by dimension
| Dimension | Relational (PostgreSQL) | Document (MongoDB) |
|---|---|---|
| Data model | Tables, rows and columns; atomic values in every cell | Nested documents with arrays and subdocuments |
| Schema | Explicit, mandatory, validated by the server before writing | Flexible; optional validation with $jsonSchema |
| Changing the schema | ALTER TABLE + migration + deployment window |
Write the new field; versioning with schema_v |
| Query language | SQL, an ISO standard since 1987, portable between products | A proprietary API + aggregation pipeline; not portable |
| Relationships | JOIN on the server, between any pair of tables |
References resolved by the application; $lookup as the exception |
| Referential integrity | Declarative: FOREIGN KEY with five ON DELETE actions |
Nonexistent; the application's responsibility |
| Other constraints | NOT NULL, UNIQUE, CHECK, domains |
Unique indexes and $jsonSchema |
| Transactions | Full ACID, multi-table, from the beginning; isolation levels | Atomic per document; multi-document since 2018, with a cost |
| Read scaling | Read replicas | Secondaries of the replica set |
| Write scaling | Vertical; partitioning possible but complex | Horizontal through built-in partitioning |
| Default consistency | Strong | Strong when reading from the primary; eventual when reading from secondaries |
| Heterogeneous data | Null columns, one table per type or EAV | Natural |
| Unforeseen queries | Excellent: the normalized schema answers what was not anticipated | Limited by the design; may require a redesign |
| Analysis and reporting | Native; every BI tool speaks SQL | Aggregation pipeline; connectors for BI |
| Maturity | More than 45 years of theory and product | Close to 20 years, with rapid evolution |
| Tooling | An enormous ecosystem: ORMs, migrations, BI, auditing, logical replication | Broad and growing; smaller in niches |
| Available talent | Very wide: SQL is basic knowledge | Scarcer, above all in advanced modeling |
| Operational cost | Low on one node; high if distributed | Low on one node; medium distributed, but planned for by design |
| Ideal use cases | Transactions, finance, inventory, reporting, critical integrity | Catalogs, content, profiles, events, changing structures |
| Discouraged use cases | Very heterogeneous data and schemas that change every week | Heavily related data with unpredictable queries and cross-entity transactions |
Two rows deserve a comment, because they usually decide projects and are rarely mentioned in sales presentations.
Available talent. Almost anyone with technical training knows SQL. Advanced document modeling —what the previous lesson covered— is mastered by far fewer people. A technology decision the team cannot operate is a bad decision, however correct it may be on paper.
Unforeseen queries. It is the most underrated advantage of the relational model. A normalized schema answers questions nobody had thought of on the day it was designed. A document design answers very well the questions it was made for, and may need a redesign for the new ones. If you do not know what you will be asked in two years' time, that is an argument in favor of SQL.
- The CAP theorem, properly explained
Formulated by Eric Brewer in 2000 and formally proved in 2002, the CAP theorem is the most cited and worst understood theoretical result in the world of distributed databases.
The three letters refer to properties of a distributed system —several nodes communicating over a network—:
- C, consistency: every read returns the most recent write. All the nodes see the same thing at the same instant. Careful: this is not the C of ACID; they are different concepts sharing a letter, and that coincidence has caused considerable confusion.
- A, availability: every request to a working node receives a response, with no error and no indefinite wait.
- P, partition tolerance: the system keeps operating even when messages between nodes are lost.
The misunderstanding
The popular formulation —"pick two of three"— is incorrect and misleading. It suggests there is a menu with three options: CA, CP and AP. And that is not how it works.
A partition is not a design option: it is a fact of nature. Cables break, switches reboot, data centers get isolated. If your system is distributed, there will be partitions, you cannot give up P, and therefore "CA" does not exist as a real category.
The correct statement is this one:
When a network partition occurs, a distributed system has to choose between carrying on answering with possibly stale data (A) or refusing to answer so as not to give incorrect data (C). While there is no partition, you can have both.
flowchart TD
P{"Is there a network partition<br/>right now?"}
P -->|NO - 99.9% of the time| OK["You have C and A at once.<br/>The theorem says nothing."]
P -->|YES| E{"You have to choose"}
E -->|Prioritize C| CP["CP: the isolated node REJECTS<br/>requests rather than risk<br/>incorrect data.<br/>MongoDB, HBase, etcd"]
E -->|Prioritize A| AP["AP: the isolated node ANSWERS<br/>with what it has, even if<br/>it is stale.<br/>Cassandra, DynamoDB, Riak"]
The concrete BiblioRed example
Imagine the South branch's network is cut and its node is isolated from the rest. Marta Alsina, from the portal, publishes a review that reaches the main node. Iván Pereda, connected through the South branch's network, asks to see that book's reviews.
- A CP system (this is how MongoDB behaves): the isolated node knows it cannot confirm that its data is up to date, so it returns an error or rejects the operation. Iván sees a "service unavailable" message. Annoying, but he never sees incorrect information.
- An AP system (this is how Cassandra behaves by default): the isolated node answers with what it has. Iván sees the reviews without Marta's. When the network is restored, the nodes synchronize and the review appears. The service is always available, at the cost of having shown an old state.
Neither one is "better". It depends on what hurts more in your domain: for a library catalog, AP is perfectly reasonable —nobody's day is ruined by not seeing a review for thirty seconds—. For a bank balance or for recording a loan with a fine, the answer is CP with no argument.
Where each product falls
| Product | Behavior on partition | Comment |
|---|---|---|
| PostgreSQL on one node | The theorem does not apply | With no distribution there can be no partition |
| PostgreSQL with synchronous replicas | CP | If the replica does not confirm, the write does not complete |
| MongoDB (replica set) | CP by default | With no majority there is no primary, and with no primary there are no writes |
| Cassandra | AP, tunable to CP | The consistency level is chosen per operation |
| Redis | Depends on the configuration | In a cluster, it tends towards AP |
| Neo4j (cluster) | CP | It prioritizes the correctness of the graph |
The Cassandra row is important: modern products do not pick a fixed box. They offer a dial the application adjusts operation by operation, and we will see it in section 6.
- PACELC: the necessary refinement
CAP has a practical problem: it only says something about the moment of the partition, which is 0.1% of the time. It says nothing about the other 99.9%. And there are important decisions there too.
Daniel Abadi proposed the PACELC extension in 2012, which reads like this:
If there is a Partition (P), choose between Availability (A) and Consistency (C); Else (E), choose between Latency (L) and Consistency (C).
The second half is the valuable contribution. Even with a perfect network, keeping all the nodes perfectly synchronized costs time: every write has to wait for the replicas' confirmation before being considered good. It is a permanent trade-off between speed and certainty.
| System | PACELC classification | Translation |
|---|---|---|
| PostgreSQL with a synchronous replica | PC/EC | On partition it prioritizes consistency; and without partition too, even at a latency cost |
| MongoDB (default configuration) | PC/EC | Consistency first in both scenarios |
| MongoDB reading from secondaries | PC/EL | Consistent on partition, but it accepts slightly stale data in exchange for low latency |
| Cassandra | PA/EL | Availability on partition and low latency the rest of the time |
| DynamoDB | PA/EL (configurable) | You can ask for a strongly consistent read by paying more and waiting longer |
What to take away from PACELC: consistency is not free even when everything is working well. Every time a system guarantees that you see the latest data, somebody has waited. The design question is not "do I want consistency?" —everybody does— but "how much latency am I willing to pay for it, in this specific operation?".
And the answer is usually different depending on the operation. At BiblioRed:
- Recording the return of a copy with a fine: maximum consistency, latency does not matter.
- Showing the list of a book's reviews: minimum latency, a one-second lag bothers nobody.
That these are different answers within the same system is exactly the point.
- ACID versus BASE
They are the two models of guarantees, and their acronyms were chosen with a chemist's sense of humor: an acid and a base.
ACID
We will look at it in depth in lesson 06-01. Here, the conceptual contrast:
| Letter | Name | What it guarantees |
|---|---|---|
| A | Atomicity | The transaction is applied in full or nothing is applied |
| C | Consistency | On finishing, all the schema's rules are satisfied |
| I | Isolation | Simultaneous transactions do not interfere with each other |
| D | Durability | What is committed survives a power cut |
An example at BiblioRed: recording a loan inserts a row into loans and changes copies.status to on_loan. With ACID, either both things happen or neither does. You never end up with a loan of a copy that still shows as available.
BASE
The alternative acronym describing how many distributed systems behave:
| Letter | Name | What it means |
|---|---|---|
| BA | Basically Available | The system always answers, even if sometimes with old or partial data |
| S | Soft state | The system's state can change with no new write arriving, because the replicas are catching up |
| E | Eventually consistent | If writes stop arriving, all the replicas converge on the same value at some point |
The key word in BASE is "eventually", and it hides the question you always have to ask: eventually when? The theory does not say; practice does, and in well-sized systems it is usually milliseconds to a few seconds.
| ACID | BASE | |
|---|---|---|
| Priority | Correctness | Availability |
| Consistency | Immediate and guaranteed | Eventual |
| Availability on failure | May degrade | Is maintained |
| Complexity for the developer | Low: the manager takes care of it | High: the code manages the lag |
| Scaling | Harder to distribute | Designed to be distributed |
| Typical domains | Banking, inventory, billing, loans | Catalogs, social content, telemetry, caches |
A frequent and necessary warning: ACID and BASE are not a product choice, they are an operation choice. MongoDB can give you ACID in a multi-document transaction and BASE in a read from a secondary. PostgreSQL with asynchronous replicas also delivers eventually consistent reads. The correct label goes on the operation, not on the logo.
- What "eventually consistent" means for a BiblioRed reader
The theory is better understood with a concrete scene. Marta Alsina publishes a review of "The Map of Time" at 10:25:03.
sequenceDiagram
participant M as Marta (browser)
participant P as Primary
participant S as Secondary
participant I as Ivan (browser)
M->>P: 10:25:03.000 writes the review
P-->>M: 10:25:03.012 confirmed
Note over P,S: replication takes ~40 ms
I->>S: 10:25:03.030 asks for the book's reviews
S-->>I: returns 811 reviews - WITHOUT Marta's
P->>S: 10:25:03.052 the secondary applies the oplog
I->>S: 10:25:04.500 Ivan reloads the page
S-->>I: returns 812 reviews - with Marta's
For 40 milliseconds, two users of the same system see different realities. That is eventual consistency, and in this case it is completely harmless.
Now the variant that does hurt: Marta reloads her own page 20 ms later and the request lands on the secondary. Marta does not see her own review. And that is the scenario that generates support tickets, because the user is certain they pressed "publish" and the system appears to have lost it. They may even publish it again, and then there will be two.
This case has a name and a solution. It is called read-your-own-writes, and it is one of the session guarantees that distributed systems offer:
| Session guarantee | What it ensures |
|---|---|
| Read your own writes | A client always sees its own changes |
| Monotonic reads | A client never sees a version earlier than one it has already seen |
| Monotonic writes | A single client's writes are applied in its own order |
| Reads consistent with the write | If a write depended on a read, the order is respected |
MongoDB implements these guarantees with causally consistent sessions, enabled by default in modern drivers:
const session = db.getMongo().startSession({ causalConsistency: true })
const reviews = session.getDatabase("bibliored").getCollection("reviews")
// Marta publishes her review
reviews.insertOne({
_id: "REV-1813",
material: { material_id: "MAT-0331", title: "The Map of Time" },
member: { member_id: 14, display_name: "Marta Alsina" },
score: 5,
text: "A novel that plays with time without making the reader dizzy.",
status: "published",
date: new Date(),
schema_v: 2
})
// In the SAME session: she is guaranteed to see it, even reading from a secondary
reviews.countDocuments({ "material.material_id": "MAT-0331" })
session.endSession()The practical design rule, which holds for any distributed system:
After a write, read from the primary or inside a causal session. All the other reads —listings, searches, browsing— can go to secondaries with no problem. Distinguishing those two paths in the code is one of the best investments in a distributed system.
- Tunable consistency: quorum,
writeConcern and readConcern
writeConcern and readConcernModern systems do not force you to choose once and for all. They offer a dial per operation.
The idea of the quorum
With N replicas, you define how many have to confirm a write (W) and how many you read from (R). Strong consistency is guaranteed when:
With N = 3 replicas:
| W | R | W + R > N | Behavior |
|---|---|---|---|
| 1 | 1 | 2 > 3 ✗ | Blazing fast, but you can read old data |
| 2 | 2 | 4 > 3 ✓ | The usual balance: a majority on both sides |
| 3 | 1 | 4 > 3 ✓ | Slow, fragile write, blazing fast read |
| 1 | 3 | 4 > 3 ✓ | Blazing fast write, slow and fragile read |
The intuition behind the formula: if you write to 2 of 3 and read from 2 of 3, at least one node is in both sets and therefore holds the new data. The arithmetic is the whole trick.
writeConcern: how much confirmation I demand when writing
// Maximum safety: a majority of nodes confirms AND the data is on disk
db.reviews.insertOne(
{ _id: "REV-1814", material: { material_id: "MAT-0412" }, score: 5,
status: "published", date: new Date(), schema_v: 2 },
{ writeConcern: { w: "majority", j: true, wtimeout: 5000 } }
)// Maximum speed: I wait for nobody's confirmation
db.activity.updateOne(
{ _id: "ACT-14-2026-08-02" },
{ $push: { events: { t: new Date(), type: "record", material_id: "MAT-0331" } },
$inc: { event_count: 1 } },
{ upsert: true, writeConcern: { w: 0 } }
)Look at that acknowledged: false: the server has confirmed nothing. It is acceptable for an activity event —if one out of 17 million is lost, nothing happens— and unacceptable for a review the user believes they have published.
| Option | Meaning | Use at BiblioRed |
|---|---|---|
w: 0 |
No confirmation | Activity events |
w: 1 |
The primary has written it (the default) | Normal operations |
w: "majority" |
A majority of nodes has it | Reviews, moderation |
j: true |
Written to the journal, survives a power cut | Data that cannot be lost |
wtimeout |
Maximum milliseconds to wait | Always, with majority |
A warning about wtimeout: if it expires, the operation returns an error but it may have been applied anyway. The timeout limits how long you wait, not what happens on the server. The application has to be ready to retry idempotently.
readConcern: how much certainty I demand when reading
// Guaranteed read: only data confirmed by the majority, never reversible
db.reviews.find({ "material.material_id": "MAT-0331" })
.readConcern("majority").limit(3)
// Fast read: whatever the node has, even if it can be rolled back
db.catalog.find({ tags: "historical fiction" })
.readConcern("local").limit(10)| Level | What it returns | Use |
|---|---|---|
local |
What the node has, with no guarantees (the default) | Listings, searches |
majority |
Only what a majority has confirmed; never rolled back | Data the user has modified |
linearizable |
The most recent read possible, with maximum latency | Very rare; only for critical reads |
snapshot |
A coherent snapshot in time | Inside transactions |
The concept of a rollback explains why majority matters. If the primary accepts a write, goes down before replicating it and another node is elected primary, that write disappears: it never formed part of the majority. A client that had read it with local would have seen data that ceased to exist. With majority, that cannot happen.
- The border has blurred (I): PostgreSQL with
jsonb
jsonbHere is the nuance that makes almost every comparison written before 2016 obsolete.
PostgreSQL has the jsonb type: binary JSON, indexable and queryable with operators of its own. In other words: a relational database that stores documents.
CREATE TABLE catalog (
material_id VARCHAR(12) PRIMARY KEY,
type VARCHAR(20) NOT NULL,
title VARCHAR(200) NOT NULL,
language CHAR(2) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT ck_type CHECK (type IN ('book','dvd','magazine','audiobook'))
);
CREATE INDEX idx_catalog_metadata ON catalog USING GIN (metadata);
INSERT INTO catalog (material_id, type, title, language, metadata) VALUES
('MAT-0331','book','The Map of Time','es',
'{"isbn":"9788401339097","publisher":"Ediciones Vallmar","pages":612,
"tags":["historical fiction","science fiction"]}'),
('MAT-0802','magazine','Vallmar Cultural','ca',
'{"issn":"2604-1188","issue":42,"volume":7,"frequency":"monthly",
"tags":["local culture"]}'),
('MAT-0801','audiobook','The Map of Time','es',
'{"narrator":"Àlex Roure","duration_min":860,"format":"MP3",
"tags":["historical fiction","audio"]}');Look at what has just been achieved: fixed columns for what is common and validatable —with their PRIMARY KEY, their NOT NULL and their CHECK— and a free document for what is specific to each type. It is exactly the problem from section 1 of lesson 03-01, solved without leaving PostgreSQL.
-- Search by a key inside the JSON: the @> operator asks "does it contain this?"
SELECT material_id, title
FROM catalog
WHERE metadata @> '{"tags":["historical fiction"]}'; material_id | title
-------------+-----------------
MAT-0331 | The Map of Time
MAT-0801 | The Map of Time
(2 rows)-- Extract a field from the JSON and use it as a column
SELECT title,
metadata ->> 'publisher' AS publisher,
(metadata ->> 'pages')::int AS pages
FROM catalog
WHERE type = 'book'; title | publisher | pages
-----------------+-------------------+-------
The Map of Time | Ediciones Vallmar | 612
(1 row)-- Update a field inside the JSON without rewriting the rest
UPDATE catalog
SET metadata = jsonb_set(metadata, '{pages}', '618')
WHERE material_id = 'MAT-0331';The essential jsonb operators:
| Operator | What it does | Example |
|---|---|---|
-> |
Extracts a field as jsonb |
metadata -> 'tags' |
->> |
Extracts a field as text | metadata ->> 'publisher' |
#> / #>> |
Extracts by nested path | metadata #>> '{cover,large}' |
@> |
Does it contain this fragment? | metadata @> '{"language":"ca"}' |
? |
Does this key exist? | metadata ? 'issn' |
jsonb_set |
Modifies a value | jsonb_set(m, '{pages}', '618') |
jsonb_array_elements |
Unfolds an array (like $unwind) |
In a lateral FROM |
And the GIN index makes searches by JSON content use an index instead of scanning the table, just like a MongoDB multikey index.
What PostgreSQL gains with jsonb: schema flexibility for the heterogeneous part, without losing JOIN, multi-table ACID transactions, referential integrity or SQL.
What MongoDB still does better: partial updates on large documents, deeply nested arrays with rich operations ($push, $pull, the positional operator), built-in horizontal partitioning, and an ergonomics of working with documents that in SQL always feels somewhat more ceremonious.
A first-order practical consequence: if your only reason for adopting MongoDB is "I need to store some data with a variable shape", jsonb will very probably solve the problem without adding a database to your architecture. And one database fewer means fewer backups, less monitoring, fewer upgrades and one fewer expert you need to hire.
- The border has blurred (II): transactions in MongoDB
The reverse movement happened too. For years, "MongoDB has no transactions" was the definitive argument against it. That stopped being true:
- MongoDB 4.0 (2018): multi-document transactions in replica sets.
- MongoDB 4.2 (2019): distributed transactions, across shards too.
const session = db.getMongo().startSession()
const database = session.getDatabase("bibliored")
try {
session.startTransaction({
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" }
})
// 1. Publish the review
database.reviews.insertOne({
_id: "REV-1815",
material: { material_id: "MAT-0331", title: "The Map of Time", type: "book" },
member: { member_id: 16, display_name: "Nuria Bastos", branch_id: 3 },
score: 4,
text: "Very good atmosphere, although the pace drops in the middle section.",
status: "published", helpful_votes: 0, date: new Date(), schema_v: 2
}, { session: session })
// 2. Update the catalog's computed field (lesson 03-03)
database.catalog.updateOne(
{ _id: "MAT-0331" },
{ $inc: { "rating.total_reviews": 1,
"rating.score_sum": 4,
"rating.distribution.4": 1 } },
{ session: session }
)
session.commitTransaction()
print("Transaction committed: review and counters consistent")
} catch (e) {
session.abortTransaction()
print("Transaction aborted: " + e.message)
} finally {
session.endSession()
}This solves the consistency problem we left open in lesson 03-03: the review and the catalog's counter change in one piece. Either both or neither.
But there is small print, and it is important:
| Aspect | Reality |
|---|---|
| Cost | Noticeably more expensive than a simple write |
| Maximum duration | 60 seconds by default; beyond that, it aborts |
| Requirement | A replica set or a sharded cluster; it does not work on a standalone node |
| Philosophy | They are a safety net, not the everyday tool |
The manufacturer's own position puts it well: if your application needs transactions constantly, the document design is not the right one —or the domain wanted a relational database—. A good document design makes most operations atomic by nature, because everything that has to change together is in the same document. Exactly what we were after with the concept of the aggregate.
- The same query in both worlds
Let's compare the same real requirement: "materials in Spanish with the historical fiction tag, with their average score, ordered from best to worst, the first five".
-- PostgreSQL with jsonb
SELECT c.material_id,
c.title,
(c.metadata ->> 'publisher') AS publisher,
ROUND(AVG(r.score), 2) AS average,
COUNT(r.review_id) AS total_reviews
FROM catalog c
LEFT JOIN reviews r ON r.material_id = c.material_id AND r.status = 'published'
WHERE c.language = 'es'
AND c.active
AND c.metadata @> '{"tags":["historical fiction"]}'
GROUP BY c.material_id, c.title, c.metadata
HAVING COUNT(r.review_id) >= 3
ORDER BY average DESC NULLS LAST
LIMIT 5; material_id | title | publisher | average | total_reviews
-------------+------------------------------+-------------------+---------+---------------
MAT-0412 | The Pillars of the Earth | Ediciones Vallmar | 4.61 | 1240
MAT-0331 | The Map of Time | Ediciones Vallmar | 4.30 | 812
MAT-0508 | The Shadow of the Lighthouse | Faro Editorial | 4.12 | 97
(3 rows)// MongoDB, with the design from lesson 03-03
db.catalog.find(
{
language: "es",
active: true,
tags: "historical fiction",
"rating.total_reviews": { $gte: 3 }
},
{ title: 1, "metadata.publisher": 1, "rating.average": 1, "rating.total_reviews": 1 }
).sort({ "rating.average": -1 }).limit(5)[
{ _id: 'MAT-0412', title: 'The Pillars of the Earth',
metadata: { publisher: 'Ediciones Vallmar' },
rating: { average: 4.61, total_reviews: 1240 } },
{ _id: 'MAT-0331', title: 'The Map of Time',
metadata: { publisher: 'Ediciones Vallmar' },
rating: { average: 4.3, total_reviews: 812 } },
{ _id: 'MAT-0508', title: 'The Shadow of the Lighthouse',
metadata: { publisher: 'Faro Editorial' },
rating: { average: 4.12, total_reviews: 97 } }
]The honest comparison, point by point:
| Aspect | PostgreSQL with jsonb |
MongoDB |
|---|---|---|
| Data read | Crosses two tables and aggregates on the spot | A single document per material |
| Cost with 2,150 reviews per material | It aggregates on every execution | It reads an already computed number |
| Accuracy of the average | Always exact | Depends on the computed field being up to date |
| Write cost | None extra | Every review updates the catalog |
| A new, unforeseen query | You write it and it works | May require a redesign or $lookup |
| Code complexity | One declarative query | A simple query + maintaining the computed field |
What matters is not which one wins. It is that both are good solutions with different balances: PostgreSQL computes on reading and guarantees accuracy; MongoDB computes on writing and guarantees read speed. The choice depends on the ratio between reads and writes and on how much instant accuracy the business demands.
And notice something revealing: the computed field can also be done in PostgreSQL —with a materialized view or a column maintained by a trigger— and the on-the-fly computation can also be done in MongoDB with an aggregation pipeline. Modeling techniques cut across products; what changes is what the engine gives you out of the box.
- Decision guide
The eight questions, in order
- What is my data like? Homogeneous and heavily related → relational. Heterogeneous, nested, with a variable shape → document (or
jsonb). - Do I know my queries? If I cannot list them, or if they are going to change unpredictably → relational. Document modeling requires knowing them.
- Do I need transactions across entities as the rule? Yes → relational. Occasionally → either of the two.
- How much integrity does the business demand? If inconsistent data has legal or financial consequences → relational, with the guarantees on the server.
- What volume and what growth do I have? Less than a few hundred gigabytes and predictable growth → it fits on one node, and that rules out the scaling argument.
- How often does the schema change? Every week → document or
jsonb. Every six months →ALTER TABLEis not a problem. - What does my team know how to operate? It is a first-order technical question, not a human-resources detail.
- Do I need reporting and analysis? If the main use is dashboards and analysts → relational, because the tools speak SQL.
The default advice
Start with the relational database unless you have a clear reason not to, and be able to write that reason in one sentence.
It is not conservatism. It is that the relational model is the most general one: it works reasonably well for almost everything, it has the biggest ecosystem, the most available talent, and —with jsonb— it has absorbed a good part of the flexibility that motivated the NoSQL movement. Starting there and migrating what does not fit is far cheaper than the other way around.
BiblioRed's reason, written in one sentence, clears that bar: "the catalog has different metadata per material type, the review service changes shape every month and the activity log is a high volume of writes that needs no referential integrity".
flowchart TD
A["New system or subsystem"] --> B{"Cross-entity transactions<br/>or critical integrity?"}
B -->|Yes| REL["RELATIONAL<br/>PostgreSQL"]
B -->|No| C{"Do I know my<br/>queries well?"}
C -->|No| REL
C -->|Yes| D{"Heterogeneous data<br/>or a fast-changing schema?"}
D -->|No| REL
D -->|Yes| E{"Does it fit on one node<br/>and is the rest of the system<br/>already relational?"}
E -->|Yes| JB["RELATIONAL + jsonb<br/>one database fewer to operate"]
E -->|No| DOC["DOCUMENT<br/>MongoDB"]
- Myths dismantled
Myth 1: "NoSQL is faster".
Faster at what? Reading a complete aggregate by its key is faster in MongoDB than rebuilding it with five JOINs. A complex aggregation over normalized data is usually faster in PostgreSQL. And most real performance problems are solved with an index, not with a change of engine. Speed depends on the fit between the design and the query, not on the logo.
Myth 2: "NoSQL has no schema".
It has a schema. It lives in the application code instead of on the server. The difference is who checks it and when it fails: on the INSERT and automatically, or on reading and six months later. An unpoliced schema is not the absence of a schema: it is a schema with no guarantees.
Myth 3: "Relational databases do not scale". They scale extraordinarily well vertically, and a single well-tuned PostgreSQL instance handles hundreds of gigabytes and thousands of transactions per second. What does not scale easily is distributed writes across several nodes, which is a far less frequent need than people believe. Most systems that "need to scale" actually need an index and a better written query.
Myth 4: "MongoDB loses data".
It was a legitimate criticism around 2011, when the default configuration did not wait for write confirmation. For years now the default has been w: 1, and with w: "majority" and j: true the guarantees are comparable to those of a relational database with synchronous replicas. Data is lost through inadequate configuration, not through the product.
Myth 5: "With NoSQL you do not need to design". It is the most expensive myth. The whole of lesson 03-03 exists because you need to design more, not less: with no normalization and no foreign keys to correct mistakes, a bad document model has no safety net.
Myth 6: "You have to pick one and use it for everything". False for a long time now, and it is precisely the subject of the next section.
- Polyglot persistence: BiblioRed's architecture
The term was coined by Martin Fowler in 2011 by analogy with polyglot programming, and it describes a simple idea:
Polyglot persistence: deliberately using several storage engines within the same system, choosing for each type of data the one that handles it best, instead of forcing everything into a single engine.
It is exactly where BiblioRed has ended up:
flowchart TD
APP["BiblioRed's portal and<br/>management application"]
APP --> PG["PostgreSQL - biblioredb<br/>branches - members - authors - books<br/>copies - loans - reservations<br/>ACID, integrity, reporting"]
APP --> MG["MongoDB - bibliored<br/>catalog - reviews - activity<br/>flexible schema, aggregates"]
PG -->|hourly synchronization<br/>of availability| MG
MG -.->|nightly process:<br/>precomputed recommendations| MG
| Need | Engine | Why |
|---|---|---|
| Loans, members, copies, reservations | PostgreSQL | Transactions, referential integrity, unpredictable queries, management reports |
| Enriched catalog | MongoDB | Heterogeneous metadata per material type |
| Reviews | MongoDB | Self-contained aggregate, a schema that changes every month |
| Activity log | MongoDB | High write volume with no need for integrity |
| Recommendations | Precomputation → MongoDB | 12,000 members do not justify deploying Neo4j |
| Cache and sessions | Deferred | There is still no latency problem to solve |
The price of polyglot persistence
It is worth saying clearly, because architecture presentations almost never do:
- Consistency across engines: availability lives in PostgreSQL and is copied to MongoDB. That copy can fall behind, and somebody has to watch over it.
- No transactions across engines: there is no way to make a write in PostgreSQL and one in MongoDB atomic with respect to each other. There are patterns to mitigate it, but none of them is free.
- Double operations: two products to upgrade, two backup and restore schemes, two permission systems, two sets of metrics.
- Double expertise on the team: you have to know how to design and tune both.
- Reports that cross engines: any analysis combining loans and reviews requires an integration layer, an analytical store or an extraction process.
The rule that sums up professional judgment:
Every engine you add has to justify its operating cost with an advantage you cannot get any other way. Two well-chosen and well-operated engines are a good architecture. Five engines chosen out of curiosity are technical debt with a payroll of its own.
And that is why BiblioRed has stopped at two: Redis, Cassandra and Neo4j were deferred not because they did not fit —they fit very well, as we saw in 03-02— but because their advantage does not yet outweigh the cost of operating them.
This architecture will be developed in full in lesson 08-03, Case Study: Polyglot Persistence, with the data flow between engines, the synchronization processes, failure handling and the system's planned evolution. Before that, lessons 08-01 and 08-02 will analyze a relational case and a document case separately.
Common Mistakes and Tips
Mistake 1: repeating "pick two of three" when talking about CAP. It is the incorrect formulation and whoever hears it comes away with a wrong idea. The partition is not chosen: it happens. The choice is between C and A when there is a partition, and the rest of the time you have both.
Mistake 2: confusing the C of CAP with the C of ACID. The C of CAP is "all the nodes see the same thing". The C of ACID is "the schema's rules are satisfied when the transaction ends". Same letter, different concepts.
Mistake 3: reading from a secondary right after writing. It is the number one cause of "the system has lost my change" incidents. Solution: read from the primary after writing, or use causally consistent sessions.
Mistake 4: using w: 0 for data that matters.
acknowledged: false literally means nobody has confirmed anything. It is only acceptable for data you can afford to lose.
Mistake 5: deciding with 2013's comparisons.
jsonb and multi-document transactions changed the terrain. An argument of the type "MongoDB has no transactions" or "PostgreSQL cannot handle flexible data" is ten years out of date.
Mistake 6: adopting polyglot persistence for elegance. Every extra engine multiplies the cost of operation. If in doubt, do not add it: you can always add it later with data that justifies it.
Tip 1: write the decision down, do not just make it. A half-page document —context, options considered, decision, reason, accepted consequences— for every engine choice. In two years' time, when somebody asks "why MongoDB?", that half page is worth its weight in gold.
Tip 2: tune consistency per operation, not per system.
It is the PACELC lesson taken into the code: w: "majority" to publish a review, w: 0 to record a browsing event. The same database, two different guarantees, each one justified.
Tip 3: try jsonb before adding MongoDB.
If you already have PostgreSQL and your problem is only schema flexibility, an afternoon of experimenting with jsonb and a GIN index can save you a whole engine.
Tip 4: measure replication latency in production. "Eventually consistent" says nothing useful until you know that in your system it is 40 ms. With that number you can decide which reads go to the primary; without it, you can only guess.
Exercises
Exercise 1
For each of these four BiblioRed operations, state: (a) which writeConcern or readConcern you would use, (b) whether the read should go to the primary or can go to a secondary, and (c) whether it is a scenario where you would prioritize consistency or availability in the face of a network partition. Justify every answer.
- Recording the return of a copy with a fine of €3.50.
- Showing the list of reviews of "The Pillars of the Earth" to an anonymous visitor.
- Recording that a member has opened a material's record.
- Publishing a review and, immediately afterwards, redirecting the reader to the material's page where they should see it.
Exercise 2
A colleague states: "Let's migrate the whole of BiblioRed to MongoDB because NoSQL scales better and is faster, and that way we have a single database." Write a technical and respectful reply addressing the three arguments —scaling, speed and unification— with the concepts from this lesson, and propose a concrete alternative.
Exercise 3
BiblioRed wants to add personal reading lists: each member can create lists with a name, a description, a visibility (private/public) and between 5 and 200 materials, and can follow other members' public lists. Expected queries: see my lists; see a list with its materials' data; search public lists by tag; count a list's followers.
Decide where this feature lives —PostgreSQL, PostgreSQL with jsonb, or MongoDB— by going through the eight questions of section 10, and write the resulting schema or document.
Solutions
Solution 1
1. Return with a €3.50 fine.
(a) writeConcern: { w: "majority", j: true }. There is money involved: the write has to be confirmed by a majority and be on disk, so that it survives a primary going down and cannot be rolled back. (b) Read from the primary, with readConcern: "majority". (c) Consistency. Faced with a partition, it is preferable for the front desk to show "service unavailable, try again in a minute" than to record a fine that later disappears or gets duplicated. Besides, this operation actually lives in PostgreSQL: it touches loans and copies atomically and it is a textbook ACID case.
2. List of reviews for an anonymous visitor.
(a) readConcern: "local". (b) A secondary, without hesitation: it is the portal's most frequent read path and offloading the primary is exactly what secondaries exist for. (c) Availability. A visitor seeing 811 reviews instead of 812 for a few milliseconds has no consequences whatsoever; the catalog page returning an error does.
3. Recording the opening of a record.
(a) writeConcern: { w: 0 }. It is one telemetry event among 17 million a year; losing some does not change any report and waiting for confirmation would penalize browsing latency (requirement C6: under 10 ms). (b) Not applicable: it is a pure write. (c) Availability, absolutely. Faced with a partition, the right thing is to discard the event silently rather than degrade the browsing experience or queue data indefinitely.
4. Publishing a review and redirecting to the material's page.
(a) A write with writeConcern: { w: "majority" } —the user believes they have published it and it cannot disappear— and a subsequent read with readConcern: "majority". (b) The primary, or inside a causally consistent session. It is the canonical read-your-own-writes case: if the subsequent read lands on a lagging secondary, Marta does not see her own review, believes it has been lost and probably publishes it again. (c) Consistency, but for a reason of perception rather than correctness: the cost of a user duplicating their content is greater than that of a momentary error message.
What the set illustrates: four operations in the same system with four different configurations. It is PACELC applied in practice.
Solution 2
Thanks for raising it; it is worth analyzing the three arguments separately, because I think one of them points at something real and the other two do not.
On scaling. It is true that MongoDB scales better horizontally. The question is whether we need that: BiblioRed has 12,000 members, 40,000 copies and around 17 million activity events a year. That fits comfortably in a single PostgreSQL instance on ordinary hardware, and in fact our current bottleneck is not volume. Migrating for a scaling capability we are not going to use leaves us with the cost of the migration and none of the benefit.
On speed. "Faster" depends on the operation. MongoDB is faster at reading a complete aggregate by its key; PostgreSQL is faster at aggregating normalized data, which is exactly what our monthly loans-per-branch reports do. Besides, most of the performance problems we have had were solved with an index. Before changing engine, I propose measuring the three slowest queries with
EXPLAIN: if the problem is indexes, the migration does not fix it.On unifying into a single database. This is the strongest argument: two engines cost twice the backups, monitoring and expertise on the team. But unifying into MongoDB would cost us something dearer: we would lose the referential integrity of the loans and the ACID transactions that guarantee that a loan and its copy's status change in one piece. Today the server gives us that for free; in MongoDB we would have to write and maintain it ourselves, and every mistake that slips through ends up at the front desk.
An alternative proposal. If the real goal is to simplify, let's explore the opposite route: moving the catalog and the reviews into PostgreSQL using
jsonbcolumns with GIN indexes. That would give us the schema flexibility we were after —different metadata per material type, new fields with noALTER TABLE— without giving upJOINs, transactions or integrity, and with a single database, which is what you wanted. I propose a one-week proof of concept with the real catalog data and a measured comparison of the five most frequent queries. Ifjsonbdoes not measure up, we will have a solid argument for keeping MongoDB, written down and with numbers.
Solution 3
Going through the eight questions
| # | Question | Answer for reading lists |
|---|---|---|
| 1 | What is the data like? | Homogeneous: every list has the same structure. This is not a heterogeneity case |
| 2 | Do I know my queries? | Yes, all four are listed |
| 3 | Transactions across entities? | Very little: adding a material to a list affects the list and a counter |
| 4 | Integrity? | Medium-high: a list pointing at nonexistent materials looks terrible in the portal |
| 5 | Volume? | 12,000 members × a few lists × up to 200 materials. Small |
| 6 | Does the schema change? | Little: it is a feature with a stable shape |
| 7 | What does the team operate? | Both engines; it does not break the tie |
| 8 | Reporting? | Some: most followed lists, most listed materials |
Decision: MongoDB. And not because of questions 1 and 6, which pointed at relational, but because of the aggregate. A reading list meets the three conditions from lesson 03-03: it is read whole (the main query is "see a list with its materials"), it is written together, and it has a clear root. With a cap of 200 materials it is a textbook one-to-few case, and with an extended reference (title, type and cover) the main query is resolved in a single access, with no JOIN against catalog.
The decisive argument is question 4 combined with the pattern: integrity matters, but it is display integrity, not business integrity. If a material is withdrawn from the catalog and remains in a list, the record shows "material unavailable" and nothing serious happens. That does not demand a FOREIGN KEY.
The followers go outside: they grow with no known cap (a popular list can have thousands) and they are queried separately ("lists I follow"). It is one-to-many, so its own collection with a computed counter on the list.
{
"_id": "LST-00471",
"schema_v": 1,
"owner": {
"member_id": 14,
"display_name": "Marta Alsina",
"branch_id": 1
},
"name": "Historical fiction for the summer",
"description": "What I am taking to the beach this year. Nothing under 400 pages.",
"visibility": "public",
"tags": ["historical fiction", "summer", "recommendations"],
"materials": [
{ "material_id": "MAT-0331", "title": "The Map of Time",
"type": "book", "cover": "/img/catalog/0331-s.webp",
"added": "2026-06-02T18:20:00Z", "note": "Start with this one." },
{ "material_id": "MAT-0412", "title": "The Pillars of the Earth",
"type": "book", "cover": "/img/catalog/0412-s.webp",
"added": "2026-06-02T18:22:00Z", "note": null },
{ "material_id": "MAT-0801", "title": "The Map of Time",
"type": "audiobook", "cover": "/img/catalog/0801-s.webp",
"added": "2026-06-14T09:05:00Z", "note": "Narrated version, for the car." }
],
"material_count": 3,
"follower_count": 47,
"created": "2026-06-02T18:18:00Z",
"updated": "2026-06-14T09:05:00Z"
}{
"_id": "FOL-LST-00471-16",
"list_id": "LST-00471",
"member_id": 16,
"display_name": "Nuria Bastos",
"since": "2026-06-20T11:40:00Z",
"notifications": true
}Indexes: { "owner.member_id": 1, updated: -1 } for "my lists"; { visibility: 1, tags: 1, follower_count: -1 } for the public list search; and in list_followers, { member_id: 1 } and { list_id: 1 }.
Validation ($jsonSchema): maxItems: 200 on materials —turning the design cap into a rule checked by the server— and enum: ["private","public"] on visibility.
A final note of honesty. This feature would also work perfectly well in PostgreSQL with two tables and a foreign key, with the added advantage that the server would guarantee the integrity. The decision leans towards MongoDB because the catalog is already there —the extended reference is resolved inside the same engine— and because the aggregate pattern fits naturally. If the catalog lived in PostgreSQL, the correct answer would be the opposite one. A subsystem's decision depends on where the rest of the system lives, and that is a criterion no comparison table captures.
Conclusion
This lesson has put the course's two halves face to face and closed the outstanding theory:
- The dimension-by-dimension comparison shows there is no winner: there are different balances. The two most underrated rows are available talent and the ability to answer unforeseen queries, both in favor of the relational model.
- The CAP theorem is not "pick two of three". The partition is not chosen, it happens; the real choice is between consistency and availability when there is a partition, and while there is none you have both. MongoDB is CP, Cassandra is AP by default and tunable.
- PACELC adds what is missing: even with no partition you have to choose between latency and consistency, because synchronizing replicas costs time. Consistency is not free even when everything works.
- ACID guarantees immediate correctness; BASE —basically available, soft state, eventually consistent— guarantees availability and convergence. They are not product labels but operation labels: the same database can offer both.
- Eventual consistency means, concretely, that for a few tens of milliseconds two users can see different realities. Harmless almost always, except in read-your-own-writes, which is solved by reading from the primary or with causally consistent sessions.
- Consistency is tunable per operation: the quorum rule
W + R > N,writeConcern(w: 0,w: 1,w: "majority",j: true,wtimeout) andreadConcern(local,majority,linearizable,snapshot). - The border has blurred. PostgreSQL stores documents with
jsonb, indexes them with GIN and queries them with@>,->>andjsonb_set, without giving upJOINs, transactions or integrity. MongoDB has had multi-document transactions since 2018, with the caveat that they are a safety net and not the everyday tool. Deciding with 2013's arguments is the most common way of getting it wrong. - The decision guide is eight questions —shape of the data, predictability of the queries, transactions, integrity, volume, rate of schema change, team competence and analytical needs— and one default piece of advice: start relational and be able to write in one sentence the reason for not doing so.
- Six myths dismantled: NoSQL is not "faster" in the abstract, it does have a schema (unpoliced), relational databases do scale (vertically), MongoDB does not lose data with the right configuration, with NoSQL you have to design more, and you do not have to pick a single engine.
- Polyglot persistence is deliberately using several engines, each for what it handles best. It has a real price —consistency across engines, no cross-engine transactions, double operations, double expertise, reports that cross systems— and that is why every engine added has to justify its cost with an advantage that cannot be obtained any other way.
This brings module 3 to a close. We have understood why NoSQL was born and what it gives up in exchange for what, we have toured its four families with real operation, we have learned to design documents from the queries —with their patterns, their anti-patterns and their recoverable guarantees— and we have compared the two worlds with the concepts that govern any distributed system. BiblioRed has come out of here with a complete and, above all, justified architecture: PostgreSQL for what demands integrity and transactions, MongoDB for what demands flexibility and volume, and three more engines consciously deferred with a written criterion for when they would stop being so. In module 4, Schema Design, we go back to the very beginning with a more demanding eye: before writing a CREATE TABLE or a document, you have to design. We will see the principles of good schema design, entity-relationship diagrams as a language for thinking about a domain before touching the keyboard, how those diagrams are transformed into relational schemas, and how to choose the data types and constraints that will make the schema stand the test of time.
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
