Dev Skills

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.

01

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.

In short
  • 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.
LevelStill allowsIn plain terms
Read uncommittedDirty readsYou can read a change that later gets rolled back. Almost never worth using.
Read committedNon-repeatable readsRead the same row twice in one transaction, get two different answers. The common default.
Repeatable readPhantom readsRows you already read stay stable, but new rows can appear in a range you queried.
SerializableNothingBehaves as if transactions ran one at a time. Correct, and slowest.
Isolation levels, and the anomaly each one still allows. This is where "it worked in testing" bugs come from.
What it costs you
Stronger isolation costs throughput, because the database must hold more locks or do more conflict checking. Many systems run at read committed without thinking about it. That is fine until you write a check-then-act sequence and discover it was never safe.
02

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.

In short
  • 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.
03

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.

In short
  • 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.
WritesLeaderall writesFollowerreadsFollowerreadsReads
every write goes to the one leaderfollowers copy from it, always slightly behindreads spread across followers — which is why replication scales reads and not writes
Step 2 in motion: one leader takes every write, followers copy from it and serve reads. Reads scale; writes do not.
Writeshash(key)Shard Aa–hShard Bi–pShard Cq–z
the key decides the shard — same key, same box, every time
Step 4: the key decides which machine owns the row. This is the only move that raises write throughput for a single table.
Say this
"Before I reach for a different database, I would add the missing index, then add read replicas, then split by feature. At my estimated write rate I never hit the ceiling that would force sharding, so I would stay on Postgres and revisit at 20x."
04

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.

In short
  • 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".
TypeQuery byStrengthBad at
Key-valueThe key, onlyVery fast lookups, easy to scaleAny question that is not "give me this key"
DocumentKey or fields inside the documentFlexible shape, decent queryingJoins across documents, multi-document transactions
Wide-columnPartition key, then a range within itHuge write rates, fast contiguous readsAd hoc queries; you design tables per query
GraphTraversing relationshipsDeep connection questions that would be many joinsBulk scans and simple key lookups
The same question asked of each family: what can you look things up by?
Common trap
Saying "we will use NoSQL" tells the interviewer nothing, and slightly suggests you think it is one product. Name the family and the access pattern: "a wide-column store partitioned by conversation id, clustered by timestamp descending, so one conversation's recent messages are a single contiguous read."
05

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.

In short
  • 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.
A
ACID: "confirm order"
  • —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.
B
BASE: "add to cart"
  • —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.
Which one: The strong answer is not "we are an ACID shop" or "we use BASE". It is naming which operations get which guarantee and why, based on what being wrong costs.
The same shopping app, two operations, two sets of promises.
06

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.

In short
  • 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.
Say this
"Postgres for orders and inventory, because those need a real transaction and the write rate is only a few hundred a second. The click-event stream is 80,000 writes a second, append-only, read only by device id, so that goes in a wide-column store partitioned by device. Two systems, and no transaction spans them, so the pipeline is idempotent instead."
Common trap
Choosing a database by name rather than by access pattern. "Why this and not Postgres?" is the follow-up, and the only answers that survive it contain a number or a query shape.
Worked example

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?

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

  2. 2
    Check 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.

  3. 3
    Look 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.

  4. 4
    Decide

    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.

  5. 5
    Name 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.

  6. 6
    Say 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.

Result: A boring relational database doing the correctness-critical work, and a cache doing the volume. The interesting part of the answer is not the choice. It is being able to say which number would change it.
Back to the summary and the self-check.← SQL vs NoSQL — and why, not just names