Skip to content
Software Atlas

SQL vs NoSQL

Data models and access patterns, not hype: how relational tables, documents, key-value, wide-column and graph databases store the same data, which queries each makes cheap or expensive, and how a staff engineer chooses.

⏱ ~50 min read16 sections14 interactive widgetsSQL · Go · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentence"SQL vs NoSQL" isn't "old and slow vs new and scalable". It's a choice of data model: the shape your data is stored in, which decides which questions are cheap to ask and which are expensive.

You'll hear "NoSQL scales, SQL doesn't" a lot. It's mostly hype. Big SQL databases serve millions of users, and plenty of NoSQL projects failed because the data didn't fit the model. The real question is simpler: what will your app ask the database, over and over? Those questions are your access patterns, and this whole topic is about matching them to a model.

Neither is better. The cabinet is great when you ask lots of different questions ("which products sold best in October?" pulls from every drawer at once). The box is great when you ask the same question over and over ("show me this customer's account") and want it in one trip.

Here is the same tiny shop, stored both ways. Click between them and notice where "Blue Mug" appears:

Same shop, different models
Two customers, three products, three orders. Watch where “Blue Mug” is stored.
customers
idname
1Ada
2Bob
products
idnameprice_cents
10Blue Mug1200
11Tea (100 g)800
12Notebook450
orders
idcustomer_idplaced_at
10012026-10-01
10112026-10-20
10222026-10-21
order_items
order_idproduct_idqty
100102
100111
101123
102101
“Blue Mug” stored onceRename it in one place.
✓ Cheap: Any question, including new ones: “best-selling product in October” is one SQL query with joins.
✕ Costly: Reading one whole order means joining 4 tables (fast on one machine; harder when tables are split across machines).

The five families

"NoSQL" isn't one thing. It's everything that isn't relational. In this topic you'll meet five data models, all storing the same shop:

ModelThink of it asExamplesCheap question
RelationalSpreadsheets that point at each otherPostgres, MySQLAlmost anything, with joins
DocumentA JSON object per thingMongoDB"Give me this whole thing"
Key-valueOne giant hash mapRedis, DynamoDB"Give me the value for this key"
Wide-columnSorted rows grouped by a key, spread over many machinesCassandra, ScyllaDB"The latest N rows for this key"
GraphDots and arrowsNeo4j"Who is connected to whom, several hops away?"
Foundations§02

Tables, keys and joins

In one sentenceA relational database stores data in tables. Rows in one table point at rows in another by key, and you ask questions in SQL by describing the answer you want. The database works out how to fetch it.

The shop, as tables

A table is like a spreadsheet with strict rules: every row has the same columns, and every column has a type. Our shop needs four tables:

The shop schema
customers🔑 idPKnameemailUNIQUEorders🔑 idPKcustomer_idFKplaced_atorder_items🔑 order_idPK·FK🔑 product_idPK·FKqtyproducts🔑 idPKnameprice_cents
🔑 PK = primary key (unique id of each row). FK = foreign key (points at another table's primary key). Arrows show “this column points at that row”. order_items links orders and products: one row per product in an order.

Each table has a primary key: a column whose value is unique per row, like customers.id. Other tables point at it with a foreign key: orders.customer_id holds a customer's id. That's the "customer #1" note on the order slip from the filing-cabinet analogy.

Why is there an order_items table at all? An order holds many products, and a product shows up in many orders. That's a many-to-many relationship. A single column can't hold "a list of products", so each (order, product) pair gets its own row in a join table.

Here is how you declare those tables in SQL. This is the schema: the rules every row must follow.

schema.sql
CREATE TABLE customers (
  id    bigint PRIMARY KEY,           -- unique per row, indexed automatically
  name  text   NOT NULL,
  email text   NOT NULL UNIQUE        -- no two customers share an email
);

CREATE TABLE products (
  id          bigint PRIMARY KEY,
  name        text NOT NULL,
  price_cents int  NOT NULL CHECK (price_cents >= 0)
);

CREATE TABLE orders (
  id          bigint PRIMARY KEY,
  customer_id bigint NOT NULL REFERENCES customers(id),  -- ← foreign key
  placed_at   date   NOT NULL
);

CREATE TABLE order_items (               -- the join table
  order_id   bigint REFERENCES orders(id),
  product_id bigint REFERENCES products(id),
  qty        int NOT NULL CHECK (qty > 0),
  PRIMARY KEY (order_id, product_id)     -- one row per (order, product) pair
);

Asking a question: SELECT with JOIN

"Show me everything Ada ordered" needs all four tables. A JOIN glues rows from two tables together wherever a condition matches:

ada-orders.sql
SELECT o.id AS order_id, c.name AS customer, p.name AS product, i.qty
FROM orders o
JOIN customers   c ON c.id = o.customer_id   -- each order + its customer
JOIN order_items i ON i.order_id = o.id      -- + its line items
JOIN products    p ON p.id = i.product_id    -- + each item's product
WHERE c.name = 'Ada'
ORDER BY o.id, p.name;

This is the real output from Postgres 16:

 order_id | customer |   product   | qty
----------+----------+-------------+-----
      100 | Ada      | Blue Mug    |   2
      100 | Ada      | Tea (100 g) |   1
      101 | Ada      | Notebook    |   3
(3 rows)

Step through what that query means, one clause at a time:

What a JOIN does, clause by clause
The logical meaning of the query. The planner may run the steps in a different order, with the same result.
Code
1SELECT o.id AS order_id, c.name AS customer,
2 p.name AS product, i.qty
3FROM orders o
4JOIN customers c ON c.id = o.customer_id
5JOIN order_items i ON i.order_id = o.id
6JOIN products p ON p.id = i.product_id
7WHERE c.name = 'Ada'
8ORDER BY o.id, p.name;
orders o
100 · customer_id 1
101 · customer_id 1
102 · customer_id 2
customers c
1 · Ada
2 · Bob
order_items i + products p
not joined yet
result
nothing yet
FROM orders o: start with every row of orders. o is a short nickname so we can write o.id.
1 / 6

You say what, the database decides how

Look at what the query doesn't say. It doesn't say "loop over orders", "use this index", or "read customers first". SQL is a declarative language: you describe the result, and the database's query planner picks the steps. Ask Postgres how it would run a smaller join with EXPLAIN:

shop=# EXPLAIN SELECT o.id, c.name FROM orders o
         JOIN customers c ON c.id = o.customer_id WHERE c.name = 'Ada';
 Hash Join  (cost=20.18..51.66 rows=8 width=40)
   Hash Cond: (o.customer_id = c.id)
   ->  Seq Scan on orders o  (cost=0.00..27.00 rows=1700 width=16)
   ->  Hash  (cost=20.12..20.12 rows=4 width=40)
         ->  Seq Scan on customers c  (cost=0.00..20.12 rows=4 width=40)
               Filter: (name = 'Ada'::text)

The numbers in brackets are the planner's estimates (cost in its own units, guessed row counts and sizes). They're guesses for a freshly created, nearly empty table, which is why it imagines 1,700 orders; ignore them for now. Read the plan bottom-up: Postgres decided to find Ada in customers first, put her into an in-memory lookup table (a hash), then walk orders once and match each order against it. With different table sizes or indexes, it would pick a different plan for the same SQL. That freedom is the relational model's superpower: you can ask questions nobody planned for when the schema was designed. (Reading plans properly is taught in Database indexes.)

The database guards the rules

The schema isn't just documentation; the database enforces it on every write. Real Postgres output:

shop=# INSERT INTO order_items VALUES (102, 99, 1);   -- product 99 doesn't exist
ERROR:  insert or update on table "order_items" violates foreign key constraint "order_items_product_id_fkey"
DETAIL:  Key (product_id)=(99) is not present in table "products".

shop=# INSERT INTO customers VALUES (3, 'Cy', 'ada@example.com');
ERROR:  duplicate key value violates unique constraint "customers_email_key"
DETAIL:  Key (email)=(ada@example.com) already exists.

Bad data can't get in, whatever bug your app has. Keep that in mind: in later sections, several NoSQL models hand this job back to your code.

From Go and JavaScript

Your app sends the same SQL as a string. The $1 placeholder is filled in by the driver, never pasted into the string yourself, which is what prevents SQL injection.

orders.go
import (
    "context"
    "database/sql"
    "fmt"

    _ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" driver
)

func printOrders(ctx context.Context, db *sql.DB, name string) error {
    rows, err := db.QueryContext(ctx, `
        SELECT o.id, p.name, i.qty
        FROM orders o
        JOIN customers   c ON c.id = o.customer_id
        JOIN order_items i ON i.order_id = o.id
        JOIN products    p ON p.id = i.product_id
        WHERE c.name = $1
        ORDER BY o.id, p.name`, name) // ← $1 = name, sent separately
    if err != nil {
        return err
    }
    defer rows.Close()
    for rows.Next() {
        var orderID int64
        var product string
        var qty int
        if err := rows.Scan(&orderID, &product, &qty); err != nil {
            return err
        }
        fmt.Println(orderID, product, qty) // 100 Blue Mug 2 …
    }
    return rows.Err()
}
// db comes from: db, err := sql.Open("pgx", "postgres://user@localhost:5432/shop")
Foundations§03

Normalization: every fact in one place

In one sentenceNormalization means storing every fact exactly once. A product's name lives in one row of products; everything else points at that row. Change it there, and every query sees the new name.

Why not one big table?

The four tables in §02 might look like over-engineering. Why not one spreadsheet with a row per line item and every detail on it?

order_idcustomeremailproductpriceqty
100Adaada@example.comBlue Mug12.002
100Adaada@example.comTea (100 g)8.001
101Adaada@example.comNotebook4.503
102Bobbob@example.comBlue Mug12.001

It reads nicely, and you never need a join. But "Ada's email" is stored three times, and "Blue Mug costs 12.00" twice. Duplicated facts cause three kinds of anomaly: bugs where the data ends up contradicting itself.

1

Update anomaly

Ada changes her email. You must update 3 rows. A bug or crash after updating 2 leaves the database claiming she has two emails.
2

Insert anomaly

You add a new product, "Teapot". Nobody has ordered it yet, so there's no row to put it on. The product can't exist without an order.
3

Delete anomaly

Bob cancels order 102 and you delete its row. That was the only row mentioning Bob, so you've also forgotten Bob exists.

Splitting the data into customers, products, orders and order_items removes all three: each fact has one home, and rows point at each other by id. Try renaming a product both ways:

Rename “Blue Mug” → “Ocean Mug”
Five orders contain the mug. Same change, two data models.
productsthe only copy
10 · Blue Mug · 12.00
orders joined with productscomputed at read time
order 100 · Ada · product 10 → Blue Mug
order 102 · Bob · product 10 → Blue Mug
order 104 · Cy · product 10 → Blue Mug
order 107 · Dee · product 10 → Blue Mug
order 111 · Eve · product 10 → Blue Mug
writes: 0
The name lives in one row. Orders reach it through product_id.
What if the mug were in
→ 1 write

The price of normalization: joins

Nothing is free. Because the facts are spread across tables, reading a whole order means putting them back together with joins at query time. On one machine, with the right indexes, that takes well under a millisecond for a single order, and relational databases are extremely good at it.

It gets harder when the tables are so big they're split across many machines: then a join may have to fetch rows from several machines over the network. Several NoSQL models were designed around avoiding exactly that. We'll come back to it in §11.

Copying on purpose: denormalization

Denormalization means copying data on purpose so reads get cheaper. It isn't a sin. It's a trade you make with your eyes open:

  • Historical facts should be copied. If the mug costs 12.00 today and 15.00 next year, old orders must still say 12.00. So real schemas store unit_price_cents on order_items. That isn't a duplicate: it's a different fact ("the price at the time of this order"). The same goes for the shipping address on an order.
  • Read copies for speed. A precomputed "order count per customer", a search index, a cache: all copies of facts that live elsewhere. That's fine as long as one place is the source of truth and the copies are rebuilt from it.
Foundations§04

Documents: one box per thing

In one sentenceA document database stores each record as one JSON-like object with nested objects and arrays inside. The design rule is the reverse of normalization: store together what you read together.

The shop, as documents

In MongoDB, a record is a document and documents live in a collection (roughly, a table). Each document has a unique _id. Here is order 100 as one document:

orders collection — one document
{
  _id: 100,
  customerId: 1,                 // ← a reference: just the id, like a foreign key
  placedAt: ISODate("2026-10-01"),
  status: "shipped",
  items: [                       // ← embedded: the line items live INSIDE the order
    { productId: 10, name: "Blue Mug",    priceCents: 1200, qty: 2 },
    { productId: 11, name: "Tea (100 g)", priceCents: 800,  qty: 1 }
  ]
}

Two techniques appear in that one document:

  • Embedding: items is an array inside the order. The four-table join from §02 collapses into one read, a single round trip to the database, because the data is already assembled.
  • Referencing: customerId: 1 just names the customer, who lives in a separate customers collection. The order also copies the product's name and priceCents. That's the "price at the time of the order" from §03: a deliberate snapshot, not an accident.

Compare it with the tables. Switch between the two views and follow "Blue Mug":

Same shop, different models
Two customers, three products, three orders. Watch where “Blue Mug” is stored.
orders collection
{ _id: 100, customerId: 1,
  items: [
    { productId: 10, name: "Blue Mug", qty: 2 },
    { productId: 11, name: "Tea (100 g)", qty: 1 } ] }
{ _id: 101, customerId: 1,
  items: [ { productId: 12, name: "Notebook", qty: 3 } ] }
{ _id: 102, customerId: 2,
  items: [ { productId: 10, name: "Blue Mug", qty: 1 } ] }
customers · products
{ _id: 1, name: "Ada" }
{ _id: 2, name: "Bob" }

{ _id: 10, name: "Blue Mug", priceCents: 1200,
  description: "…", stock: 40 }
…
The order copies only the fields its page needs; the full product lives in products.
“Blue Mug” stored 3 timesRenaming it means updating every copy.
✓ Cheap: “Show order 100”: one read returns the order with its items, no joins.
✕ Costly: Renaming Blue Mug means updating every order document that copied the name.

Talking to MongoDB

MongoDB isn't installed in this site's sandbox, so the outputs below are illustrative: written to match what the real mongosh shell prints, but not captured from a live server. The query syntax is real.

mongosh
// One read returns the whole order, items included.
db.orders.findOne({ _id: 100 })

// Query INSIDE the array: every order containing product 10.
db.orders.find({ "items.productId": 10 }, { _id: 1, placedAt: 1 })
// → [ { _id: 100, placedAt: ISODate('2026-10-01T00:00:00.000Z') },
//     { _id: 102, placedAt: ISODate('2026-10-21T00:00:00.000Z') } ]      (illustrative)

// Make that query fast: an index on a field inside the array.
db.orders.createIndex({ "items.productId": 1 })

From application code it looks like this. The Go program compiles against the official v2 driver; the result shown is illustrative:

order.go
import (
    "context"

    "go.mongodb.org/mongo-driver/v2/bson"
    "go.mongodb.org/mongo-driver/v2/mongo"
)

type Item struct {
    ProductID  int    `bson:"productId"`
    Name       string `bson:"name"`
    PriceCents int    `bson:"priceCents"`
    Qty        int    `bson:"qty"`
}

type Order struct {
    ID         int    `bson:"_id"`
    CustomerID int    `bson:"customerId"`
    Items      []Item `bson:"items"` // ← embedded array decodes into a slice
}

func loadOrder(ctx context.Context, orders *mongo.Collection, id int) (Order, error) {
    var o Order
    err := orders.FindOne(ctx, bson.M{"_id": id}).Decode(&o) // ← one round trip: one request, one reply
    return o, err
}
// orders comes from: client.Database("shop").Collection("orders")

Referencing, and joins that aren't free

When you need the customer's name next to the order, you have two choices: a second query by customerId, or a $lookup stage, which is MongoDB's join:

mongosh — $lookup
db.orders.aggregate([
  { $match: { _id: 100 } },
  { $lookup: {
      from: "customers",          // the other collection
      localField: "customerId",   // field in orders
      foreignField: "_id",        // field in customers
      as: "customer"              // put matches in this new array field
  } }
])

aggregate takes a list of stages, and each stage's output feeds the next (a pipeline). $lookup works, but document databases are built and tuned around not needing it in code that runs on every request. If you find yourself joining on every request, the data probably wants to be embedded, or relational. (When the "join" is done in your app with one query per row, you get the N+1 problem, see N+1 Queries.)

Schema-on-write vs schema-on-read

Postgres rejected bad rows in §02 because the table has a schema checked on every write: schema-on-write. By default MongoDB accepts any document into any collection:

mongosh
db.products.insertOne({ _id: 13, name: "Teapot", colour: "blue" })  // "colour"!
db.products.insertOne({ _id: 14, name: "Kettle", color: "red", priceCents: "39.99" })
// both succeed: { acknowledged: true, insertedId: … }   (illustrative)

One document spells colour, the other color, and one price is a string. Nothing stopped them. This is schema-on-read: the shape is decided by the code that reads the data, and that code must cope with every shape that was ever written.

✕ Schema-on-read: every reader defends
// Every reader must handle every historical shape.
// a ?? b means "a, unless a is null/undefined — then b"
const color = p.color ?? p.colour ?? "unknown";
const cents = typeof p.priceCents === "string"
  ? Math.round(parseFloat(p.priceCents) * 100)
  : p.priceCents;
✓ Schema-on-write: one gatekeeper
-- The database refuses bad shapes at the door.
CREATE TABLE products (
  id          bigint PRIMARY KEY,
  name        text NOT NULL,
  color       text,
  price_cents int  NOT NULL
);
Practitioner§05

Embed or reference?

In one sentenceEmbed what is read together, belongs to the parent, and stays small. Reference what grows without limit, is shared by many parents, or changes on its own.

Every document design comes down to this question, asked once per relationship. Here's the checklist, then the three classic ways embedding goes wrong.

Ask about the related data…Embed if…Reference if…
Is it read with the parent?Almost always, togetherOften on its own
Who owns it?Only this parent (line items belong to one order)Shared (a product appears in thousands of orders)
How many?A few, with a known upper limitCould grow forever
Does it change independently?Rarely, or it's a snapshotOften (stock level, user's display name)

Order line items pass every test: always shown with the order, owned by it, a handful per order, frozen once the order is placed. That's why §04 embedded them.

Failure 1: the array that never stops growing

A blog post with its comments embedded looks lovely, until a post goes viral. Every new comment makes the document bigger. MongoDB caps a single document at 16 MB, and long before that, every read of the post (even "just show the title") drags the whole thing off disk and over the network. Every new comment also rewrites a larger document.

Drag the slider to see what happens as comments pile up:

A blog post that goes viral
Each comment is ~0.3 KB, the post itself ~2 KB (rough averages). MongoDB's limit is 16 MB per document.
post document size16 MB limit
32 KB
show the post title
reads 32 KB
show latest 20 comments
reads 32 KB
add one comment
grows a 32 KB doc
Small and fast: one read gets the post and all its comments. This is why embedding feels great at first.

The fix is to reference: comments go in their own collection, each with a postId, plus an index on { postId: 1, createdAt: -1 } so "latest 20 comments of this post" is a quick lookup. A common middle ground is to embed only the latest few comments in the post (for the first screen) and keep the full list separate.

Failure 2: many-to-many

Students take many courses; courses have many students. Embed courses in each student and every course's details are copied into hundreds of student documents. Rename a course and you're back to the update anomaly from §03, now across hundreds of documents with no join to save you.

The usual document answer is to reference in one or both directions (courseIds: [7, 9] on the student) and fetch the courses in a second query. That works. But if your data is mostly many-to-many relationships, that's a strong hint the relational model (a join table) fits better.

Failure 3: data that's shared and changes

Suppose each order embeds the customer's current email "for convenience". Ada changes her email: now you must find and update every order she ever placed. Ask yourself: is this copy a snapshot or a live value?

  • Snapshot (price at purchase, shipping address used): copy it and never update it. Good embedding.
  • Live value (current email, product's current stock): reference it by id.

The middle path: copy just a few fields

The order in §04 does both at once. It references the product by productId, and copies the two fields the order page needs (name, priceCents). MongoDB's docs call this the extended reference pattern. The page renders from one read; the product's full details (description, stock, images) stay in products, the single source of truth.

Practitioner§06

Key-value: design the key from the question

In one sentenceA key-value store is one giant hash map, like a JavaScript object or a Go map that lives on a server: put(key, value), get(key). It's blazing fast and easy to spread over many machines, but it can only find things by key. So you design the keys from the questions you'll ask.

The simplest model: Redis

Redis keeps everything in memory, so a GET usually answers in well under a millisecond. Values can be plain strings or small structures such as hashes (a map inside one key). Real session from Redis 7.0:

127.0.0.1:6379> SET session:9f2c '{"userId":1}' EX 3600
OK
127.0.0.1:6379> GET session:9f2c
"{\"userId\":1}"
127.0.0.1:6379> HSET cart:1 product:10 2 product:11 1
(integer) 2
127.0.0.1:6379> HINCRBY cart:1 product:10 1
(integer) 3
127.0.0.1:6379> HGETALL cart:1
1) "product:10"
2) "3"
3) "product:11"
4) "1"
127.0.0.1:6379> GET session:nope
(nil)
session.go
// go-redis v9: github.com/redis/go-redis/v9
err := rdb.Set(ctx, "session:9f2c", `{"userId":1}`, time.Hour).Err()
if err != nil {
    return err
}

raw, err := rdb.Get(ctx, "session:9f2c").Result()
if errors.Is(err, redis.Nil) {      // ← redis.Nil = "no such key"
    return ErrLoggedOut
} else if err != nil {
    return err
}
fmt.Println(raw)                     // {"userId":1}

if err := rdb.HIncrBy(ctx, "cart:1", "product:10", 1).Err(); err != nil {
    return err
}
// rdb comes from: rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

What you can't ask Redis is "which carts contain product 10?". There is no index on values, so you'd have to read every key. Key-value stores are perfect for sessions, carts, rate-limit counters, feature flags and caches (see Caching): anything where the app always knows the exact key.

Keys with structure: DynamoDB

DynamoDB extends the idea. Each item (DynamoDB's word for one record) has a key with two parts:

  • a partition key (PK), which picks the machine the item is stored on. Items with the same PK are kept together.
  • an optional sort key (SK), which keeps those items sorted within their partition.

A Query names one PK and can ask for a range of SKs ("starts with ORDER#2026-10"). It reads only that slice. Anything that doesn't name a partition key needs a Scan, which reads the entire table.

Single-table design: keys from access patterns

Here's the twist that confuses people coming from SQL. With single-table design, you put customers, orders and order items in one table and choose the keys so that each access pattern is one Query. You start from a list of questions, not from the entities:

  1. Get a customer's profile.
  2. Get a customer and their orders, newest first.
  3. Get a customer's orders in one month.
  4. Get one order with its line items.

Then you pick keys that put each answer in one partition, sorted the way you'll read it. Pick an access pattern below and watch which items get read:

Single-table design: which items does each request read?
One DynamoDB table holds customers, orders and line items. Pick an access pattern.
Query PK = "CUSTOMER#1" (newest first)
PK (partition key)SK (sort key)data
CUSTOMER#1ORDER#2026-09-14#097total 8.00
CUSTOMER#1ORDER#2026-10-01#100total 32.00
CUSTOMER#1ORDER#2026-10-20#101total 13.50
CUSTOMER#1PROFILEAda · ada@example.com
CUSTOMER#2ORDER#2026-10-21#102total 12.00
CUSTOMER#2PROFILEBob · bob@example.com
ORDER#097ITEM#11Tea (100 g) ×1
ORDER#100ITEM#10Blue Mug ×2
ORDER#100ITEM#11Tea (100 g) ×1
ORDER#101ITEM#12Notebook ×3
ORDER#102ITEM#10Blue Mug ×1
returned: 4items read: 4 of 11
A Query on one partition key returns the whole partition, sorted by SK. Reading it backwards (newest first) gives PROFILE first, because “P” sorts after “O”, then the orders from newest to oldest. Profile and orders arrive in one request: the join was done when we chose the keys.

The same Query from code. The Go program compiles against the AWS SDK for Go v2; DynamoDB isn't available in the sandbox, so no output is shown:

orders.go
out, err := ddb.Query(ctx, &dynamodb.QueryInput{
    TableName:              aws.String("shop"),
    KeyConditionExpression: aws.String("PK = :pk"), // ← one partition only
    ExpressionAttributeValues: map[string]types.AttributeValue{
        ":pk": &types.AttributeValueMemberS{Value: "CUSTOMER#1"},
    },
    ScanIndexForward: aws.Bool(false), // sort key descending → newest first
})
if err != nil {
    return err
}
// out.Items: the PROFILE item and every ORDER# item for customer 1

The new question nobody planned for

Now the product team asks: "which orders contain the Blue Mug?" No key starts with the product, so the only option is a Scan of every item. Try the last pattern in the widget. The fix is a Global Secondary Index (GSI): DynamoDB keeps a second copy of the items keyed by another attribute (say GSI1PK = PRODUCT#10). It works, but every new question means new keys, an extra copy of the data, and extra write cost.

That's the core trade of key-value stores, made sharp:

✓

What you get

Every planned access pattern costs about the same at 1 GB or 100 TB: one partition, a predictable few milliseconds. No joins, nothing to tune.
✕

What you pay

Every unplanned question is a Scan or a schema redesign. You must know your access patterns up front.
!

Hard limits

A DynamoDB item can't exceed 400 KB, and one Query returns at most 1 MB before you have to ask for the next page.
Practitioner§07

Wide-column and graph

In one sentenceWide-column stores (Cassandra) are key-value stores with sorted rows inside each key, built for enormous write volumes: you design one table per query. Graph databases (Neo4j) store the links themselves, so "friends of friends of friends" is a walk, not a pile of joins.

Wide-column: Cassandra

A wide-column store like Cassandra spreads data over many machines (often dozens), any of which can accept writes. Its query language, CQL, looks almost exactly like SQL, which is misleading, because the rules underneath are DynamoDB's: you can only ask what the key layout answers.

Cassandra isn't installed in the sandbox; the statements are real CQL and the outputs are illustrative.

orders_by_customer.cql
CREATE TABLE orders_by_customer (
  customer_id bigint,
  placed_at   date,
  order_id    bigint,
  total_cents int,
  item_names  list<text>,
  PRIMARY KEY ((customer_id), placed_at, order_id)
) WITH CLUSTERING ORDER BY (placed_at DESC, order_id DESC);

-- ✓ one partition, already sorted newest-first on disk
SELECT placed_at, order_id, total_cents FROM orders_by_customer
WHERE customer_id = 1 LIMIT 10;

So far, it's DynamoDB with SQL-looking syntax. The difference shows up when you ask something the key doesn't cover:

cqlsh:shop> SELECT * FROM orders_by_customer WHERE total_cents > 5000;
InvalidRequest: Error from server: code=2200 [Invalid query] message="Cannot execute
this query as it might involve data filtering and thus may have unpredictable
performance. If you want to execute this query despite the performance
unpredictability, use ALLOW FILTERING"                                  (illustrative)

Cassandra refuses rather than quietly reading every partition on every machine. And there are no joins at all. The answer is query-first modeling: one table per question, each keyed for its query, with the application writing every new order into all of them.

Cassandra: which nodes does a query touch?
Three machines (nodes). Each partition is stored on the node its partition key hashes to (copies on other nodes not shown).
SELECT * FROM orders_by_customer WHERE customer_id = 1;
-- ✓ runs
node Areads
orders_by_customer
customer_id = 1
2026-10-20 · order 101
2026-10-01 · order 100
2026-09-14 · order 097
node Bidle
orders_by_customer
customer_id = 3
2026-10-25 · order 105
node Cidle
orders_by_customer
customer_id = 2
2026-10-21 · order 102
The partition key is given, so Cassandra hashes customer_id = 1, knows that partition lives on node A, and reads it. The rows are already sorted newest-first on disk.

Graph: Neo4j

Some questions are all relationships: "customers who bought what Ada bought", "people you may know", "is this payment linked to a known fraud ring within 4 hops?". A graph database stores nodes (Ada, an order, the Blue Mug) and relationships (PLACED, CONTAINS) as first-class data. Following a relationship is a direct pointer hop, not an index lookup.

The shop as a graph
PLACEDPLACEDPLACEDCONTAINSCONTAINSCONTAINSCONTAINSAdaBobOrder 100Order 101Order 102Blue MugTeaNotebook● Customer● Order● Product
Nodes are things; arrows are relationships with a type. The highlighted path is “customers who bought what Ada bought”: Ada → order → Blue Mug ← order ← Bob. Each hop follows a stored link, so no index lookup is needed per step.

In Cypher, Neo4j's query language, you draw the pattern you're looking for (illustrative output; Neo4j isn't in the sandbox):

also-bought.cypher
MATCH (a:Customer {name: "Ada"})-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product)
      <-[:CONTAINS]-(:Order)<-[:PLACED]-(other:Customer)
WHERE other <> a
RETURN DISTINCT other.name
// → "Bob"     (illustrative)

The same question in SQL works fine at this size. Real Postgres output:

also-bought.sql
SELECT DISTINCT other.name
FROM customers a
JOIN orders      ao    ON ao.customer_id = a.id
JOIN order_items ai    ON ai.order_id    = ao.id
JOIN order_items bi    ON bi.product_id  = ai.product_id   -- same product…
JOIN orders      bo    ON bo.id          = bi.order_id     -- …in another order
JOIN customers   other ON other.id       = bo.customer_id
WHERE a.name = 'Ada' AND other.id <> a.id;
--  name
-- ------
--  Bob

Five joins (six tables) for two hops, and it grows from there. Each extra hop adds more joins, and the number of rows in between can multiply at every step. Cypher handles variable depth directly: (a)-[:FOLLOWS*1..4]->(b) means "1 to 4 FOLLOWS hops". SQL needs a recursive query (WITH RECURSIVE) for that, which works but gets slow and awkward on large, highly connected data.

All five, side by side

You've now seen every model storing the same shop. Flip through them and notice how the same facts get arranged differently, and which question each layout makes easy:

Same shop, different models
Two customers, three products, three orders. Watch where “Blue Mug” is stored.
customers
idname
1Ada
2Bob
products
idnameprice_cents
10Blue Mug1200
11Tea (100 g)800
12Notebook450
orders
idcustomer_idplaced_at
10012026-10-01
10112026-10-20
10222026-10-21
order_items
order_idproduct_idqty
100102
100111
101123
102101
“Blue Mug” stored onceRename it in one place.
✓ Cheap: Any question, including new ones: “best-selling product in October” is one SQL query with joins.
✕ Costly: Reading one whole order means joining 4 tables (fast on one machine; harder when tables are split across machines).
Practitioner§08

Model this: one app, five databases

In one sentenceNo model is fast or slow in general. Each one makes some access patterns cheap by making others expensive. The skill is knowing which ones your app does all day.

Pick an app, then a database model. You'll see how that model would store the app's data and what each of three typical questions would cost. The badges mean:

✓

Cheap

One lookup or one sorted slice, roughly the same speed at any data size.
~

OK

Works well with the right index or a bit of extra work. Fine for most apps.
✕

Costly / DIY

Reads far more data than it returns, needs many round trips, or isn't supported. You'd have to build it yourself (extra tables, a second system).
Model this
Pick an app, then a model. Every row in the grid is one access pattern; click a column to see the details.
app
model
access pattern
Show order 100 with its items~ OK✓ cheap✓ cheap✓ cheap~ OK
Checkout: take stock and create order atomically✓ cheap~ OK✕ costly✕ costly~ OK
Revenue per product category last month✓ cheap~ OK✕ DIY✕ DIY~ OK
Relational: how it's stored
customers, products, orders, order_items (§02)
~ OKShow order 100 with its items:3-table join on indexed keys: well under a millisecond
✓ cheapCheckout: take stock and create order atomically:one ACID transaction (§09)
✓ cheapRevenue per product category last month:one GROUP BY with joins
Verdict: Relational. Money and stock want multi-row transactions, and the business will keep inventing reports. Add Redis for carts and sessions.

What the patterns tell you

Click through all five apps and a few patterns jump out:

  • Relational is almost never ✕ until scale or graph depth. That's the value of joins plus a query planner: new questions are usually just new SQL, maybe plus an index. Its ✕s appear at extreme write volumes and many-hop traversals.
  • Key-value and wide-column are ✓ or ✕, rarely ~. They're superb at the questions you designed keys for and helpless at the rest. They reward knowing your access patterns in advance.
  • Document sits in between. It's great when one document matches one screen, and it degrades when the data is shared between many documents.
  • Graph wins one column decisively (multi-hop relationships) and is an odd fit elsewhere.
Practitioner§09

Transactions and consistency guarantees

In one sentenceRelational databases give you multi-row transactions by default: many changes, all or nothing. Most NoSQL databases guarantee all-or-nothing only within one document, item or partition, and treat anything bigger as a slower add-on, or don't offer it.

The checkout problem

Checkout must do two things together: create the order and take the items out of stock. If only one happens, you either sell a mug you don't have or lose an order. A transaction makes several changes atomic: either all of them happen or none do. In Postgres it's BEGIN … COMMIT. First give products a stock column with a rule that it can never go negative, and leave just 1 Blue Mug. Then try to sell 2 of them. Real output:

shop=# ALTER TABLE products ADD COLUMN stock int NOT NULL DEFAULT 0 CHECK (stock >= 0);
ALTER TABLE
shop=# UPDATE products SET stock = 1 WHERE id = 10;
UPDATE 1
shop=# BEGIN;
BEGIN
shop=*# INSERT INTO orders VALUES (103, 2, '2026-10-30');
INSERT 0 1
shop=*# INSERT INTO order_items VALUES (103, 10, 2);
INSERT 0 1
shop=*# UPDATE products SET stock = stock - 2 WHERE id = 10;
ERROR:  new row for relation "products" violates check constraint "products_stock_check"
DETAIL:  Failing row contains (10, Blue Mug, 1200, -1).
shop=!# COMMIT;
ROLLBACK
shop=# SELECT id FROM orders WHERE id = 103;
 id
----
(0 rows)

The stock rule failed, so the order rows inserted a moment earlier vanished too. That's the "A" in ACID. Any number of rows, in any tables, one all-or-nothing unit. How isolation between concurrent transactions works (and where it leaks) is the subject of Transactions & Isolation. The classic lost-update bug is in Race Conditions.

Two buyers, one mug

Atomicity matters most when two requests arrive at the same moment. Here is the same race (two buyers, 1 mug left) handled three ways. Step through each:

Two buyers, one mug left
Both click “Buy” at the same moment. Pick how checkout is written.
buyer A's request
SELECT stock → 1
buyer B's request
not started
database
stock = 1 · orders: none
The app reads first and decides in its own code. Buyer A's request reads stock = 1. Enough, it thinks.
1 / 5

MongoDB: design so one document is enough

In MongoDB, a write to a single document is always atomic, including every nested field and array inside it. That's a big reason to embed: if an invariant (a rule that must always hold, like "stock is never negative") lives in one document, you don't need a transaction at all. "Take 2 mugs, but only if at least 2 are left" is one conditional update:

mongosh
db.products.updateOne(
  { _id: 10, stock: { $gte: 2 } },   // match only if stock >= 2
  { $inc: { stock: -2 } }            // then subtract 2, atomically
)
// → { matchedCount: 0, modifiedCount: 0 }   when there isn't enough   (illustrative)

When the invariant spans documents (stock in products, the new order in orders), MongoDB offers multi-document transactions (since version 4.0; 4.2 when the data is split across several machines). They work, but cost more than single-document writes and have time limits, so MongoDB's own advice is to model data so most operations don't need them.

checkout.go
sess, err := client.StartSession()
if err != nil {
    return err
}
defer sess.EndSession(ctx)

_, err = sess.WithTransaction(ctx, func(ctx context.Context) (any, error) {
    res, err := products.UpdateOne(ctx,
        bson.M{"_id": 10, "stock": bson.M{"$gte": 2}}, // only if enough stock
        bson.M{"$inc": bson.M{"stock": -2}})
    if err != nil {
        return nil, err
    }
    if res.ModifiedCount == 0 {
        return nil, ErrOutOfStock // ← returning an error aborts everything
    }
    _, err = orders.InsertOne(ctx, order)
    return nil, err
})
return err

DynamoDB: conditions and small transactions

DynamoDB writes to one item are atomic and can carry a condition (stock >= :n, or attribute_not_exists(PK) for "only create if new"), which gives you safe compare-and-set: "write this only if the current value is still what I expect". If another request changed it first, the write is refused instead of silently overwriting. For several items there's TransactWriteItems: up to 100 actions, all or nothing, at about twice the cost of normal writes.

Reads have a twist: by default they're eventually consistent. A read right after a write may return the old value for a moment, because DynamoDB keeps several copies and may answer from one that hasn't caught up (eventual consistency). Pass ConsistentRead: true to get the latest value, at twice the read cost.

Cassandra: choose per query

Cassandra keeps each row on several machines, typically 3. On every query you choose how many of those copies must answer: ONE, QUORUM (a majority: 2 of 3) or ALL. This is tunable consistency. Call the total number of copies N, the number a write waits for W, and the number a read asks R. The key rule: if R + W > N, every read overlaps with the latest write. Try it:

Write to W copies, read from R copies
3 copies of each row. The write of “Ocean Mug” has reached W of them; a read asks R of them, in the worst case the ones the write missed.
write W
read R
copy 1
Ocean Mug (new)
copy 2read
Ocean Mug (new)
copy 3read
Blue Mug (old)
R + W = 4 > N = 3✓ the read is guaranteed to include the new value
Any 2 copies and any 2 copies out of 3 must share at least one, so the read always sees the latest write (the database keeps the newest by timestamp). Cost: writes wait for 2 copies and keep working with 1 down; reads wait for 2 and keep working with 1 down.

For "only one user may claim this email" you need compare-and-set ("insert only if nobody has claimed it yet"), which Cassandra offers as lightweight transactions:

claim-email.cql
INSERT INTO users_by_email (email, user_id) VALUES ('ada@example.com', 1) IF NOT EXISTS;
--  [applied]
-- -----------
--       True          ← a second attempt would print False   (illustrative)

They're correct under concurrency, but several times slower than normal writes, because the replicas must first agree with each other. They're also limited to one partition. Cassandra has no general multi-partition transactions (newer releases are adding them, so check your version).

Summary

Atomic unit by defaultBigger transactionsRead freshness
Postgres / MySQLAny number of rows (transaction)Built inLatest committed (on the primary, the main copy that takes writes)
MongoDBOne documentMulti-document transactions (cost more)Configurable; primary reads are latest
DynamoDBOne item (+ conditions)TransactWriteItems, ≤ 100 itemsEventual by default; ConsistentRead
CassandraOne partition writeLWT on one partition onlyTunable: ONE / QUORUM / ALL
RedisOne commandMULTI/EXEC or a Lua script (run together, but no rollback)Latest on the primary
Practitioner§10

Postgres as both: JSONB

In one sentencePostgres can store and index JSON documents in a JSONB column, right next to normal columns. For many apps that gives you most of the document model's flexibility plus joins, constraints and transactions, in one database you already run.

A table that's half relational, half document

Our catalog sells mugs, tea and books. They share name and price, but their other attributes have nothing in common. A mug has a color and capacity, tea has an origin, a book has an author. Instead of 30 mostly-empty columns, keep the shared fields as columns and the varying ones in JSONB:

catalog.sql
CREATE TABLE catalog (
  id          bigint PRIMARY KEY,
  name        text   NOT NULL,
  price_cents int    NOT NULL,
  attrs       jsonb  NOT NULL DEFAULT '{}'   -- ← the document part
);
INSERT INTO catalog VALUES
  (0, 'Ocean Mug', 1200, '{"kind":"mug","color":"teal","capacity_ml":350,"dishwasher_safe":true}');

To have something to measure, I filled the table with 300,000 generated products (one statement; generate_series(1, 300000) produces the numbers 1 to 300,000, one per row) plus the Ocean Mug:

fill.sql
INSERT INTO catalog
SELECT g, 'Item ' || g, 100 + (g % 5000),
  CASE g % 3
    WHEN 0 THEN jsonb_build_object('kind','mug','color',(ARRAY['blue','red','green','white'])[1 + g % 4],'capacity_ml', 250 + (g % 4) * 50)
    WHEN 1 THEN jsonb_build_object('kind','tea','origin',(ARRAY['Assam','Darjeeling','Uji','Yunnan'])[1 + g % 4],'tags', jsonb_build_array('loose','organic'))
    ELSE        jsonb_build_object('kind','book','author','Author ' || (g % 1000),'pages', 100 + g % 400)
  END
FROM generate_series(1, 300000) g;
ANALYZE catalog;   -- recount rows and values so the planner's estimates are right

Real output from that table (300,001 rows):

shop=# SELECT id, name, attrs FROM catalog WHERE id IN (0, 1, 2);
 id |   name    |                                     attrs
----+-----------+-------------------------------------------------------------------------------
  0 | Ocean Mug | {"kind": "mug", "color": "teal", "capacity_ml": 350, "dishwasher_safe": true}
  1 | Item 1    | {"kind": "tea", "tags": ["loose", "organic"], "origin": "Darjeeling"}
  2 | Item 2    | {"kind": "book", "pages": 102, "author": "Author 2"}

shop=# SELECT name, attrs->>'color' AS color, (attrs->>'capacity_ml')::int AS ml
       FROM catalog WHERE id = 0;
   name    | color | ml
-----------+-------+-----
 Ocean Mug | teal  | 350

Indexing inside the JSON: real EXPLAIN output

Without an index, finding the teal mug means reading every row. EXPLAIN ANALYZE runs the query and reports what Postgres actually did:

shop=# EXPLAIN ANALYZE SELECT id, name FROM catalog WHERE attrs @> '{"color":"teal"}';
 Gather  (cost=1000.00..7251.51 rows=10 width=19) (actual time=23.771..29.845 rows=1 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Parallel Seq Scan on catalog  (cost=0.00..6250.51 rows=4 width=19) (actual time=19.931..19.932 rows=0 loops=3)
         Filter: (attrs @> '{"color": "teal"}'::jsonb)
         Rows Removed by Filter: 100000
 Execution Time: 29.859 ms

"Seq Scan… Rows Removed by Filter: 100000" (in each of 3 parallel workers) means it read all 300,000 rows to return one: about 30 ms. Now add a GIN index, which maps every key/value inside every document to the rows that contain it. It works like the index at the back of a book, but for JSON:

A GIN index, simplified
table: catalog
row 0 · {"kind":"mug","color":"teal"}
row 1 · {"kind":"tea","origin":"Darjeeling"}
row 3 · {"kind":"mug","color":"blue"}
row 6 · {"kind":"mug","color":"teal"}
◀ points to
GIN index (sorted)
color = blue → rows 3
color = teal → rows 0, 6
kind = mug → rows 0, 3, 6
kind = tea → rows 1
origin = Darjeeling → rows 1
The index lists every key/value found inside any document, each with the rows that contain it. attrs @> '{"color":"teal"}' looks up one entry and gets rows 0 and 6 without reading the table. (Simplified: jsonb_path_ops really stores a hash of each path-and-value, but the idea is the same.)

Here it is on the real table:

shop=# CREATE INDEX catalog_attrs_gin ON catalog USING gin (attrs jsonb_path_ops);
shop=# EXPLAIN ANALYZE SELECT id, name FROM catalog WHERE attrs @> '{"color":"teal"}';
 Bitmap Heap Scan on catalog  (cost=12.86..51.60 rows=10 width=19) (actual time=0.018..0.018 rows=1 loops=1)
   Recheck Cond: (attrs @> '{"color": "teal"}'::jsonb)
   Heap Blocks: exact=1
   ->  Bitmap Index Scan on catalog_attrs_gin  (cost=0.00..12.86 rows=10 width=0) (actual time=0.010..0.010 rows=1 loops=1)
         Index Cond: (attrs @> '{"color": "teal"}'::jsonb)
 Execution Time: 0.037 ms

From 29.9 ms to 0.037 ms, about 800× faster on this machine. "Bitmap Index Scan on catalog_attrs_gin" is the index finding the matching row; "Heap Blocks: exact=1" says it then read just one page of the table.

One catch: the GIN index only helps the operators it was built for. The same question written with ->> ignores it:

shop=# EXPLAIN ANALYZE SELECT id, name FROM catalog WHERE attrs->>'color' = 'teal';
 Gather  (cost=1000.00..7713.01 rows=1500 width=19) (actual time=22.000..29.704 rows=1 loops=1)
   ->  Parallel Seq Scan on catalog  (… rows=0 loops=3)
         Filter: ((attrs ->> 'color'::text) = 'teal'::text)
 Execution Time: 29.714 ms

shop=# CREATE INDEX catalog_color ON catalog ((attrs->>'color'));   -- expression index
shop=# EXPLAIN ANALYZE SELECT id, name FROM catalog WHERE attrs->>'color' = 'teal';
 Index Scan using catalog_color on catalog  (cost=0.42..8.44 rows=1 width=19) (actual time=0.026..0.027 rows=1 loops=1)
   Index Cond: ((attrs ->> 'color'::text) = 'teal'::text)
 Execution Time: 0.039 ms

You can also put rules on the document part, so schema-on-write comes back for the fields you care about (real output):

shop=# ALTER TABLE catalog ADD CONSTRAINT attrs_has_kind CHECK (attrs ? 'kind');
shop=# INSERT INTO catalog VALUES (999999, 'Mystery', 100, '{"colour":"blue"}');
ERROR:  new row for relation "catalog" violates check constraint "attrs_has_kind"
DETAIL:  Failing row contains (999999, Mystery, 100, {"colour": "blue"}).

From Go and JavaScript

Both drivers convert JSONB to native values for you. Real runs against the table above:

catalog.go
// pgx v5, using its own API (pgx.Connect) rather than database/sql,
// because it can decode JSONB straight into a struct.
type MugAttrs struct {
    Kind       string `json:"kind"`
    Color      string `json:"color"`
    CapacityML int    `json:"capacity_ml"`
    Dishwasher bool   `json:"dishwasher_safe,omitempty"` // missing key → false
}

var name string
var attrs MugAttrs
err := conn.QueryRow(ctx,
    `SELECT name, attrs FROM catalog WHERE attrs @> $1`,
    map[string]any{"color": "teal"}, // ← encoded as JSON for you
).Scan(&name, &attrs)                // ← JSONB decoded into the struct
if err != nil {
    return err
}
fmt.Printf("%s: %+v\n", name, attrs)
// Ocean Mug: {Kind:mug Color:teal CapacityML:350 Dishwasher:true}

// Writing: pass a struct; pgx turns it into JSON.
_, err = conn.Exec(ctx,
    `INSERT INTO catalog (id, name, price_cents, attrs) VALUES ($1, $2, $3, $4)`,
    500001, "Red Mug", 1100, MugAttrs{Kind: "mug", Color: "red", CapacityML: 300})
// stored as: {"kind": "mug", "color": "red", "capacity_ml": 300}

When "just use Postgres" is right, and when it isn't

✕ Reach for a separate document DB when…
- your data is huge and append-heavy and must be
  spread over many machines from day one
- you need MongoDB-specific features your team
  already relies on (change streams, its sharding,
  Atlas search) and know how to run them
- documents are large and updated piece by piece
  very often (Postgres rewrites the whole JSONB
  value on every update)
✓ JSONB is enough when…
- some attributes vary per row (product specs,
  settings, form answers, webhook payloads)
- you also need joins, constraints or multi-row
  transactions with the rest of your data
- one machine (plus replicas) handles your load,
  which covers far more apps than people think
- you'd rather run one database than two
Staff-level§11

Scaling: what "NoSQL scales" really means

In one sentence"NoSQL scales" really means: many NoSQL databases were designed to be split across machines from day one, and they made that easy by giving up the features that make splitting hard: joins and transactions that span many rows.

The usual path

Scaling a database usually goes through the same stages. Each one buys a lot of headroom, and each later stage costs more complexity than the one before. Step through them:

Four stages of scaling a database
Each stage buys headroom; each later one costs more complexity.
your app servers
↓ every query
🐘 Postgres
4 cores · 16 GB
Gain: Simple. Joins, transactions and ad-hoc queries all just work.
Cost: Eventually CPU, memory or disk runs out.
  1. Scale up (vertical scaling). Move to a bigger machine. Today's large cloud machines have 100+ CPU cores and terabytes of RAM (approximate, and growing every year). A well-indexed Postgres or MySQL on one such machine can handle tens of thousands of simple queries per second. That's enough for the vast majority of companies. No code changes.
  2. Add read replicas. Copies of the database on other machines take the reads; the primary takes the writes. Most apps read far more than they write, so this multiplies capacity. The catch is that replicas lag slightly behind, so a user might not see their own change straight away (see Replication). A cache in front helps in the same way.
  3. Split the data (partitioning / sharding). When writes or data size outgrow one machine, each machine holds only a slice: customers 1–1M here, 1M–2M there. This is the step that's hard for a relational database, for reasons the next part explains. The schemes themselves (range vs hash, hot keys, resharding) are in Sharding.

Why splitting hurts relational databases

Once data is spread across machines, two everyday relational features suddenly need the network:

  • Joins across machines. If Ada's orders live on machine A and the Blue Mug's product row on machine B, the join from §02 now fetches rows from both, over the network, for every query. A local join takes microseconds; a network round trip within a data center takes around half a millisecond (approximate). Repeat that per row and it adds up fast.
  • Transactions across machines. The checkout from §09 might touch the order on machine A and the stock on machine B. Making both commit or neither needs a coordination protocol (such as two-phase commit) with extra round trips, and a crashed machine can leave the others waiting.

Run the join case and count the round trips:

One question, data on three machines
“Show Ada's latest orders with product names.” Each arrow to a shard is one network round trip (~0.5 ms, approximate).
orders on the page:
app server
shard A
customers, orders
shard B
product 10
shard C
product 11
round trips: 0 / 3 · network time ≈ 0.0 ms
The tables are split by different keys: orders by customer, products by product id. A join now needs the network.

Why NoSQL databases didn't have that problem

Look back at the rules DynamoDB and Cassandra impose. Every query must name a partition key. There are no joins. Atomicity stops at one item or partition. Those aren't arbitrary restrictions: they're exactly the rules that guarantee every operation touches one machine. That's what made it easy for them to grow by adding machines.

The price moved to the data model. You design your keys around your access patterns (§06), copy data into several tables (§07), and handle the rest in application code.

The line is blurring

The "SQL can't scale out" story is a decade out of date:

  • Distributed SQL databases (Google Spanner, CockroachDB, YugabyteDB, TiDB) partition data automatically and still offer SQL with multi-row transactions. They use replication protocols (Paxos or Raft) to keep the copies agreed. The cost: transactions that span machines, and especially regions, are slower than on one machine.
  • Sharding layers for existing databases: Vitess for MySQL (used at YouTube and Slack), Citus for Postgres.
  • NoSQL moving the other way: MongoDB added multi-document transactions, DynamoDB added TransactWriteItems and a SQL-like language (PartiQL), and Cassandra has long offered a SQL-like language.
Staff-level§12

Changing the shape over time

In one sentenceData outlives the code that wrote it. In SQL you change the shape of all rows at once with a migration, and the database enforces it. In a document store, old shapes linger, and every reader must understand every version until they're gone.

The example: customers have a single name ("Ada Lovelace"), and the product team now wants first_name and last_name.

SQL: migrations, in safe steps

A database migration is a versioned script that changes the schema. The SQL isn't the tricky part. The tricky part is that old code is still running while you deploy new code: for a few minutes, some servers run the old version and some the new. So no single step may break the version that's still running.

The safe recipe is expand / contract: six steps, alternating between database changes and code deploys. The rule behind every step: whatever the newest code writes, the oldest code still running must be able to read. Step through it, and try skipping ahead to see what breaks:

Expand / contract, one step at a time
Three app servers and the customers table. Code deploys roll out gradually, so versions overlap.
0 · before
app serverscode version
server 1 · v1server 2 · v1server 3 · v1
customers table3 rows, all with name
idnameNOT NULLemail
✓ nothing breaks: Every server runs v1, which reads and writes name.

Here is the whole run on Postgres 16, with the customers table from §02. Between the SQL steps, the statements each code version would send show that nothing breaks (real output):

migrations/0042_split_name.sql
-- 1. EXPAND: add the new columns, nullable. Old code (v1) doesn't know
--    they exist, and its inserts still work:
ALTER TABLE customers ADD COLUMN first_name text, ADD COLUMN last_name text;
INSERT INTO customers (id, name, email) VALUES (4, 'Cy Young', 'cy@example.com');
-- INSERT 0 1

-- 2. DEPLOY v2: writes BOTH shapes, still reads name.
INSERT INTO customers (id, name, first_name, last_name, email)
VALUES (5, 'Dee Okafor', 'Dee', 'Okafor', 'dee@example.com');
-- INSERT 0 1
--    Meanwhile a v1 server, not yet replaced, renames Dee. v1 only
--    knows name, so first_name/last_name still say Dee Okafor:
UPDATE customers SET name = 'Dee Smith' WHERE id = 5;

-- 3. Once no v1 server runs: BACKFILL in small batches; repeat until it
--    says UPDATE 0. Fix rows with no first_name AND rows where name no
--    longer matches (Dee).
UPDATE customers
SET first_name = split_part(trim(name), ' ', 1),
    last_name  = substr(trim(name), length(split_part(trim(name), ' ', 1)) + 2)
WHERE id IN (
  SELECT id FROM customers
  WHERE first_name IS NULL
     OR trim(name) IS DISTINCT FROM trim(first_name || ' ' || last_name)
  LIMIT 1000);
-- UPDATE 5      (then, run again: UPDATE 0)

-- 4. DEPLOY v3: READS the new columns, still WRITES both. v2 servers
--    still running read name, and v3 keeps filling it in for them.
INSERT INTO customers (id, name, first_name, last_name, email)
VALUES (6, 'Eve Moss', 'Eve', 'Moss', 'eve@example.com');
-- INSERT 0 1

-- 5. Once no v2 runs: let name be empty, then DEPLOY v4, which stops
--    writing name. v3 servers still running don't read name, so a NULL
--    there can't hurt them.
ALTER TABLE customers ALTER COLUMN name DROP NOT NULL;
INSERT INTO customers (id, first_name, last_name, email)
VALUES (7, 'Fay', 'Lin', 'fay@example.com');
-- INSERT 0 1

-- 6. CONTRACT: once no code anywhere reads or writes name,
--    enforce the new shape and remove the old one.
ALTER TABLE customers ALTER COLUMN first_name SET NOT NULL;
ALTER TABLE customers DROP COLUMN name;

After the last step, SELECT * FROM customers prints (note Dee's corrected last name):

 id |       email       | first_name |    last_name
----+-------------------+------------+-----------------
  1 | ada@example.com   | Ada        | Lovelace
  2 | bob@example.com   | Bob        | Ng
  3 | grace@example.com | Grace      | Brewster Hopper
  4 | cy@example.com    | Cy         | Young
  5 | dee@example.com   | Dee        | Smith
  6 | eve@example.com   | Eve        | Moss
  7 | fay@example.com   | Fay        | Lin

Why the backfill also compares name with the new columns: during step 2, old v1 servers can still edit name after v2 has filled in the new columns, as with Dee. Checking only for NULLs would skip her, and after step 6 she'd be "Dee Okafor" forever.

And here is what went wrong when I ran the steps out of order. The first three are real Postgres errors; the last is a real error from a Go v2 server that reads name into a string with rows.Scan, as in §02:

-- Backfilled BEFORE deploying v2, so v1 inserted Cy with first_name NULL; then step 6:
ERROR:  column "first_name" of relation "customers" contains null values

-- Dropped name while v2 servers were still writing it:
ERROR:  column "name" of relation "customers" does not exist

-- Deployed v4 (no name) without first dropping NOT NULL on name:
ERROR:  null value in column "name" of relation "customers" violates not-null constraint

-- Skipped v3: deployed "stop writing name" while v2 servers still read it. v2 loads Fay:
sql: Scan error on column index 0, name "name": converting NULL to string is unsupported

After step 6, every row in the table has the new shape, and the database guarantees it. There's no "version 1 customer" anywhere for a reader to trip over. The cost is three code deploys (v2, v3, v4), four database steps and some patience. A plain ADD COLUMN (even with a constant default) is instant in Postgres 11+; the batched backfill is the slow part on big tables.

Documents: versions and lazy migration

A document store could do the same thing with a script that rewrites every document. Often teams don't. With millions of documents spread over many machines, they choose lazy migration instead: each document carries a schemaVersion, and the app upgrades old documents when it next touches them.

customer.go
type Customer struct {
    ID            int    `bson:"_id"`
    SchemaVersion int    `bson:"schemaVersion"`       // missing in v1 docs → 0
    Name          string `bson:"name,omitempty"`      // v1 only
    FirstName     string `bson:"firstName,omitempty"` // v2
    LastName      string `bson:"lastName,omitempty"`  // v2
}

// upgrade turns any older shape into the current one.
// The bool reports whether anything changed, so the caller can save it.
func upgrade(c Customer) (Customer, bool) {
    if c.SchemaVersion >= 2 {
        return c, false
    }
    first, last, _ := strings.Cut(c.Name, " ") // "Ada Lovelace" → "Ada", "Lovelace"
    c.FirstName, c.LastName, c.Name = first, last, ""
    c.SchemaVersion = 2
    return c, true
}
// upgrade(Customer{ID: 1, Name: "Ada Lovelace"})
// → {ID:1 SchemaVersion:2 Name: FirstName:Ada LastName:Lovelace}, true

Here's how a lazy migration plays out over time:

A lazy migration, over a few months
Splitting name into firstName / lastName in a customers collection.
what the code does
reads: name
customers collection
Ada · v1 · name
Bob · v1 · name
Cy · v1 · name
Dee · v1 · name
Eve · v1 · name
Fay · v1 · name
Today every customer document has a single name field, and there's no schemaVersion, which we'll treat as version 1.
1 / 7

Where did the integrity checks go?

Count what moved out of the database and into your code as we went from tables to documents:

RuleRelational databaseDocument / key-value store
"This order's customer exists"Foreign key, checked on every writeYour code; orphans are possible if a delete races an insert
"Emails are unique"UNIQUE constraintA unique index (MongoDB has them), or a separate "claim" item (DynamoDB, Cassandra LWT)
"Price is a non-negative integer"Column type + CHECKYour code, or an optional validator ($jsonSchema)
"Every record has the current shape"Migration + NOT NULLschemaVersion + upgrade code in every reader
Staff-level§13

Choosing like a staff engineer

In one sentencePick the database by asking, in order: what are the access patterns, how correct must the data be, what does the team know, and who runs it at 3 a.m.? Brand and hype aren't on the list.

Four questions, in order

1

Access patterns

List the top questions with rough rates (§08). Are they known and stable, or will product keep inventing new ones? Unknown or changing → you want ad-hoc queries → relational.
2

Consistency needs

Which writes must be all-or-nothing across several records (money, stock, bookings)? Which reads must be fresh? Lots of both → ACID transactions (§09).
3

Team & operations

Who knows how to model, tune, back up, restore and upgrade this database? A managed service shifts the work but not the expertise. Every extra database is another thing to keep running.

The fourth question is scale, and it comes last on purpose. Put real numbers on it (§11). Most systems never outgrow one well-run relational database plus replicas and a cache. The ones that do usually know it early, because the numbers are obvious (IoT fleets, chat at consumer scale, ad tracking).

Walk through the same questions interactively:

Which data model?
A starting point, not a law. Answer for your main workload.
Do you know your main access patterns, and are they stable?
Can you list the top questions the app will ask, and are they unlikely to change much?

Polyglot persistence

Real systems often use several stores, each for what it's best at. That's polyglot persistence:

StoreRoleSource of truth?
PostgresOrders, customers, paymentsYes
RedisSessions, carts, rate limits, cached pagesNo (a cache, see Caching)
Search engineFull-text product search, filtersNo: derived from Postgres
Data warehouseAnalytics over years of historyNo: derived

The column that matters is the last one. Exactly one store owns each fact. Everything else is a read model: a copy shaped for a particular kind of read, which you can throw away and rebuild from the source of truth.

The trap: dual writes

The obvious way to keep the search index in sync is to write to both stores from your code:

product-service.mjs — the bug
await db.query("UPDATE products SET name = $1 WHERE id = $2", [name, id]); // ✓ committed
await search.index({ index: "products", id, document: { name } });         // ✗ times out
// Postgres says "Ocean Mug", the search index still says "Blue Mug"
// — and nothing will ever notice or fix it.

That's a dual write. Between the two calls, the process can crash, the network can fail, or a second request can update the same product in the opposite order. Then the two stores disagree, silently and permanently:

Two requests, two stores, two orders
req A
PG: name=Ocean
index: Ocean
req B
PG: name=Teal
index: Teal
result
PG=Teal · index=Ocean ✕
Each request writes Postgres then the search index. Request B's Postgres write lands last but its index write lands first, so the stores end up disagreeing, with no error anywhere.

Wrapping both calls in a database transaction doesn't help: the search engine isn't part of it. The fixes all follow one idea: write only to the source of truth, and let the copies follow it.

  • Outbox pattern: in the same transaction as the update, insert a row into an outbox table. A separate worker reads the outbox and updates the index, retrying until it succeeds. See Behavioral patterns.
  • Change data capture: a tool (Debezium is the best-known) reads Postgres's own write-ahead log and streams every committed change to the index, in commit order.
  • Rebuildable copies: because the index is derived, you can always rebuild it from scratch from Postgres. Schedule regular rebuilds or comparisons to catch drift.
Practitioner§14

Pitfalls gallery

#1

Picking NoSQL “because it scales” before counting anything

The team chooses Cassandra or DynamoDB for an app with 50 writes per second, then spends months building by hand the joins, reports and admin queries that SQL would have given them for free. Fix: write down peak writes/sec and data size first (§11). If one machine plus replicas covers it, start relational.

#2

Using a document database like a relational one

Separate customers, orders, orderItems and products collections, glued together with $lookup or one query per item. You get the costs of both models and the benefits of neither (and often the N+1 problem, see N+1 Queries). Fix: model documents around reads (§05), or use a relational database.

#3

Arrays that grow forever

Embedding every comment, message or sensor reading inside its parent document. It works in testing and fails in production, when popular documents approach the 16 MB limit and every read drags megabytes along. Fix: embed only what has a known, small maximum; reference the rest (§05).

#4

Designing DynamoDB keys before listing access patterns

Keys copied from the SQL schema (PK = orderId), then a Scan with a filter for "orders of customer 1". A Scan reads, and bills, every item in the table, even if the filter keeps 3. Fix: list the access patterns first, then design keys so each one is a Query (§06). Add a GSI for each new pattern.

#5

Reaching for ALLOW FILTERING

Cassandra refused the query, so someone added ALLOW FILTERING, and it worked fine on a 3-node test cluster. In production it asks every node to read every partition. Fix: create another table keyed for that query and write to both (§07).

#6

A partition key with too few values

Partitioning sensor readings by day alone puts all of today's writes on one partition, so on one set of machines, while the rest of the cluster sits idle. That's a hot partition. Fix: make the partition key spread the load ((sensor_id, day)), so each partition stays small and writes spread out. More in Sharding.

#7

One JSONB column for the whole record

CREATE TABLE things (id bigint, data jsonb), then every query is data->>'…'. You lose types, foreign keys, NOT NULL and the planner's statistics. Fix: real columns for anything you filter, join or enforce; JSONB only for the attributes that vary (§10).

#8

Dual writes to keep two stores in sync

await db.save(); await search.index();. A crash or timeout between the two leaves them disagreeing forever. Fix: write only to the source of truth; update the copies with an outbox or change data capture (§13).

#9

Forgetting the old document shapes

New code assumes firstName exists; a customer who signed up three years ago still has only name, and their profile page crashes. Fix: a schemaVersion field, one upgrade function every read goes through, a background job that finishes the migration, and tests with old-shaped documents (§12).

#10

Expecting to read your own write from an eventually consistent store

A user saves their profile, the page reloads, and the old data appears, because the read went to a copy that hadn't caught up (DynamoDB's default reads, a Cassandra ONE read, a lagging replica). Fix: use a strongly consistent read (ConsistentRead, QUORUM, the primary) for read-after-write flows, or return the saved data straight from the write (§09).

Practitioner§15

Check yourself

Decide before you click. Each explanation points back to the section that covers it: Q1 in §03, Q2 in §07, Q3 in §06, Q4 in §10, Q5 in §13, Q6 in §09, Q7 in §05.

QUESTION 1 / 7
“Blue Mug” appears in 50,000 orders. You rename it. How many writes?
QUESTION 2 / 7
Which query runs without ALLOW FILTERING?
CREATE TABLE readings (
  sensor_id text, day date, ts timestamp, value double,
  PRIMARY KEY ((sensor_id, day), ts)
);
QUESTION 3 / 7
Your DynamoDB table uses PK = CUSTOMER#id, SK = ORDER#date#id. Product asks for “all orders containing product 10”. Best fix?
QUESTION 4 / 7
catalog has a GIN index USING gin (attrs jsonb_path_ops). Which WHERE clause can use it?
QUESTION 5 / 7
What can go wrong here?
await db.query("UPDATE products SET name = $1 WHERE id = $2", [name, id]);
await search.index({ index: "products", id, document: { name } });
QUESTION 6 / 7
Cassandra keeps 3 copies. You write at ONE and read at ONE. Is the read guaranteed to see the write?
QUESTION 7 / 7
Building a chat app on MongoDB. Where should a channel's messages go?
Answered 0 / 7
Foundations§16

Cheat sheet

🧭
Access patterns firstList the questions your app asks all day, with rates. The model that makes those cheap wins.
🗄️
Relational: each fact onceNormalize, join at read time, let the planner pick the steps. Best when questions change and correctness matters.
📦
Document: read together, store togetherEmbed what's owned, small and read with the parent. Reference what's shared or grows forever (16 MB cap).
🔑
Key-value / wide-column: key = queryEvery query names a partition key. Design keys from access patterns; unplanned questions mean a Scan or a new table.
🕸️
Graph: hops are cheapUse it when multi-hop, variable-depth relationships are the product. 1–2 fixed hops: SQL is fine.
⚛️
Know the atomic unitSQL: any rows in a transaction. Mongo: one document. DynamoDB: one item (+ ≤100-item transactions). Cassandra: one partition.
🐘
Postgres does documents tooJSONB + GIN (@>) or an expression index (->>). Real columns for what you filter, join or enforce.
🔁
One source of truthCaches and search indexes are copies. Feed them with an outbox or CDC, never with dual writes.
If the main access pattern is…Start with
Many different questions, changing often; money, stock, bookingsRelational (Postgres / MySQL)
"Fetch this whole thing by id"; varying shapes per recordDocument (MongoDB), or Postgres + JSONB
"Get / set by exact key", sub-millisecond, can expireKey-value (Redis); DynamoDB if it must be durable at scale
Huge write firehose, read back by key + time rangeWide-column (Cassandra / ScyllaDB) or a time-series database
Multi-hop relationships (recommendations, fraud, permissions)Graph (Neo4j), usually next to a primary database
Full-text search, relevance rankingSearch engine, fed from the source of truth
Writes or data outgrow one big machine, and you need SQL + transactionsDistributed SQL (Spanner, CockroachDB, YugabyteDB) or a sharding layer (Vitess, Citus)
ConceptRelationalDocumentDynamoDBCassandra
Recordrowdocumentitemrow
Group of recordstablecollectiontabletable
Find by keyprimary key_idPK (+ SK)partition key (+ clustering)
Related dataforeign key + JOINembed or referencesame PK, sorted by SKone table per query
Schema enforcedon writeon read (optional validator)on read (keys only)on write (columns), query-first
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in DatabasesLSM Trees vs B-Trees
Read →