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

  1. Comparison dimension by dimension
  2. The CAP theorem, properly explained
  3. PACELC: the necessary refinement
  4. ACID versus BASE
  5. What "eventually consistent" means for a BiblioRed reader
  6. Tunable consistency: quorum, writeConcern and readConcern
  7. The border has blurred (I): PostgreSQL with jsonb
  8. The border has blurred (II): transactions in MongoDB
  9. The same query in both worlds
  10. Decision guide
  11. Myths dismantled
  12. Polyglot persistence: BiblioRed's architecture
  13. Common mistakes and tips
  14. Exercises
  15. Conclusion

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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()
{ acknowledged: true, insertedId: 'REV-1813' }
812

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.

  1. Tunable consistency: quorum, writeConcern and readConcern

Modern 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:

W + R > N

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 } }
)
{ acknowledged: true, insertedId: 'REV-1814' }
// 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 } }
)
{ acknowledged: false }

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.

  1. The border has blurred (I): PostgreSQL with jsonb

Here 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"]}');
INSERT 0 3

Look at what has just been achieved: fixed columns for what is common and validatable —with their PRIMARY KEY, their NOT NULL and their CHECKand 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';
UPDATE 1

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.

  1. 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()
}
Transaction committed: review and counters consistent

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.

  1. 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.

  1. Decision guide

The eight questions, in order

  1. What is my data like? Homogeneous and heavily related → relational. Heterogeneous, nested, with a variable shape → document (or jsonb).
  2. Do I know my queries? If I cannot list them, or if they are going to change unpredictably → relational. Document modeling requires knowing them.
  3. Do I need transactions across entities as the rule? Yes → relational. Occasionally → either of the two.
  4. How much integrity does the business demand? If inconsistent data has legal or financial consequences → relational, with the guarantees on the server.
  5. 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.
  6. How often does the schema change? Every week → document or jsonb. Every six months → ALTER TABLE is not a problem.
  7. What does my team know how to operate? It is a first-order technical question, not a human-resources detail.
  8. 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"]

  1. 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.

  1. 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.

  1. Recording the return of a copy with a fine of €3.50.
  2. Showing the list of reviews of "The Pillars of the Earth" to an anonymous visitor.
  3. Recording that a member has opened a material's record.
  4. 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 jsonb columns with GIN indexes. That would give us the schema flexibility we were after —different metadata per material type, new fields with no ALTER TABLE— without giving up JOINs, 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. If jsonb does 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) and readConcern (local, majority, linearizable, snapshot).
  • The border has blurred. PostgreSQL stores documents with jsonb, indexes them with GIN and queries them with @>, ->> and jsonb_set, without giving up JOINs, 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.

© Copyright 2026. All rights reserved