SQL vs NoSQL — and why, not just names — in depth
The summary said "pick by access pattern, not by which word sounds bigger". This page goes further: what ACID means one word at a time, how relational databases scale before you give up on them, all four kinds of NoSQL and what each is for, what BASE means, and a decision procedure you can run in an interview instead of guessing.
What a relational database gives you
Data lives in tables of rows and columns, the shape is fixed by a schema, and rows in different tables reference each other by id. That is the familiar part. The valuable part is what the database promises about *changes*.
A transaction is a group of changes that happen together or not at all. Transfer money between two accounts and you have two writes: subtract from one, add to the other. A transaction guarantees you never get one without the other, even if the machine loses power between them.
That guarantee has a four-letter name, and it is worth taking one word at a time rather than reciting the acronym.
- Atomicity: all the changes happen, or none do. No half-finished transfers.
- Consistency: the database refuses to end up in a state that breaks your rules. A foreign key must point at something real; a balance column marked non-negative cannot go negative.
- Isolation: concurrent transactions do not see each other's half-finished work. At the strictest level, two people transferring at once get a result as if they had gone one after the other; weaker levels allow the anomalies in the table below.
- Durability: once it says committed, it survives a power cut. This is what the write-ahead log is for.
| Level | Still allows | In plain terms |
|---|---|---|
| Read uncommitted | Dirty reads | You can read a change that later gets rolled back. Almost never worth using. |
| Read committed | Non-repeatable reads | Read the same row twice in one transaction, get two different answers. The common default. |
| Repeatable read | Phantom reads | Rows you already read stay stable, but new rows can appear in a range you queried. |
| Serializable | Nothing | Behaves as if transactions ran one at a time. Correct, and slowest. |
Normalisation and denormalisation
Normalisation means storing each fact exactly once. A customer's address lives in the customers table, and orders point at the customer by id rather than copying the address. Change the address once and every order reflects it.
The benefit is that data cannot contradict itself, because there is only one copy to be wrong. The cost is joins: assembling a useful view means reading several tables and matching them up, and joins get expensive as tables grow, particularly once the tables live on different machines.
Denormalisation stores a fact more than once on purpose to avoid the join. Copy the customer name onto the order row so displaying an order needs one read instead of two.
You are trading write complexity and correctness risk for read speed. Now a name change has to update many rows, and if one update fails you have two versions of the truth. Denormalise when reads massively outnumber writes and you have measured the join as the problem. Not before.
- Normalised: each fact stored once, no contradictions, more joins at read time.
- Denormalised: facts copied, fewer joins, faster reads, and updates must now touch several places.
- Denormalise in response to a measurement, not a hunch.
- A materialised view is denormalisation the database maintains for you, and often the better first move.
- NoSQL stores are usually denormalised by design; that is not a different philosophy so much as the same trade made up front.
Scaling a relational database: four moves, in order
People jump from "Postgres" to "we need NoSQL" while skipping steps that would often have solved the problem. Take them in order, because each is dramatically cheaper than the next.
1. Tune what you have. Add the missing index. Fix the N+1 query. Stop selecting columns you do not use. A missing index alone can turn a full table scan into a lookup. This costs an afternoon and needs no architectural change.
2. Add read replicas. Copies of the database that stay in step and serve reads. Read capacity multiplies; write capacity does not change at all, because every replica applies every write. The cost is replication lag: a replica is always slightly behind, so a user can write and then not see their own change.
3. Federation: split by feature. Move whole tables to their own database: users here, products there, orders somewhere else. Each database gets smaller, its working set is more likely to fit in memory, and total write capacity grows because writes are spread across several primaries. The cost is that you can no longer join across the boundary, and no transaction spans two of them.
4. Sharding: split by row. Same table, split across machines by a key: users A–M here, N–Z there. This is the only move that raises write throughput for a single table, and it is by far the most expensive to do and to live with.
- Tuning is nearly free and often finds more than people expect. Do it first.
- Replicas scale reads only. If your problem is writes, replicas make it slightly worse.
- Federation splits by feature and is often enough. The cost is losing cross-database joins and transactions.
- Sharding is the only move that raises write capacity for one hot table, and it is a project, not a setting.
- Many systems never need to go past step 2. Saying so, with your write estimate, scores well.
NoSQL, type by type, and what each is for
"NoSQL" is not one thing. It is four quite different families that share only the property of not being relational tables. Treating them as interchangeable is a common mistake.
- Key-value (Redis, DynamoDB): a giant dictionary. Give it a key, get a blob. Very fast lookups, no querying by anything but the key. For: caches, sessions, rate-limit counters, feature flags.
- Document (MongoDB, Couchbase): stores JSON-ish documents and can query and index *inside* them. Fields can differ between documents. For: content, catalogues, user profiles, anything where the shape varies per record.
- Wide-column (Cassandra, HBase, Bigtable): rows keyed by a partition key, with columns grouped and sorted inside each partition. Built for enormous write volume and for reading a contiguous slice of one partition. For: time series, event logs, message history, feeds.
- Graph (Neo4j): nodes and the relationships between them, where the relationships are first-class. For: social graphs, fraud rings, recommendations, and anything where the question is "how are these two connected".
| Type | Query by | Strength | Bad at |
|---|---|---|---|
| Key-value | The key, only | Very fast lookups, easy to scale | Any question that is not "give me this key" |
| Document | Key or fields inside the document | Flexible shape, decent querying | Joins across documents, multi-document transactions |
| Wide-column | Partition key, then a range within it | Huge write rates, fast contiguous reads | Ad hoc queries; you design tables per query |
| Graph | Traversing relationships | Deep connection questions that would be many joins | Bulk scans and simple key lookups |
BASE: the other set of promises
ACID describes what relational databases promise. BASE describes what many distributed stores offer instead, and it is weaker on purpose.
Basically Available: the system answers, even during failures, possibly with stale or partial data. Soft state: the data can change on its own as copies reconcile, without a write happening. Eventually consistent: stop writing and all copies converge, with no promise about when.
This is not laziness; it is the direct consequence of wanting to survive machines and networks failing while still answering. If you insist every copy agrees before you answer, you cannot answer when a copy is unreachable. BASE chooses to answer.
The practical consequence is that correctness moves into your application. If two people can edit the same thing, you decide what winning means. If a counter can be incremented in two places, you decide how to merge. The database will not stop you getting this wrong.
- ACID: refuse the write rather than be inconsistent. BASE: accept the write and converge later.
- BASE buys availability and write throughput; it sells correctness guarantees back to your application code.
- Eventually consistent reads can go *backwards*: refresh and see an older value. This is legal and surprises people.
- Choose per operation, not per system. Likes are BASE; payments are ACID; both can live in one product.
- —Stock check and payment must both happen or neither does.
- —Refuses rather than risks selling something twice.
- —Slower per write, and unavailable if the owning partition is unreachable.
- —Being wrong here costs money, support time and trust.
- —Accept locally even during a network partition, reconcile after.
- —Always available; nobody sees an error page.
- —Worst case is a duplicate item the user removes in two seconds.
- —Being briefly wrong here costs nothing.
How to choose, in the room
A procedure you can run out loud, rather than a preference you assert.
First: is there an invariant that must never break under concurrency? Seats, stock, balances, unique usernames. If yes, you want real transactions where that invariant lives, and that means relational unless you have a very good reason.
Second: what is the one query this system runs ten thousand times a second? If it is "give me everything for this key", a key-value or wide-column store fits and its limits will not hurt. If you cannot predict the queries, you want the flexibility of SQL.
Third: do the numbers force it? Estimate the write rate. If a single primary can commit it (and one modern machine commits far more than people assume), then the scale argument for going distributed does not exist yet, and you should say so.
Fourth: can you use both? Many production systems do. Transactional core in Postgres, the enormous append-only event stream in a wide-column store, the cache in Redis, the search index in something built for search. Saying this is not indecision; it is how many production systems are built.
- Invariant under contention → relational, for that part at least.
- One known access pattern at huge volume → key-value or wide-column.
- Unpredictable queries → relational, because you cannot design tables per query you have not thought of.
- No number forcing your hand → stay boring, and say why you are staying boring.
- Polyglot persistence is normal. Justify each store by the access pattern it serves.
Choosing storage for a link shortener
Around 40 new links a second and 4,000 redirects a second. Roughly 1.2 TB after five years. Every read is "give me the destination for this code". Users can pick custom codes, so two people must never get the same one. Which database, and why?
- 1Look for an invariant first
There is one: no two links may share a code. That is a uniqueness constraint under concurrency, which a database enforces well and application code enforces badly, because check-then-insert has a race between the check and the insert.
- 2Check whether the numbers force a distributed store
40 writes a second is nothing. A single primary handles that with about two orders of magnitude of headroom. 1.2 TB fits on one machine. So nothing here forces a distributed database, and saying that explicitly is worth more than reaching for one.
- 3Look at the access pattern
Every read is a lookup by a single key. That is key-value shaped, which is why people reach for a key-value store here, and the read path does use one, as a cache. The question is what holds the durable truth.
- 4Decide
Postgres, keyed by the short code, with a unique constraint on it. The constraint gives collision handling and "that name is taken" for free, in one mechanism, with no race. Redis in front absorbs the 4,000 reads a second so the database rarely sees a redirect at all.
- 5Name what it costs
One primary is the write ceiling. At 40 a second against a ceiling in the thousands, that is fine, and I would revisit at roughly 100x. The cache means a disabled link stays live for the length of its TTL.
- 6Say what would change your mind
If writes reached tens of thousands a second, I would move to a key-value store partitioned by code. Then I would have to solve uniqueness myself, most likely by generating codes that cannot collide rather than by detecting collisions.