Document, key-value, and wide-column stores: access-pattern-first design, DynamoDB single-table with GSIs, and Cassandra hard rules.
⏱ 15 min readTopics chapter readerLevel · Specialized & Applied
01 · Overview
Start here
⏱ 5 min · Topic 1 of 13
Until now you built the model first and wrote queries later. In a relational database, a join can rescue a weak model. Many NoSQL stores have no join.
NoSQL is a name for stores that are not relational databases. Many of them split data across many machines, so they can stay fast with huge data. The price is that the model must fit the questions.
So the order of work turns around. First you list the questions the app asks. Then you shape the data so each question is one cheap read.
Recap: Normalization & Denormalization showed that a copy of a value removes a join but costs updates. Here you make copies on purpose.
What you will learn
How to list access patterns, and the four kinds of NoSQL store.
Documents: embed or reference.
Partition keys, sort keys, and one table for many kinds of items.
A second index, and one table for each query.
Hot partitions, the cost of copies, and when to stay relational.
02 · The method
Start from the queries
⏱ 7 min · Topic 2 of 13
An access pattern is one read or write that your app must do. Write each one down. Say what it is given and what it returns.
Take the shop behind a mobile app. The app needs three reads:
Three access patterns for the shop app
Name
Question
Given
Returns
P1
Show a customer’s recent orders.
A customer id.
That customer’s orders, newest first.
P2
Show one order with its items.
An order id.
The order and its items.
P3
Count the orders on one day.
A date.
One number.
In SQL, P2 is a join of orders and order_items. P3 is a GROUP BY. Many NoSQL stores cannot do either one fast, so you shape the data for each pattern instead.
Four kinds of store
NoSQL stores come in four main kinds:
Key-value: you give a key and get one value back. DynamoDB works this way.
Document: the value is a JSON document, text with named fields and lists inside it. MongoDB is one.
Wide-column: rows are grouped by a key and sorted inside each group. Cassandra is one.
Graph: the data is things and the links between them.
Graph stores have their own chapter, Graph Data Modeling. The next sections use the first three kinds. Each section solves one pattern from the table.
Interview question: design a NoSQL schema
Your first move is to say, "Let me list the access patterns." Then name the key for each one. Starting with tables and entities sounds like a relational habit.
03 · Documents
Documents: embed or reference
⏱ 7 min · Topic 3 of 13
A document store keeps JSON documents. For P2 you have two choices. You can embed the items inside the order document, or you can reference them by id.
Embedding puts the child data inside its parent, so one read returns both. Here are orders 101 and 105 as documents:
One read returns the order and both items. Order 105 has no items yet, so its list is empty.
Embed when the child is read with the parent, is small, and belongs to only that parent. Items match all three. Use a reference when one of the three is false.
P1 needs a reference
Could you embed all of a customer’s orders inside the customer? No. The list has no upper bound, and a document can be at most 16 MiB in MongoDB.
So each order keeps a customer_id. You add an index on customer_id and order_date.
Carla (customer 3) has orders 104 and 105. The sort -1 puts the newest first. Order 105 is pending, so it has no amount yet.
Watch out
Do not embed a list that keeps growing, such as all orders of a customer. Every read loads the whole list. One day the document reaches the size limit and writes fail.
04 · Keys
Partition key and sort key
⏱ 8 min · Topic 4 of 13
DynamoDB and Cassandra split a table into partitions. A partition is one slice of the table, kept together on a machine. The partition key decides which slice holds a row.
In DynamoDB, a row is called an item. The sort key puts the items of one partition in order. Cassandra calls it a clustering column.
A query gives one partition key. It can add a condition on the sort key, such as "begins with". The store then reads only that one slice.
Now design for P1. Use the customer as the partition key. Use the order date and id as the sort key, so one customer’s orders sit together, oldest first:
The shop orders as items (6 items)
PK (partition key)
SK (sort key)
status
amount
CUSTOMER#1
ORDER#2026-01-03#101
paid
40.00
CUSTOMER#1
ORDER#2026-01-10#102
paid
25.00
CUSTOMER#2
ORDER#2026-01-11#103
refunded
60.00
CUSTOMER#3
ORDER#2026-01-15#104
paid
15.50
CUSTOMER#3
ORDER#2026-01-20#105
pending
none yet
CUSTOMER#4
ORDER#2026-01-21#106
paid
80.00
Ask for the two newest orders of customer 3. The query reads only the CUSTOMER#3 slice, from the end:
P1 as a partition queryworked example
SQL
-- DynamoDB-style query, written in plain wordsQueryPK='CUSTOMER#3'SKbeginswith'ORDER#'newestfirst(readtheslicebackwards)limit2
Result · 2 rows
PK
SK
status
amount
CUSTOMER#3
ORDER#2026-01-20#105
pending
NULL
CUSTOMER#3
ORDER#2026-01-15#104
paid
15.50
The sort key holds the date, so the slice is already in date order. The store reads two items and no others.
Now ask which customer made order 104. Order id is not in the partition key. Without a second index, the only way is a scan.
A scan reads every item in the table.
A scan is fine for 6 items. With millions, it is slow and costs money for every item read. A filter on a scan drops items only after the store has read them.
Watch out
You cannot join at read time. If a screen needs two kinds of data, store them under one partition key, or accept two reads.
05 · One table
One table, many item types
⏱ 6 min · Topic 5 of 13
P2 needs an order and its items in one read. So give them the same partition key. The order is one item, and each product line is another item.
The items are different kinds, but they share one table. This is single-table design. The sort key says what kind of item each one is:
Two order partitions (6 items)
PK
SK
Holds
ORDER#101
HEADER
Customer 1, paid, 40.00
ORDER#101
ITEM#Notebook
Quantity 1
ORDER#101
ITEM#Pen
Quantity 2
ORDER#106
HEADER
Customer 4, paid, 80.00
ORDER#106
ITEM#Chair
Quantity 1
ORDER#106
ITEM#Lamp
Quantity 1
Items in one slice sort by their sort key, so HEADER comes before ITEM#. Read order 101 with one partition key:
P2 as a partition queryworked example
SQL
-- DynamoDB-style query, written in plain wordsQueryPK='ORDER#101'
Result · 3 rows
PK
SK
Holds
ORDER#101
HEADER
Customer 1, paid, 40.00
ORDER#101
ITEM#Notebook
Quantity 1
ORDER#101
ITEM#Pen
Quantity 2
Three items come back from one slice, with no join. All items that share one partition key are called an item collection.
Single-table design is a choice, not a rule. Many teams keep one table for each kind of item and are happy. The next table compares the two:
One table or many tables
One table for all kinds
One table for each kind
P2: order with items
One read.
Two reads: the order, then its items.
Reading the data by eye
Hard. Keys carry the meaning.
Easy. Each table looks like a SQL table.
A new question later
May need new keys, an index, or a data rewrite.
Needs a new index or a new table.
Watch out
A DynamoDB item can be at most 400 KB. Do not put a list that grows without end inside one item. Make each list entry its own item, as the ITEM# rows above.
06 · A second question
A second index
⏱ 6 min · Topic 6 of 13
P1 asks for the orders of one customer. But each order now sits under its own ORDER# key. The orders of customer 3 are in two different slices, so no single partition key finds them.
A global secondary index (GSI) fixes this. It is a second view of the main table, called the base table, with a different partition key and sort key. The store keeps it up to date for you.
Give each order header two extra attributes. An attribute is one named value in an item, like a column. GSI1PK is the customer, and GSI1SK is the date and order id.
Items without these attributes do not appear in the index:
P1 on the indexworked example
SQL
Input data
GSI1 (6 headers; the 7 item rows are not in it)6 rows
GSI1PK
GSI1SK
base table PK
CUSTOMER#1
2026-01-03#101
ORDER#101
CUSTOMER#1
2026-01-10#102
ORDER#102
CUSTOMER#2
2026-01-11#103
ORDER#103
CUSTOMER#3
2026-01-15#104
ORDER#104
CUSTOMER#3
2026-01-20#105
ORDER#105
CUSTOMER#4
2026-01-21#106
ORDER#106
-- DynamoDB-style query on index GSI1, in plain wordsQueryindexGSI1GSI1PK='CUSTOMER#3'newestfirstlimit2
Result · 2 rows
GSI1PK
GSI1SK
base table PK
CUSTOMER#3
2026-01-20#105
ORDER#105
CUSTOMER#3
2026-01-15#104
ORDER#104
The table holds 13 items: 6 headers and 7 item rows. Only the 6 headers carry GSI1PK, so the index is small.
An index has costs:
The store updates a GSI a moment after the table. So a new order may not show in the index for a short time.
Every write to an indexed item also writes to the index, so writes cost more.
Cassandra takes a different path. The next section shows it.
07 · Cassandra
One table for each query
⏱ 5 min · Topic 7 of 13
In Cassandra, a query should name the partition key. The common advice is simple. Make one table for each query, and write to every table when data arrives.
Here is a table for P1. The partition key is customer_id. The rows inside each partition are sorted by order_date, newest first:
orders_by_customer, and P1worked example
SQL
Input data
orders_by_customer (rows grouped by partition, sorted inside)6 rows
The query reads the partition for customer 3 and stops after 2 rows. The rows are already newest first, so no sort runs.
P2 gets its own table, orders_by_id, with the order id as the partition key. When order 107 arrives, your code writes it to both tables. That is the price of fast reads.
Keep each partition small. A partition that grows without end gets slow to read and hard to manage. A common fix is to add a month to the partition key.
08 · Spread the load
Hot partitions and sharding
⏱ 6 min · Topic 8 of 13
P3 counts the orders on one day. A simple design uses the date as the partition key: DAY#2026-01-21. Every new order that day writes into that one partition.
A partition can handle only a limited amount of traffic. A partition that gets far more traffic than the others is a hot partition. On a busy day the store may slow down or refuse writes.
The fix is write sharding. You add a small number, the shard, to the key. Then one day uses several partitions.
Here is an imagined busy day. Order 106 is real. Orders 340, 341 and 342 are made up:
Pick a shard for each orderworked example
SQL
Input data
busy_day (imagined)4 rows
order_id
order_date
106
2026-01-21
340
2026-01-21
341
2026-01-21
342
2026-01-21
-- shard = the remainder of order_id divided by 3SELECTorder_id,'DAY#2026-01-21#'||(order_id%3)ASpartition_keyFROMbusy_dayORDERBYorder_id;
Result · 4 rows
order_id
partition_key
106
DAY#2026-01-21#1
340
DAY#2026-01-21#1
341
DAY#2026-01-21#2
342
DAY#2026-01-21#0
106 and 340 both leave remainder 1. 341 leaves 2, and 342 leaves 0. The writes now go to three partitions.
Now P3 must read every shard and add the counts. That is the cost of sharding: one read becomes three.
The input is the busy_day table above. The day total is 1 + 2 + 1 = 4 orders. The real shop has only one order on each day, so it needs no shards.
Watch out
A partition key such as the date or the order status sends today’s writes to one or a few places. Choose a key with many different values that get similar traffic. Add a shard number only when one key is still too busy.
09 · Copies
Copies and what they cost
⏱ 6 min · Topic 9 of 13
Say the P1 screen shows the customer’s name beside each order. Without a join, you copy the name into each order summary. One read then fills the screen.
The copy has a price. Imagine Asha changes her name to Asha Rao. Every copy of the old name must change:
Update every copy of the nameworked example
SQL
Input data
order_summaries (a copy of the name in every row)6 rows
order_id
customer_id
customer_name
amount
101
1
Asha
40.00
102
1
Asha
25.00
103
2
Ben
60.00
104
3
Carla
15.50
105
3
Carla
NULL
106
4
Dev
80.00
-- SQL-style, to show which rows change. A NoSQL store needs one write per row.UPDATEorder_summariesSETcustomer_name='Asha Rao'WHEREcustomer_id=1RETURNINGorder_id,customer_name;
Result · 2 rows
order_id
customer_name
101
Asha Rao
102
Asha Rao
Two summary rows change, plus the customer profile: 3 writes. A customer with 5,000 orders would need 5,001.
Compare the two designs for the name:
Copy the name or keep a reference
Copy the name
Keep only customer_id
Reads for the P1 screen
One.
Two: the orders, then the profile.
Writes when a name changes
One for the profile and one for each order.
One.
What can go wrong
More writes, and some can fail.
The screen is a little slower.
Some copies must never change. Say each order stores the country the customer had on that day. Order 101 stores IN, and Asha’s move to DE on 2026-01-08 must not change it.
Temporal & Historical Modeling covers this idea.
Watch out
The store does not update copies for you. If your code fails after the first write, the old and new names live side by side. Plan a retry or a repair job.
10 · Choose
Choose a store
⏱ 8 min · Topic 10 of 13
Pick the store from the dominant access pattern, not from fashion. Start with what the workload needs:
What the workload needs, and a fit
Need
A good fit
Read by key, at very large scale.
Key-value or wide-column: DynamoDB, Cassandra.
Read a whole nested object at once.
Document store: MongoDB.
Follow links, many steps deep.
Graph store. See Graph Data Modeling.
New questions every week, many joins, strict rules.
A relational database.
Totals over all history.
A warehouse, not the app store.
The shop has 5 customers and 6 orders. A relational database is the right answer today.
NoSQL is not a step up from a relational database.
Consistency
A store keeps copies of data on several machines. These copies are called replicas. A read may reach a replica that has not yet seen the newest write.
Two kinds of read
Kind
What you get
Cost
Eventually consistent
A read may return old data for a short time.
Cheaper and faster.
Strongly consistent
A read returns the latest write.
Slower, and it costs more.
You choose per read. DynamoDB reads are eventually consistent by default. You can ask for a strongly consistent read on the base table, but not on a GSI.
In Cassandra you set a consistency level for each query. ONE waits for one replica. QUORUM waits for most replicas.
P1 can use the cheap default, because an order that is a second late is harmless. A check that an order is paid before it ships needs the latest value.
Interview question: when not to use NoSQL?
Stay relational when the questions are not known, when many joins are needed, or when strict rules span tables. Pick NoSQL when one known access pattern needs scale or low delay.
11 · Recap
Recap
⏱ 4 min · Topic 11 of 13
Six ideas to keep:
List the access patterns first. The queries are the spec.
In a document store, embed a small child that belongs to one parent. Reference an unbounded list.
The partition key decides the slice. The sort key orders the items inside it.
Put items that you read together under one partition key. A GSI serves a second question.
Spread busy keys with a shard number. The price is one read for each shard.
Every copy removes a read and adds a write. Stay relational when you cannot list the questions.
Check yourself
Why is P1 a reference in MongoDB, and P2 an embed?
In the shop table, why does P1 on CUSTOMER#3 return order 105 first?
On the busy day, why is the count 4, and how many reads does P3 need?
12 · Practice
Practice lab
⏱ 3 min · Topic 12 of 13
Each exercise opens a canvas for an entity-relationship diagram (ERD). You draw the model, and the review checks it against sample rows.
Start with the first one. Write the questions the app asks before you draw anything.