What this module covers: "SQL or NoSQL?" is the wrong question, and this module replaces it with the right one: what are your access patterns, and which storage shape serves them? It covers access-pattern-first modelling as a method, the four broad datastore shapes and what each genuinely costs, an honest examination of the claim that NoSQL "scales better" (it scales because it removes the features that are hard to distribute), the case for Postgres as a default and where that case ends, the one decision that's hardest to reverse, and the real price of polyglot persistence — including the dual-write problem that makes two stores harder than twice one store.
Ask About Access Patterns, Not About Databases
The question "should we use SQL or NoSQL?" cannot be answered, because the categories aren't comparable — one names a query language, the other names everything that isn't it. The answerable question is:
What are the queries this system will run, how often, and how much data will they touch?
Answer that and the datastore choice mostly follows. Skip it and you'll pick a datastore for reasons that have nothing to do with your workload — familiarity, a conference talk, or a benchmark measuring something you don't do.
The method, in order:
Enumerate the access patterns. Every read and write the system performs, as concrete statements.
Attach volume and latency to each. Which are hot, which are rare, which are user-facing.
Identify which patterns are known and fixed, and which are unknown or ad-hoc.
Then choose.
text
That list already decides most of the design. R1 and R2 are key-based and would suit almost anything. W1 needs a multi-row atomic transaction, which rules out several options or makes them much harder. R4 is unknown at design time — and unknown access patterns are the single strongest argument for a relational database, because SQL lets you ask questions you didn't plan for.
Analogy: choosing a datastore is choosing a kitchen for a menu you're about to write. A sushi counter and a pizza oven are both excellent and neither is general-purpose. If you don't know the menu yet, you want the kitchen with a stove, an oven, and a fridge — less specialised at everything, capable of anything.
Why this matters in production: the expensive failure isn't picking a "slow" database. It's picking one whose data model can't express a query the business asks for later. "Show me all orders over $500 from customers in Karnataka, sorted by name" is thirty seconds of SQL and a genuine engineering project on a store designed for single-key access.
The Four Shapes
Relational (Postgres, MySQL)
Tables, rows, typed columns, foreign keys, joins, ACID transactions, a declarative query language, and a planner that decides execution for you.
Good at: ad-hoc queries, multi-entity transactions, enforced integrity, aggregation, and adapting to access patterns you didn't anticipate.
Costs: horizontal write scaling is genuinely hard (Module P-6), joins across very large tables get expensive, and schema changes on huge tables need care (Module O-4).
Document (MongoDB, DynamoDB in document mode, Postgres JSONB)
Self-contained documents, usually JSON, retrieved by key. Related data is nested rather than joined.
Good at: aggregates that are read and written as a unit — a product with its variants, a form submission, a CMS page. Flexible shape per document. Fetching the whole entity in one read.
Costs: the nesting that makes reads fast makes cross-document queries hard. Data duplicated across documents must be updated in many places, and a single document has an ACID boundary while a multi-document update generally doesn't (support varies and comes with caveats). Flexible schema means the schema moved into your application code, where nothing enforces it — you will find documents from three eras of your product in the same collection.
Key-Value (Redis, DynamoDB, etcd)
Get and put by key. That's the interface, and the constraint is the point.
Good at: the fastest possible lookup, sessions, caches, feature flags, counters, rate limiters, leaderboards. Scales horizontally almost trivially because keys are independent.
Costs: you can only find things by key. No "all items where X" without maintaining your own secondary index — which means every query pattern you want must be designed in advance and kept consistent by your own code.
Rows grouped by a partition key, sorted within a partition by a clustering key. Built for enormous write throughput and linear horizontal scaling.
Good at: massive write volume, time-series and event data, predictable single-partition queries, multi-datacentre replication as a first-class feature.
Costs: the largest of any option here — you design the table for the query, and a query you didn't design for may be impossible. No joins. Aggregations are your problem. Getting the partition key wrong produces hot partitions or unbounded partitions, and changing it means rewriting the data (Modules P-6, P-9). Frequently you store the same data several times, once per access pattern, and keep the copies consistent yourself.
Graph, time-series, search, and vector stores are also real categories with real justifications — Modules P-9, P-20, and P-21 cover them once there's enough context to compare them properly.
"NoSQL Scales Better" — Examined
The claim is true and the reason matters more than the claim: these systems scale better because they removed the features that are hard to distribute.
No joins? Then no node needs data from another node to answer a query.
No multi-key transactions? Then no distributed coordination protocol, no two-phase commit, no locks held across the network (Modules A-1, A-3).
No ad-hoc queries? Then every query hits a known partition and the system can guarantee its performance.
Eventual consistency? Then a write doesn't wait for agreement from other replicas (Modules P-10, P-11).
That's not magic; it's a trade, and it's a good trade when you genuinely don't need what was removed. The failure mode is choosing the scalable option for a workload that needed joins and transactions, and then rebuilding them badly in application code — where they'll be slower, less correct, and yours to maintain. Application-level joins and hand-rolled transactions are the two most common sources of regret in this space.
The honest counter-argument to over-choosing relational: a single Postgres primary does have a write ceiling, and reaching it means sharding, which is hard. If you know you're heading for a million writes per second, choosing a horizontally scalable store from the start is correct rather than premature. The key word is know — from a real, computed estimate (Module F-3), not an aspiration.
Postgres as the Default (and Where That Ends)
Modern Postgres covers more shapes than people expect:
Need
Postgres feature
Honest limit
Documents
JSONB + GIN indexes
Less ergonomic than Mongo's tooling; no per-document schema evolution help
Key-value
Simple table, or UNLOGGED for speed
Nowhere near Redis latency; connections are expensive (Module P-4)
Queue
SELECT ... FOR UPDATE SKIP LOCKED
Fine to thousands/s; not Kafka's throughput or replay (Modules P-14, P-15)
Full-text search
tsvector + GIN
No fuzzy matching, tuneable relevance, or faceting like a real engine (Module P-20)
Vector search
pgvector
Excellent to millions of vectors; dedicated stores win at billions (Module P-21)
Time-series
Native partitioning, or TimescaleDB
Not a specialised TSDB's compression or downsampling (Module P-9)
Geospatial
PostGIS
Genuinely best-in-class, no caveat needed
The argument for using one system that does eight things adequately instead of eight systems that each do one thing excellently is not laziness — it's an accounting of total cost:
One thing to back up, restore, patch, monitor, and fail over. Multiply by eight and Module O-7's "prove you can restore it" becomes eight proofs.
One transaction boundary. Writing an order and enqueueing its confirmation job in the same Postgres transaction is atomic. Split them across Postgres and SQS and you have the dual-write problem below.
One set of expertise. A team that knows one datastore deeply outperforms a team that knows five superficially, and the incidents happen at 3am.
Where the default ends — genuine reasons to add a store:
A hard scale requirement the single node can't meet, computed rather than assumed.
An access pattern that's a poor fit, where the specialised system is an order of magnitude better rather than 20% better — real search relevance, graph traversal depth, billions of vectors.
A latency requirement below what a Postgres round trip with a real connection can deliver, which is a legitimate reason for Redis.
The Decision, in Questions
Are the access patterns known and stable, or will unanticipated queries arrive? Unknown patterns → relational. This dominates most decisions.
Do writes need to be atomic across multiple entities? Yes → relational, or accept sagas and compensation (Module A-3).
What's the write volume, computed? Under a few thousand per second is comfortably single-node territory.
Is the data naturally one self-contained aggregate, or a graph of relationships? Aggregate → document. Graph → relational or a graph store.
How stale can a read be? Zero tolerance narrows the options considerably (Module P-11).
Which queries are ad-hoc and which are hot paths? Ad-hoc needs a query language; hot paths need a designed index or key.
What does the team already run well? Not a tiebreaker — a real input, because operational competence is a design constraint (Module F-1).
The Hardest Thing to Change
Some decisions are cheap to revise. Adding an index, adding a column, moving a query — routine. The one that isn't:
The partition key / primary key. How rows are keyed and distributed determines which queries are fast, which are possible, and how the data can grow. Changing it means rewriting every row, and on a large table that's a migration project with a rollback plan, not a schema change.
So spend disproportionate time on it. If a wide-column store's partition key is wrong, you will be rebuilding tables. If a relational primary key is a random UUID on a huge table, you'll be paying for it on every insert for years (Module P-3). This is the schema equivalent of a load-bearing wall.
Polyglot Persistence and the Dual-Write Problem
Using several stores — Postgres for transactions, Redis for cache, Elasticsearch for search, S3 for blobs — is normal and often correct. The cost is more than additive, and here's the specific reason:
typescript
If the second write fails — a network blip, a restart between the two lines, a timeout — the stores now disagree, permanently and silently. The order exists and is unsearchable. Reversing the order of the writes just moves the inconsistency. Wrapping them in a try/catch and retrying helps sometimes and not when the process dies in between.
There is no way to make two independent systems atomic with two independent writes. The actual solutions all funnel through one committed source of truth:
The Outbox pattern: write the order and an outbox row in one Postgres transaction, then a separate process reads the outbox and updates Elasticsearch, retrying until it succeeds. Atomicity comes from the single transaction; the second system converges (Module A-3).
Change Data Capture: derive the search index from Postgres's WAL, so the database's own commit log is the trigger and nothing can commit without being captured (Module P-23).
Accept and reconcile: a periodic job detects and repairs divergence. Legitimate when brief inconsistency is tolerable and the reconciliation genuinely runs.
Why this matters in production: every additional store multiplies consistency questions, and this is the mechanism behind "the search results don't match the database" complaints that never fully go away. Add stores deliberately, and when you do, decide up front how they stay in sync.
Three Worked Choices
A SaaS product's core application data — customers, subscriptions, invoices, usage. Access patterns partly unknown (every customer eventually asks for a report nobody planned), multi-entity transactions everywhere, moderate volume. → Postgres. This is not a close call, and adding anything else early is unpaid complexity.
Clickstream events for analytics — 50,000 writes/s, append-only, queried by time range and aggregated. Access patterns known, no transactions, enormous volume. → A wide-column or time-series store, or a columnar warehouse fed by a stream (Modules P-9, P-22). Putting this in the transactional Postgres is the classic mistake — the write volume and the analytical scans will both hurt the OLTP workload.
A user session store — read on every request, written on login, expires. Single-key access, latency-critical, loss is tolerable (users log in again). → Redis. Postgres would work and would add a database round trip to every request for no benefit.
Notice each answer came from the access patterns, and in each case a different choice would have been defensible for a different pattern list. That's the method working.
Where This Shows Up in Your Stack
PostgreSQL: the default, and the table above is the reason — JSONB with GIN for document shapes, SKIP LOCKED for queues, tsvector for search, pgvector for embeddings, PostGIS for geospatial. Two rules keep the default honest: model from the access patterns rather than from the object graph, and treat the primary/partition key as the load-bearing decision (Module P-3), because it is the one change that means rewriting every row. Reach past Postgres when a specialised store is an order of magnitude better, not 20% better.
Node.js: an ORM makes the object model easy and the access-pattern question invisible, which is how a schema ends up shaped like your classes rather than your queries. Write the important queries first, then let the schema follow. And beware the convenience of include/populate chains — they generate the N+1 shape that Module F-8 covers, which no datastore choice fixes.
Redis: the legitimate second store when a Postgres round trip on every request buys nothing — sessions, hot key-value reads, ephemeral counters. Note what you are accepting: no transaction shared with your source of truth, and durability as a configuration decision (Modules P-12, P-14).
Wide-column and time-series stores: chosen when the access pattern is known and the volume is computed, not assumed — and the partition key must be designed against the queries before the first row is written (Module P-9).
Search, vector and analytics stores: each is a derived copy, which means each brings the dual-write problem with it. Decide the sync mechanism — outbox or CDC — at the moment you add the store, not after the first "the search results don't match the database" report (Modules P-20, P-21, P-22, P-23).
Object storage: the right home for bytes, which do not belong in a database column at all (Module A-11).
Summary
"SQL or NoSQL" is unanswerable. "What are the access patterns?" is answerable — enumerate reads and writes, attach volume and latency, note which are unknown, then choose.
Unknown future access patterns are the strongest argument for relational, because SQL answers questions you didn't design for.
The four shapes: relational (ad-hoc queries, transactions, integrity — hard write scaling), document (self-contained aggregates — hard cross-document queries and schema drift into app code), key-value (fastest lookup — key access only), wide-column (huge write throughput and linear scaling — the query must be designed into the table).
NoSQL scales better because it removed joins, multi-key transactions, and ad-hoc queries — the features that are hard to distribute. A good trade if you don't need them; regret if you rebuild them in application code.
Postgres covers documents, key-value, queues, search, vectors, time-series, and geospatial adequately — worth defaulting to for one backup story, one transaction boundary, and one body of expertise. It ends at computed scale limits, order-of-magnitude fit gaps, and sub-round-trip latency needs.
The partition/primary key is the hardest decision to reverse. Spend time proportional to that.
Polyglot persistence costs more than additively because of the dual-write problem: two independent writes can't be atomic. Use the Outbox pattern, CDC, or explicit reconciliation — decided up front.
The next module, Reverse Proxies, API Gateways, and CDNs, returns to the request path with the layer that sits in front of everything: what to terminate, what to route, what to cache at the edge, and how to invalidate content you already told the world to keep.
Knowledge Check
A team is designing an order service. Reads are almost entirely by order ID and by customer ID; writes must atomically decrement inventory. Business stakeholders can't say what reports they'll need. Which reasoning does the module endorse?
How does the module characterise the claim that NoSQL databases "scale better" than relational ones?
A service writes each new order to Postgres, then indexes it into Elasticsearch. Occasionally an order exists in Postgres but never appears in search. What does the module say?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Access patterns — order management service
Reads:
R1 Get one order by ID ~2,000/s p99 < 20ms
R2 List a customer's orders, newest first ~400/s p99 < 50ms
R3 Search orders by status + date range ~20/s p99 < 500ms (internal)
R4 "Revenue by product category, last quarter" ad-hoc, unpredictable
Writes:
W1 Create order ~200/s must be atomic with
inventory decrement
W2 Update order status ~600/s
// ⚠️ The dual-write problem. There is no transaction spanning these two systems.asyncfunctioncreateOrder(input: OrderInput){const order =await postgres.orders.insert(input);// ✓ committedawait elasticsearch.index('orders', order);// ✗ what if this fails?return order;}