NoSQL is an umbrella name for databases that do not store data in related tables: they use documents (MongoDB), key-value pairs (Redis), wide rows (Cassandra) or a graph (Neo4j). The document model fits data that is read and changed together, such as an order with its line items; it gets in the way when entities have many relationships, shared data gets duplicated, and one operation must change several records atomically. If most of your data is relational and only part of it needs to be flexible, a jsonb column in PostgreSQL is usually enough.
After this you will be able to
- tell document, key-value, wide-column and graph models apart and name a typical job for each
- model an order with line items as a MongoDB document and as a PostgreSQL row with
jsonb, and compare the queries - explain where embedding hurts: duplicated data, no schema checks, changes that span several documents
- pick a data store from the decision table and justify the choice
Know beforehand
Verified with
MongoDB 8.3 (mongo:8), mongosh 2.12PostgreSQL 17.11Redis 7.4
Checked against docs: October 11, 2026
Contents
Tables and the four NoSQL families
A relational database stores data in tables with declared columns, links them with keys, and a query assembles what it needs with JOIN. NoSQL is the name for stores built differently. They have little in common: each is optimised for its own access pattern and gives up some of what relational systems provide by default — a strict schema, arbitrary JOINs, or transactions that span many records.
Relational
- How it stores data
- Tables and rows, links by keys
- Example
- PostgreSQL, MySQL
- Strength
- Any query and
JOIN, constraints, transactions - Weak spot
- Structure changes go through migrations
Document
- How it stores data
- JSON-like documents (BSON in MongoDB) in collections
- Example
- MongoDB
- Strength
- A whole object comes back in one query, documents may have different fields
- Weak spot
- Relationships between documents and duplicated data
Key-value
- How it stores data
- A value under a unique key, in memory
- Example
- Redis
- Strength
- Simple, fast operations by key, key expiry (TTL)
- Weak spot
- Searching by content needs extra structures
Wide-column
- How it stores data
- Tables spread across cluster nodes by partition key
- Example
- Apache Cassandra
- Strength
- Reads and writes by key across many servers
- Weak spot
- No
JOIN; each table is designed for a specific query
Graph
- How it stores data
- Nodes, relationships and their properties
- Example
- Neo4j
- Strength
- Traversing relationships to any depth
- Weak spot
- Narrow profile: the rest of the data usually lives elsewhere
The document model: an order as one document
Take a coffee shop order: a customer and line items. In a relational database that is at least three tables — orders, items and customers. In MongoDB the order is one document: the customer is a nested object and the items are an array. The core modelling rule in the MongoDB documentation is that data accessed together should be stored together. All you need to follow along is Docker: docker run -d --rm --name kb-mongo-nosql mongo:8 starts a throwaway server that is removed after docker stop.
// orders.mongo.js — run: mongosh shop --quiet orders.mongo.js
const show = (x) => print(JSON.stringify(x));
db.orders.drop();
db.orders.insertMany([
{
_id: 1001,
status: "paid",
customer: { id: 7, name: "Aigerim", email: "aigerim@example.com" },
items: [
{ sku: "LATTE", title: "Latte", price: 1600, qty: 2 },
{ sku: "CROISSANT", title: "Croissant", price: 1200, qty: 1 }
]
},
{
_id: 1002,
status: "new",
customer: { id: 8, name: "Timur", email: "timur@example.com" },
items: [{ sku: "LATTE", title: "Latte", price: 1600, qty: 1 }]
},
{
_id: 1003,
status: "paid",
customer: { id: 7, name: "Aigerim", email: "aigerim@example.com" },
items: [{ sku: "ESPRESSO", title: "Espresso", price: 1100, qty: 3 }]
}
]);
// 1. The whole order in one read, no JOIN
show(db.orders.findOne({ _id: 1001 }, { _id: 0, "customer.name": 1, "items.sku": 1, "items.qty": 1 }));
// 2. Orders that contain a latte (search inside an array)
show(db.orders.find({ "items.sku": "LATTE" }, { _id: 1 }).toArray());
// 3. Revenue per product in paid orders
show(db.orders.aggregate([
{ $match: { status: "paid" } },
{ $unwind: "$items" },
{ $group: { _id: "$items.sku", revenue: { $sum: { $multiply: ["$items.price", "$items.qty"] } } } },
{ $sort: { revenue: -1 } }
]).toArray());
// 4. Add an item and change the status: one atomic write to one document
show(db.orders.updateOne(
{ _id: 1002 },
{ $push: { items: { sku: "CROISSANT", title: "Croissant", price: 1200, qty: 1 } }, $set: { status: "paid" } }
));
// 5. Customer data is copied into every order: the email changed, so fix every copy
show(db.orders.updateMany({ "customer.id": 7 }, { $set: { "customer.email": "aigerim.new@example.com" } }));$ docker cp orders.mongo.js kb-mongo-nosql:/tmp/
$ docker exec kb-mongo-nosql mongosh shop --quiet /tmp/orders.mongo.js
{"customer":{"name":"Aigerim"},"items":[{"sku":"LATTE","qty":2},{"sku":"CROISSANT","qty":1}]}
[{"_id":1001},{"_id":1002}]
[{"_id":"ESPRESSO","revenue":3300},{"_id":"LATTE","revenue":3200},{"_id":"CROISSANT","revenue":1200}]
{"acknowledged":true,"insertedId":null,"matchedCount":1,"modifiedCount":1,"upsertedCount":0}
{"acknowledged":true,"insertedId":null,"matchedCount":2,"modifiedCount":2,"upsertedCount":0}The first query returns the order with its items and no JOIN. The second searches inside an array: the condition "items.sku": "LATTE" matches if at least one element fits. The third is an aggregation pipeline: $unwind turns the array into separate records and $group sums them. The fourth adds an item and changes the status in one operation: a write to a single document in MongoDB is atomic even when it touches several nested fields — either every change applies or none does.
Look at the price inside each item. It is not a duplicate but a snapshot: the price at the time of the order must not follow the price list. Data like this — owned by the parent, bounded in size and read with it — is exactly what the MongoDB documentation recommends embedding.
Embed in the document
- a “contains” relationship: an order contains items
- the data is read and updated together
- the nested array is bounded
- the data is archived together with the parent
Store separately and reference
- the parent has many children and their number grows without bound
- duplicates are hard to keep up to date
- the nested data is often written separately from the parent
- the entity exists on its own: a customer, a product
Where documents get in the way: duplicates, schema, transactions
In the example the customer is embedded in every order. As a “delivered to” snapshot that is fine. But if customer management runs on the same data and the customer changes their email, the fifth operation has to update two documents — matchedCount: 2. In a real database that means thousands of copies, and application code has to update them. In a relational schema the email lives in one row of the customers table.
The second price of flexibility is the schema. By default a collection accepts documents of any shape. Below the price is stored as a string: the insert succeeds, and the error surfaces later, in a report.
// schema.mongo.js — run after orders.mongo.js
const show = (x) => print(JSON.stringify(x));
// Nobody checks the schema: a price stored as a string is accepted
show(db.orders.insertOne({ _id: 1004, status: "paid", items: [{ sku: "LATTE", price: "1600", qty: 1 }] }));
// ...and breaks the report later, at read time
try {
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $unwind: "$items" },
{ $group: { _id: "$items.sku", revenue: { $sum: { $multiply: ["$items.price", "$items.qty"] } } } }
]).toArray();
} catch (e) {
print(e.message);
}
db.orders.deleteOne({ _id: 1004 });
// A $jsonSchema validator: you switch the checks on yourself
db.runCommand({
collMod: "orders",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["status", "items"],
properties: {
status: { enum: ["new", "paid", "cancelled"] },
items: {
bsonType: "array",
minItems: 1,
items: {
bsonType: "object",
required: ["sku", "price", "qty"],
properties: {
price: { bsonType: ["int", "long", "double", "decimal"], minimum: 0 },
qty: { bsonType: "int", minimum: 1 }
}
}
}
}
}
}
});
try {
db.orders.insertOne({ _id: 1005, status: "paid", items: [{ sku: "LATTE", price: "1600", qty: 1 }] });
} catch (e) {
print(e.errmsg);
}$jsonSchema validator it is rejected$ docker exec kb-mongo-nosql mongosh shop --quiet /tmp/schema.mongo.js
{"acknowledged":true,"insertedId":1004}
Executor error during aggregate command on namespace: shop.orders :: caused by :: $multiply only supports numeric types, not string
Document failed validationA $jsonSchema validator fixes this, but it has to be designed and maintained like a table schema. In the error details (errInfo) MongoDB names the price field and the reason type did not match. Documents already stored are not re-checked when you add rules: the default strict level applies them to all new inserts and updates.
The third is transactions. In MongoDB an operation on a single document is atomic, so embedding often removes the need for a transaction. Multi-document transactions exist — on replica sets and sharded clusters — but the documentation warns that they usually cost more than single-document writes and should not replace good schema design. If a business operation naturally changes several independent entities — a payment, a stock level, a loyalty balance — a relational database with transactions and foreign keys handles it more directly.
Key-value, wide-column and graph stores
Redis keeps data in memory and describes itself as a data structure server: besides strings it has hashes, lists, sets, sorted sets, streams and other types. Typical jobs are caching, sessions, counters, queues and rate limiting. You read a value by its exact key, so keys are designed around how you read: finding “all sessions of user 7” is not possible without an extra structure. A throwaway server for the example: docker run -d --rm --name kb-redis-nosql redis:7-alpine.
$ docker exec -it kb-redis-nosql redis-cli
127.0.0.1:6379> SET session:9f2c '{"userId":7,"cart":["LATTE"]}' EX 1800
OK
127.0.0.1:6379> GET session:9f2c
"{\"userId\":7,\"cart\":[\"LATTE\"]}"
127.0.0.1:6379> TTL session:9f2c
(integer) 1800
127.0.0.1:6379> INCR views:product:LATTE
(integer) 1
127.0.0.1:6379> INCR views:product:LATTE
(integer) 2
127.0.0.1:6379> DEL session:9f2c
(integer) 1
127.0.0.1:6379> GET session:9f2c
(nil)EX 1800 sets a time to live: the key disappears on its own after 30 minutes. INCR increments a counter atomically even when hundreds of requests call it at once. Using Redis as a cache in front of the main database is covered in the caching lesson.
Wide-column databases such as Apache Cassandra spread data across cluster nodes by partition key. The Cassandra documentation states plainly that modelling is query-driven and joins are not supported, so data is denormalised: the same information goes into several tables, each built for one query. This is a choice for very large write volumes across many servers, not for the first version of a product.
Graph databases such as Neo4j store nodes and relationships as first-class data: a relationship has a type, a direction and properties. They fit when the main question is about paths and connections: friends of friends, recommendations, routes, dependencies between objects. A relational database answers such questions with a chain of JOINs or a recursive query, and the deeper the traversal, the harder the query.
jsonb in PostgreSQL: a document inside a table
PostgreSQL has two JSON types. json stores the text as is and parses it on every access. jsonb stores a parsed binary form: inserts are slightly slower, processing is significantly faster, and GIN indexes are available for search. The documentation recommends jsonb for most applications. Below are the same orders: the status in a regular column with a check, the rest in a document. The server: docker run -d --rm --name kb-pg-nosql -e POSTGRES_PASSWORD=test postgres:17-alpine.
-- orders_jsonb.sql — run: psql -U postgres -f orders_jsonb.sql
DROP TABLE IF EXISTS orders_doc;
CREATE TABLE orders_doc (
id integer PRIMARY KEY,
status text NOT NULL CHECK (status IN ('new', 'paid', 'cancelled')),
doc jsonb NOT NULL
);
INSERT INTO orders_doc VALUES
(1001, 'paid', '{"customer": {"id": 7, "name": "Aigerim", "email": "aigerim@example.com"},
"items": [{"sku": "LATTE", "title": "Latte", "price": 1600, "qty": 2},
{"sku": "CROISSANT", "title": "Croissant", "price": 1200, "qty": 1}]}'),
(1002, 'new', '{"customer": {"id": 8, "name": "Timur", "email": "timur@example.com"},
"items": [{"sku": "LATTE", "title": "Latte", "price": 1600, "qty": 1}]}'),
(1003, 'paid', '{"customer": {"id": 7, "name": "Aigerim", "email": "aigerim@example.com"},
"items": [{"sku": "ESPRESSO", "title": "Espresso", "price": 1100, "qty": 3}]}');
-- GIN index for the containment operator @>
CREATE INDEX orders_doc_gin ON orders_doc USING GIN (doc jsonb_path_ops);
-- 1. The whole order is one row, no JOIN
SELECT doc -> 'customer' ->> 'name' AS customer,
jsonb_path_query_array(doc, '$.items[*].sku') AS skus
FROM orders_doc WHERE id = 1001;
-- 2. Orders that contain a latte
SELECT id FROM orders_doc
WHERE doc @> '{"items": [{"sku": "LATTE"}]}'
ORDER BY id;
-- 3. Revenue per product in paid orders
SELECT item ->> 'sku' AS sku,
sum((item ->> 'price')::int * (item ->> 'qty')::int) AS revenue
FROM orders_doc, jsonb_array_elements(doc -> 'items') AS item
WHERE status = 'paid'
GROUP BY 1
ORDER BY revenue DESC;
-- 4. Add an item and change the status in one UPDATE
UPDATE orders_doc
SET doc = jsonb_set(doc, '{items}',
(doc -> 'items') || '{"sku": "CROISSANT", "title": "Croissant", "price": 1200, "qty": 1}'),
status = 'paid'
WHERE id = 1002;
-- 5. Customer data is copied into every order: fix every copy
UPDATE orders_doc
SET doc = jsonb_set(doc, '{customer,email}', '"aigerim.new@example.com"')
WHERE doc @> '{"customer": {"id": 7}}';$ docker cp orders_jsonb.sql kb-pg-nosql:/tmp/
$ docker exec kb-pg-nosql psql -U postgres -f /tmp/orders_jsonb.sql
DROP TABLE
CREATE TABLE
INSERT 0 3
CREATE INDEX
customer | skus
----------+------------------------
Aigerim | ["LATTE", "CROISSANT"]
(1 row)
id
------
1001
1002
(2 rows)
sku | revenue
-----------+---------
ESPRESSO | 3300
LATTE | 3200
CROISSANT | 1200
(3 rows)
UPDATE 1
UPDATE 2The results match MongoDB. @> is the containment operator: a row matches if the document contains the given fragment. The jsonb_path_ops index class supports exactly such queries and, according to the documentation, is usually smaller than the default one. Fields you often filter on and that must be strict are easier to keep as regular columns, with the changeable part in jsonb.
-- json keeps the text as is, jsonb keeps the parsed value
SELECT '{"b": 1, "a": 2, "a": 3}'::json AS as_json,
'{"b": 1, "a": 2, "a": 3}'::jsonb AS as_jsonb;
-- With three rows the planner prefers a full scan; switch it off to see that the index fits
SET enable_seqscan = off;
EXPLAIN (COSTS OFF)
SELECT id FROM orders_doc WHERE doc @> '{"items": [{"sku": "LATTE"}]}';
RESET enable_seqscan;
-- jsonb does not check the structure either: a string price gets in...
INSERT INTO orders_doc VALUES (1004, 'paid', '{"items": [{"sku": "LATTE", "price": "1600", "qty": 1}]}');
DELETE FROM orders_doc WHERE id = 1004;
-- ...until you add a constraint
ALTER TABLE orders_doc ADD CONSTRAINT items_is_array
CHECK (jsonb_typeof(doc -> 'items') = 'array');
INSERT INTO orders_doc VALUES (1005, 'new', '{"items": {"sku": "LATTE"}}');$ docker exec kb-pg-nosql psql -U postgres -f /tmp/jsonb_extra.sql
as_json | as_jsonb
--------------------------+------------------
{"b": 1, "a": 2, "a": 3} | {"a": 3, "b": 1}
(1 row)
SET
QUERY PLAN
---------------------------------------------------------------------
Bitmap Heap Scan on orders_doc
Recheck Cond: (doc @> '{"items": [{"sku": "LATTE"}]}'::jsonb)
-> Bitmap Index Scan on orders_doc_gin
Index Cond: (doc @> '{"items": [{"sku": "LATTE"}]}'::jsonb)
(4 rows)
RESET
INSERT 0 1
DELETE 1
ALTER TABLE
psql:/tmp/jsonb_extra.sql:18: ERROR: new row for relation "orders_doc" violates check constraint "items_is_array"
DETAIL: Failing row contains (1005, new, {"items": {"sku": "LATTE"}}).jsonbdrops duplicate keys (the last value wins) and does not keep key order;jsonkeeps the text unchanged.- The index fits the
@>query. With three rows a full scan is cheaper for the planner, so it is switched off for the demonstration. - Like MongoDB without a validator,
jsonbaccepts a price as a string: the structure is checked byCHECKconstraints or application code.
The PostgreSQL documentation gives two practical tips. Keep JSON documents to a predictable structure — that makes them easier to query. And keep them small: any update locks the whole row, so large, frequently changed documents get in the way of concurrent transactions.
-- orders_relational.sql — the same orders in tables
DROP TABLE IF EXISTS order_items, orders, products, customers;
CREATE TABLE customers (
id integer PRIMARY KEY,
name text NOT NULL,
email text NOT NULL UNIQUE
);
CREATE TABLE products (
sku text PRIMARY KEY,
title text NOT NULL
);
CREATE TABLE orders (
id integer PRIMARY KEY,
customer_id integer NOT NULL REFERENCES customers (id),
status text NOT NULL CHECK (status IN ('new', 'paid', 'cancelled'))
);
CREATE TABLE order_items (
order_id integer NOT NULL REFERENCES orders (id),
sku text NOT NULL REFERENCES products (sku),
price integer NOT NULL CHECK (price >= 0), -- price at the time of the order
qty integer NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, sku)
);
INSERT INTO customers VALUES (7, 'Aigerim', 'aigerim@example.com'), (8, 'Timur', 'timur@example.com');
INSERT INTO products VALUES ('LATTE', 'Latte'), ('CROISSANT', 'Croissant'), ('ESPRESSO', 'Espresso');
INSERT INTO orders VALUES (1001, 7, 'paid'), (1002, 8, 'new'), (1003, 7, 'paid');
INSERT INTO order_items VALUES
(1001, 'LATTE', 1600, 2), (1001, 'CROISSANT', 1200, 1),
(1002, 'LATTE', 1600, 1),
(1003, 'ESPRESSO', 1100, 3);
-- Revenue per product: the same result, via JOIN
SELECT i.sku, sum(i.price * i.qty) AS revenue
FROM orders o JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'paid'
GROUP BY i.sku
ORDER BY revenue DESC;
-- The email lives in one place: one row
UPDATE customers SET email = 'aigerim.new@example.com' WHERE id = 7;
-- The schema rejects a string price and an item of a missing order
INSERT INTO order_items VALUES (1002, 'ESPRESSO', 'one thousand', 1);
INSERT INTO order_items VALUES (9999, 'LATTE', 1600, 1);$ docker exec kb-pg-nosql psql -U postgres -f /tmp/orders_relational.sql
DROP TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 3
INSERT 0 3
INSERT 0 4
sku | revenue
-----------+---------
ESPRESSO | 3300
LATTE | 3200
CROISSANT | 1200
(3 rows)
UPDATE 1
psql:/tmp/orders_relational.sql:48: ERROR: invalid input syntax for type integer: "one thousand"
LINE 1: INSERT INTO order_items VALUES (1002, 'ESPRESSO', 'one thous...
^
psql:/tmp/orders_relational.sql:49: ERROR: insert or update on table "order_items" violates foreign key constraint "order_items_order_id_fkey"
DETAIL: Key (order_id)=(9999) is not present in table "orders".With tables the report needs a JOIN, but the email changes in one row and bad data never reaches the database: the column type and the foreign key reject it right at write time.
Decision table
Orders, payments, stock levels
- What to choose
- PostgreSQL or MySQL
- Why
- Relationships, constraints and transactions across tables
A catalogue where products have different attributes
- What to choose
- PostgreSQL with
jsonbfor attributes, or MongoDB - Why
- Common fields as columns, changeable ones as a document
Content, forms, settings: the object is read whole
- What to choose
- MongoDB or
jsonb - Why
- The data is read and changed together, few relationships
Cache, sessions, counters, rate limits
- What to choose
- Redis next to the main database
- Why
- Access by key, TTL, atomic
INCR
A stream of events or telemetry across many servers
- What to choose
- A wide-column database (Cassandra)
- Why
- Distribution by partition key, a table per query
Recommendations, social connections, routes
- What to choose
- A graph database (Neo4j)
- Why
- Traversing relationships of any depth
- Which queries will be most frequent: one whole object or a slice across many objects?
- Are there operations that must change several entities atomically?
- What gets duplicated when you embed, and how often does that data change?
- Who checks the data structure — the database or only the code?
- Do nested arrays grow without bound?
How this was verified5 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.