IMEN891K / IDSC501 / AXGS-501 · Data Engineering · Fall 2026

10. NoSQL II: Document Stores

Sangwoo Mo · POSTECH
Industrial & Management Engineering

Where We Are

PART IPART IIPART IIICollection and PreparationLec 2-5SQLLec 6-8NoSQLLec 9-10Big DataLec 11-13Monitoring and UpdatesLec 14-17

Recap: Types of NoSQL

SQLRelationalNoSQLK1V1K2V2K3V3Key-valueWide-columnGraphDocument
Last week: four data models. Today: the document model, in MongoDB

One Item, Several LocationsOne item document holds an array of location-level stock entries

paper
  qty: 100
  status: "D"
  instock: [ {loc: "A", qty: 60},
             {loc: "B", qty: 15} ]
The item and its stock entries live at different levels

Outline: NoSQL II

01

Documents and Queries

read fields, nesting and paths

02

Finding Documents

choose documents and output fields

03

Summarizing Documents

transform documents through ordered stages

04

Updating Documents

select write targets and verify changes

01 · Documents and Queries

01

Documents and Queries

read fields, nesting and paths

02

Finding Documents

choose documents and output fields

03

Summarizing Documents

transform documents through ordered stages

04

Updating Documents

select write targets and verify changes

Collection and Document

Relational terms

TableRowColumn
Productone productprice

Document terms

CollectionDocumentField
inventoryone itemqty
A collection holds documents; each document has named fields

Fields, Objects and Arrays

One inventory document

{
  item: "paper",
  qty: 100,
  size: {h: 8.5, w: 11, uom: "in"},
  tags: ["red", "blank", "plain"],
  instock: [
    {loc: "A", qty: 60},
    {loc: "B", qty: 15}
  ]
}

Levels inside it

FieldValue
qtynumber
sizenested object
tagsarray of strings
instockarray of objects
A field can hold one value, a nested object or an array

Three Query Tasks

TaskQuestionMQL entry point
RetrieveWhich items have status D?find
AggregateHow much stock is at each location?aggregate
UpdateWhich widths must change?updateMany
The operation follows the question asked of the documents

Dot Paths

The array to read

instock: [
  {loc: "A", qty: 60},  // index 0
  {loc: "B", qty: 15}   // index 1
]

A path reaches a value

PathMeaning
instock.1second stock entry
instock.1.qty15
instock.qtyqty fields across the array
A numeric path step chooses one array position

Queries as Documents

SQL condition

WHERE qty > 30

MQL predicate

{ qty: { $gt: 30 } }
The same comparison can be written as a predicate document

02 · Finding Documents

01

Documents and Queries

read fields, nesting and paths

02

Finding Documents

choose documents and output fields

03

Summarizing Documents

transform documents through ordered stages

04

Updating Documents

select write targets and verify changes

Retrieving Documentsfind(predicate, projection) chooses documents, then fields

db.inventory.find(
  { status: "D" },
  { _id: 0, item: 1, qty: 1 }
)
find result
{ "item": "paper", "qty": 100 }
{ "item": "planner", "qty": 75 }
The condition chooses two items; projection shows name and quantity

Matching Nested Fields

db.inventory.find(
  { "size.uom": "cm", "size.h": { $gt: 14 } },
  { _id: 0, item: 1, size: 1 }
)
find result
{ "item": "planner", "size": { "h": 22.85, "w": 30, "uom": "cm" } }
A dotted path tests a field inside the nested object

Exact Arrays or Contained Values

Exact array, including order

db.inventory.find(
  { tags: ["red", "blank"] },
  { _id: 0, item: 1, tags: 1 }
)
find result
{ "item": "notebook", "tags": [ "red", "blank" ] }

Contains red and plain

db.inventory.find(
  { tags: { $all: ["red", "plain"] } },
  { _id: 0, item: 1, tags: 1 }
)
find result
{ "item": "paper", "tags": [ "red", "blank", "plain" ] }
An array equality checks the whole sequence; $all checks membership

Preserving an Array in Projection

db.inventory.find(
  { item: "paper" },
  { _id: 0, item: 1, "instock.loc": 1 }
)
find result
{ "item": "paper", "instock": [ { "loc": "A" }, { "loc": "B" } ] }
Projection removes fields while preserving the array structure

Sort, Then Limit

db.inventory.find(
  {}, { _id: 0, item: 1, dim: 1 }
).sort({ "dim.0": -1, item: 1 }).limit(2)
find result
{ "item": "planner", "dim": [ 22.85, 30 ] }
{ "item": "journal", "dim": [ 14, 21 ] }
Sort determines which two documents the limit keeps

Recap: Inventory for Retrieval

itemqtystatustags
journal25Ablank, red
notebook50Ared, blank
paper100Dred, blank, plain
planner75Dblank, red
postcard45Ablue
The same five items supply every retrieval exercise

Caveat: A Location and a Quantity

paper stock entries

locqtyCondition met
A60qty ≥ 30
B15loc = B

One entry must satisfy both

{ instock: {
    $elemMatch: {loc: "B", qty: {$gte: 30}}
} }

paper is excluded

A quantity at A cannot satisfy a stock requirement at B

Demo: RetrievalPredict the matching entities, then check the condition scope

  1. Find status A items with quantity at least 50; show item and qty
  2. Require location B and qty at least 30 in the same stock entry. Does paper qualify?
Ctrl/⌘ + Enter
Result
Write a query on the left, then Run.

Answer: Retrieval

db.inventory.find(
  {status: "A", qty: {$gte: 50}},
  {_id: 0, item: 1, qty: 1}
)
find result
{ "item": "notebook", "qty": 50 }
Only notebook has status A and at least 50 units

Answer: One Stock Entry Must Qualify

ItemLocation B stockAt least 30 at B?
journalNo B entryNo
notebookNo B entryNo
paper15No
planner5No
postcard15No
No item has one B stock entry with at least 30 units

Null and Missing Fields

Two different states

{ item: "x", color: null }
{ item: "y" }

Two different questions

PredicateMatches
color: nullx and y
color: {$exists: false}y only
A null-valued field and an absent field are different states

Caveat: A Mistyped FieldOne document stores qty as the string "50"; the range filter skips it without an error

db.inventory.find(
  { qty: { $gte: 30 } },
  { _id: 0, item: 1, qty: 1 } )
qty at least 30: notebook (50) is missing
{ "item": "paper", "qty": 100 }
{ "item": "planner", "qty": 75 }
{ "item": "postcard", "qty": 45 }
db.inventory.find(
  { qty: { $type: "string" } },
  { _id: 0, item: 1, qty: 1 } )
why: its qty is a string
{ "item": "notebook", "qty": "50" }
Remedy: validate qty as numeric before accepting the document
This numeric query predicate skips string values; numeric BSON types remain comparable

03 · Summarizing Documents

01

Documents and Queries

read fields, nesting and paths

02

Finding Documents

choose documents and output fields

03

Summarizing Documents

transform documents through ordered stages

04

Updating Documents

select write targets and verify changes

A Pipeline Changes Its Input

Input: CA ZIPs

ZIPcitypop
90001LOS ANGELES57110
90002LOS ANGELES51223
94110SAN FRANCISCO74633
92101SAN DIEGO34560

Group by city

citypop
LOS ANGELES108333
SAN FRANCISCO74633
SAN DIEGO34560

Keep pop > 100,000

citypop
LOS ANGELES108333
Four ZIP records become three city totals; the filter keeps Los Angeles

The Zips CollectionOne record represents a ZIP code; Los Angeles has two records

ZIPcitypop
90001LOS ANGELES57110
90002LOS ANGELES51223
94110SAN FRANCISCO74633
92101SAN DIEGO34560
Four ZIP records represent only three cities

Group Totals by State

{$group: {_id: "$state", totalPop: {$sum: "$pop"}}}
statetotalPop
MA34114
CA217526
NY331508
TX260264
WI87956
IL192310
Twenty ZIP records produce six state totals; totalPop is a new field

Filter the Group Totals

After grouping

statetotalPop
MA34114
CA217526
NY331508
TX260264
WI87956
IL192310

totalPop ≥ 200,000

statetotalPop
NY331508
TX260264
CA217526
{$match: {totalPop: {$gte: 200000}}}
The condition reads the new state totals and removes three groups

The Same Summary in SQL

SELECT state, SUM(pop) AS totalPop
FROM zips
GROUP BY state
HAVING SUM(pop) >= 200000
ORDER BY totalPop DESC, state;
GROUP BY, HAVING and ORDER BY express the same summary

First Group: City Totals

Before: one row per ZIP

ZIPcitypop
90001LOS ANGELES57110
90002LOS ANGELES51223
94110SAN FRANCISCO74633
92101SAN DIEGO34560

After: one row per city

citypop
LOS ANGELES108333
SAN FRANCISCO74633
SAN DIEGO34560
{$group: {_id: {state: "$state", city: "$city"},
          pop: {$sum: "$pop"}}}
The two Los Angeles ZIP populations combine into one city total

The Intermediate City Documents

Los Angeles ZIPs

ZIPpop
9000157110
9000251223

Add their populations

57,110
+ 51,223
= 108,333

One city total

citypop
LOS ANGELES108333
Los Angeles contributes one city total to the next average

Second Group: Average City Population

Three city totals

citypop
LOS ANGELES108333
SAN FRANCISCO74633
SAN DIEGO34560

Average the cities

(108,333 + 74,633 + 34,560) / 3
stateavgCityPop
CA72508.667
{$group: {_id: "$_id.state", avgCityPop: {$avg: "$pop"}}}
The denominator is three cities, even though the original input contained four ZIPs

Recap: Zips for Aggregation

ZIPcitypop
90001LOS ANGELES57110
90002LOS ANGELES51223
94110SAN FRANCISCO74633
92101SAN DIEGO34560
Use these same four CA records when calculating the average city population

Demo: AggregationInspect each stage’s output and the population unit before adding another stage

  1. Total population per state; keep totals above 100,000 and sort largest first
  2. For CA, sum ZIP populations per city, then average those city totals
Ctrl/⌘ + Enter
Result
Write a query on the left, then Run.

Answer: State Totals

db.zips.aggregate([
  {$group: {_id: "$state", totalPop: {$sum: "$pop"}}},
  {$match: {totalPop: {$gt: 100000}}},
  {$sort: {totalPop: -1, _id: 1}}
])
aggregate result
{ "_id": "NY", "totalPop": 331508 }
{ "_id": "TX", "totalPop": 260264 }
{ "_id": "CA", "totalPop": 217526 }
{ "_id": "IL", "totalPop": 192310 }
Filter the new state totals, then order the remaining groups

Answer: Average City Population

db.zips.aggregate([
  {$match: {state: "CA"}},
  {$group: {_id: {state: "$state", city: "$city"},
             pop: {$sum: "$pop"}}},
  {$group: {_id: "$_id.state", avgCityPop: {$avg: "$pop"}}}
])
aggregate result
{ "_id": "CA", "avgCityPop": 72508.667 }
Average three city totals, rather than four ZIP populations

Unwinding One Array

One item document

{item: "paper",
 instock: [
  {loc:"A", qty:60},
  {loc:"B", qty:15}
 ]}

One row per stock entry

itemlocstock qty
paperA60
paperB15
One paper document becomes two records, one for A and one for B

Caveat: An Item Appears Twice

itemlocstock qty
paperA60
paperB15

Stock entries

2 rows

Distinct items

1 item: paper

Stock quantity

60 + 15 = 75
Two stock records describe one item; choose the entity before counting or summing

Total Stock per Item

db.inventory.aggregate([
  {$unwind: "$instock"},
  {$group: {_id: "$item", totalQty: {$sum: "$instock.qty"}}}
])
aggregate result
{ "_id": "journal", "totalQty": 20 }
{ "_id": "notebook", "totalQty": 5 }
{ "_id": "paper", "totalQty": 75 }
{ "_id": "planner", "totalQty": 45 }
{ "_id": "postcard", "totalQty": 50 }
Unwind exposes the numbers; group recombines them by item

Recap: Stock by Location

itemABC
journal5—15
notebook——5
paper6015—
planner405—
postcard—1535
Location-level quantities come from the instock entries

Demo: Stock by LocationInspect the changed grain before summing any quantities

  1. Unwind instock and inspect the result: what does one document now represent?
  2. Group those entries by location, sum their quantities and sort largest first
Ctrl/⌘ + Enter
Result
Write a query on the left, then Run.

Answer: Location Totals

db.inventory.aggregate([
  {$unwind: "$instock"},
  {$group: {_id: "$instock.loc", qty: {$sum: "$instock.qty"}}},
  {$sort: {qty: -1, _id: 1}}
])
aggregate result
{ "_id": "A", "qty": 105 }
{ "_id": "C", "qty": 55 }
{ "_id": "B", "qty": 35 }
The group key is now a location, not an item

Answer: One Document per Stock Entry

StageDocumentsMeaning of one document
Inventory5One item with a stock array
Unwind instock9One item at one location
Group by location3Total stock at one location
Unwinding repeats item fields; sum the quantity at the grain your question requires

Lookup Adds an Array

items

itemsupplier
journalS1
paperS1
postcardS9

suppliers

suppliercountry
S1US

After lookup

itemdetails
journal["US"]
paper["US"]
postcard[]
Matching suppliers enter an array; postcard stays with an empty array

Lookup on a Shared Key

db.items.aggregate([
  {$lookup: {from: "suppliers",
             localField: "supplier", foreignField: "supplier",
             as: "details"}},
  {$project: {_id: 0, item: 1, details: 1}}
])
aggregate result
{ "item": "journal", "details": [ { "supplier": "S1", "country": "US" } ] }
{ "item": "paper", "details": [ { "supplier": "S1", "country": "US" } ] }
{ "item": "postcard", "details": [ ] }
An unmatched supplier produces an empty array, not a missing item

04 · Updating Documents

01

Documents and Queries

read fields, nesting and paths

02

Finding Documents

choose documents and output fields

03

Summarizing Documents

transform documents through ordered stages

04

Updating Documents

select write targets and verify changes

Inserting Documents

db.inventory.insertMany([
  {item: "journal", qty: 25},
  {item: "notebook", qty: 50}
])

Missing collection: created
Missing _id: generated

Insert supplies documents; MongoDB supplies an absent _id

Selecting the Update Targets

db.inventory.find(
  {status: "A"},
  {_id: 0, item: 1, status: 1}
)
find result
{ "item": "journal", "status": "A" }
{ "item": "notebook", "status": "A" }
{ "item": "postcard", "status": "A" }
Preview the three selected documents before changing them

Changing the Selected Documents

Before

itemstatus
journalA
notebookA
paperD
plannerD
postcardA

After updating A

itemstatus
journalReview
notebookReview
paperD
plannerD
postcardReview
db.inventory.updateMany({status: "A"},
                        {$set: {status: "Review"}});
Only the three A statuses change; paper and planner keep D

Caveat: One Document or Many

Single document update

Its field changes are
atomic together

updateMany

Several document updates
are not one atomic unit

Use a transaction when all selected documents must change together

Recap: Writes Start from a Fresh Copy

CheckExpected result
Read status A before writingjournal, notebook, postcard
Change A to ReviewThree documents change
Read status A after writingNo documents remain
Preview, write and verify in the same script; each Run starts from the original inventory

Demo: WritesPreview the same records, change one field, then check the result

  1. Preview status A items, then change their status to Review
  2. Verify that status A has zero items and status Review has three
Ctrl/⌘ + Enter
find result
{ "item": "journal", "status": "A" }
{ "item": "notebook", "status": "A" }
{ "item": "postcard", "status": "A" }

Answer: Verify the Writes

db.inventory.updateMany(
  {status: "A"},
  {$set: {status: "Review"}}
);
db.inventory.countDocuments({status: "Review"});
countDocuments result
3
Three items are marked Review; the two original D items remain unchanged

Case study: The Guardian’s ComposerA newsroom CMS moved off MongoDB in 2018 but kept its documents, as jsonb in PostgreSQL

Vendor CMSon OracleComposeron MongoDB, run in-houseComposeron PostgreSQL (RDS), jsonbwanted aflexible schematwo outages,operations burden
The documents stayed documents; the engine under them changed

Takeaway: Document Queries

1A condition must name the right entity and array element
2Each pipeline stage changes the input; track structure, grain and repeated values
3Verify write targets and atomicity boundaries; choose the model and engine separately
← → / Space · S notes · O overview · F fullscreen · number + Enter
Speaker notes