Databases · lesson 6 of 6
Intermediate
2Advanced
4Also part of Databases
12- + Schema Design from SQL
- + Indexing Strategy: When an Index Actually Helps from PostgreSQL
- + Composite Indexes: The Filesort a Single-Column Index Can't Avoid from MySQL
- + Clustered vs Nonclustered: When an Index Makes a Query Slower from SQL Server
- + A Real Deadlock, and Why Lock Order Fixed It from PostgreSQL
- + Isolation Levels: The Same Transaction Reading Two Different Values from MySQL
- + A Real Dirty Read: Seeing a Value That Never Actually Existed from SQL Server
- + Replication: A Second Server, and the read_only Gap Nobody Mentions from MySQL
- + Killing the Primary and Measuring the Real Failover Time from MongoDB
- + Point-in-Time Recovery: Undoing a Bad DELETE That Already Committed from PostgreSQL
- + Backup and Recovery: What a Restore Actually Gives Back from MySQL
- + Quorum Consistency: When a Read Is Guaranteed to See the Latest Write from System Design
Also part of: MongoDB
Normalized vs Embedded: a Tradeoff That Points in Two Different Directions
Fetching one customer with their orders costs about the same, indexed, in both a normalized PostgreSQL schema and an embedded MongoDB document. Searching across orders independent of their customer is measured 11x slower embedded — the same schema choice wins and loses depending which direction you query.
By SkillByExample · Published
The same dataset, modeled two ways, measured in two different query directions — and the direction changes which model wins.
The same data, two schemas
PostgreSQL — normalized:
CREATE TABLE customers (id SERIAL PRIMARY KEY, name TEXT);
CREATE TABLE orders (id SERIAL PRIMARY KEY, customer_id INT REFERENCES customers(id), item TEXT, amount INT);
-- 5,000 customers, 50,000 orders (10 per customer)
MongoDB — embedded:
// one document per customer, orders embedded directly
{ customer_id: 1, name: "customer_1", orders: [ {item: "item_1", amount: 3}, ... 10 total ] }
// 5,000 such documents, 50,000 total order sub-documents
Same logical data, same total row/document count either way.
Query direction A: fetch one customer with their orders
CREATE INDEX idx_orders_customer ON orders(customer_id);
EXPLAIN ANALYZE
SELECT c.name, o.item, o.amount FROM customers c
JOIN orders o ON o.customer_id = c.id WHERE c.id = 2500;
Nested Loop (cost=4.65..47.58 rows=10 width=24) (actual time=0.069..0.107 rows=10 loops=1)
-> Index Scan using customers_pkey on customers c (actual time=0.019..0.020 rows=1 loops=1)
Index Cond: (id = 2500)
-> Bitmap Heap Scan on orders o (actual time=0.047..0.083 rows=10 loops=1)
Recheck Cond: (customer_id = 2500)
-> Bitmap Index Scan on idx_orders_customer (actual time=0.024..0.024 rows=10 loops=1)
Execution Time: 0.207 ms
db.customers.createIndex({customer_id: 1})
db.customers.find({customer_id: 2500}).explain('executionStats').executionStats
{
nReturned: 1,
executionTimeMillis: 6,
totalKeysExamined: 1,
totalDocsExamined: 1,
...
stage: 'FETCH', inputStage: { stage: 'IXSCAN', indexBounds: { customer_id: [ '[2500, 2500]' ] } }
}
Postgres: 0.207ms, two index operations joined together. MongoDB: 6ms at whole-millisecond
granularity (its explain() doesn’t report finer than that), one index scan straight into the
document. Both are sub-10ms for this data size — roughly a tie. The real difference isn’t
speed here, it’s operation count: Postgres did a join; MongoDB didn’t need one, because the
orders were already sitting inside the document it fetched.
Query direction B: search across orders, independent of customer
EXPLAIN ANALYZE SELECT * FROM orders WHERE amount > 8;
Seq Scan on orders (cost=0.00..944.00 rows=15107 width=19) (actual time=1.611..2.511 rows=15000 loops=1)
Filter: (amount > 8)
Rows Removed by Filter: 35000
Execution Time: 2.861 ms
15,000 matching rows, 2.861ms — a direct scan over a flat table of 50,000 rows.
db.customers.aggregate([
{ $unwind: "$orders" },
{ $match: { "orders.amount": { $gt: 8 } } },
{ $count: "matching" }
])
matching: [{"matching":15000}]
elapsed ms: 33
Same 15,000 matches, correct answer — but 33ms, a measured ~11x slower than the
equivalent Postgres query. $unwind has to expand all 5,000 customer documents into 50,000
separate order-shaped records before it can even start filtering — it’s deconstructing every
parent to get at its children, where Postgres’s orders table already exists as a flat
structure with nothing to deconstruct.
The actual tradeoff
Neither schema is faster in general — each is faster for a different access pattern on the identical data:
| Query pattern | Normalized (Postgres) | Embedded (MongoDB) |
|---|---|---|
| Fetch one parent + its children | Join, indexed — 0.207ms | No join, one document — ~6ms, roughly comparable |
| Search across all children by a property | Flat scan — 2.861ms | Unwind every parent first — 33ms, measured 11x slower |
If an application’s dominant query is “show me this customer’s orders,” embedding removes a join and keeps the read to one document. If it’s “find all orders matching this filter, regardless of customer,” a flat table is the shape the query actually wants — the embedded model has to undo its own structure just to answer it.
Practice
1. Add a multikey index on the embedded field and re-run the cross-order search.
db.customers.createIndex({"orders.amount": 1})
Re-running the same $unwind/$match/$count aggregation after this index exists can help
MongoDB locate matching sub-documents faster, but $unwind still has to reconstruct each
match back out of its parent document — the structural cost of “children live inside a
parent” doesn’t fully disappear the way it did for query direction A, where the parent lookup
already was the entire answer.
2. Confirm the two schemas really do hold the same total order count.
SELECT COUNT(*) FROM orders;
db.customers.aggregate([{$unwind: "$orders"}, {$count: "total"}])
Both report 50000 — confirming the two measurements above are a fair comparison of the same
underlying data, not an artifact of one side having more or less to search through.
3. Fetch a customer that doesn't exist and compare how each engine reports "nothing found."
SELECT * FROM customers WHERE id = 999999;
id | name
----+------
(0 rows)
db.customers.find({customer_id: 999999}).toArray()
[]
Both report an empty result rather than an error for a query that legitimately matches nothing — worth confirming directly rather than assuming, since a missing-parent-document case behaves differently in some embedded designs where a query expects a specific nested shape to exist.
That closes this pass. The thread through this whole track: comparing engines side by side turns “SQL vs NoSQL” from a page of asserted prose bullet points into six real measured mismatches — a query the ACID engine loses an update on too, a distribution summary that reports the wrong total in two unrelated engines, and a schema choice that wins or loses depending entirely on which direction the next query goes.