Documents, BSON, _id, flexible schema — and the one modelling decision that determines whether your database is fast or miserable
ObjectId into its three components and explain why it is safe to generate on the client$jsonSchema validation to re-tighten a collectionmongosh, and import 15,000 ride documents into the bdma.rides collection| Time | Segment | Activity |
|---|---|---|
| 0–6 min | Recap | Lecture 11 in one slide; confirm everyone's Atlas cluster is live |
| 6–18 min | The Hierarchy | Document, collection, database — mapped to table/row/column and where the analogy breaks |
| 18–30 min | BSON | Why binary, which types it adds, and the business cost of untyped storage |
| 30–40 min | _id & ObjectId | The 12-byte anatomy; client-side generation; time-sortability |
| 40–52 min | Flexible Schema | What it buys, what it costs, and $jsonSchema validation as the counterweight |
| 52–72 min | Embed vs Reference | The core decision, the 16 MB limit, the unbounded-array trap, worked examples |
| 72–86 min | Lab | Compass + mongosh, insertOne, find, then mongoimport of 15,000 rides |
| 86–90 min | Wrap-Up | Verify counts, assignment reminder, Lecture 13 preview |
In Lecture 11 we argued that document databases exist because applications think in nested objects and tables do not. This lecture makes that concrete. The unit of storage in MongoDB is the document: an ordered set of field-and-value pairs, where a value may itself be a document or an array of documents.
Here is one real record from the dataset you will load today — a completed booking from the Delhi NCR ride-hailing data.
{
_id: ObjectId("65f1a2c4e8b9d4a1c2f30142"),
booking_id: "CNR3000142",
booked_at: ISODate("2024-03-14T18:22:07Z"),
date: "2024-03-14",
status: "Completed",
customer_id: "CID482910",
vehicle_type: "Go Sedan",
route: { pickup: "Connaught Place", drop: "Aerocity" },
avg_vtat: 7.4,
avg_ctat: 31.2,
booking_value: 412.50,
ride_distance: 18.4,
ratings: { driver: 4.6, customer: 4.8 },
payment_method: "UPI"
}
Note what has happened. In a relational design this booking would be spread across a bookings table, a routes table, and a ratings table, joined on every read. Here it is one object, retrieved in one operation, in exactly the shape the application wants to display.
| Level | MongoDB | Relational Equivalent | In Our Course |
|---|---|---|---|
| 1 | Deployment (cluster) | Database server / instance | Your Atlas M0 cluster |
| 2 | Database | Database / schema | bdma |
| 3 | Collection | Table | rides |
| 4 | Document | Row | One booking, e.g. CNR3000142 |
| 5 | Field | Column | booking_value, route.pickup |
Where the analogy breaks — and it matters:
1. A collection has no fixed columns. A table declares its columns once, for every row. A collection declares nothing. Two documents in rides may carry entirely different field sets — and in our dataset they genuinely do.
2. A field value can be a whole structure. route is not a string; it is a sub-document with its own fields. ratings is another. A relational column holds one scalar, full stop.
3. Collections are created lazily. Writing to db.rides creates the collection if it does not exist. There is no CREATE TABLE step and no migration ceremony.
Our dataset is deliberately realistic: a booking that never completed does not have a fare, a distance, or ratings, because those facts never came into existence. Compare the completed document above with a cancelled one:
{
booking_id: "CNR3004193",
booked_at: ISODate("2024-01-01T13:00:56Z"),
date: "2024-01-01",
status: "Cancelled by Driver",
customer_id: "CID695170",
vehicle_type: "Premier Sedan",
route: { pickup: "New Delhi Railway Station", drop: "Anand Vihar ISBT" },
avg_vtat: 2.84,
cancellation: { by: "driver", reason: "Personal & Car related issues" }
}
No booking_value. No ride_distance. No ratings. No payment_method. Instead there is a cancellation sub-document that the completed booking does not have.
| Status | Count | Share | Fields Present Beyond the Common Core |
|---|---|---|---|
| Completed | 9,343 | 62.29% | avg_vtat, avg_ctat, booking_value, ride_distance, ratings, payment_method |
| Cancelled by Driver | 2,698 | 17.99% | avg_vtat, cancellation |
| No Driver Found | 1,036 | 6.91% | none — not even avg_vtat |
| Cancelled by Customer | 1,022 | 6.81% | avg_vtat, cancellation |
| Incomplete | 901 | 6.01% | avg_vtat, avg_ctat, booking_value, ride_distance, payment_method, incomplete |
Business framing: in a relational table these 15,000 rows would need roughly six nullable columns and two nullable sub-tables, and every analyst query would have to remember which NULL means "did not happen" and which means "we failed to record it". In the document model the absence of booking_value is itself the signal: no fare exists because no ride happened. Lecture 13 turns that into a query with $exists.
You write JSON. MongoDB stores BSON — Binary JSON. The difference is invisible most of the time and decisive the rest of the time.
JSON is a text format designed for humans and for interchange. It has exactly six types: string, number, boolean, null, array, object. That is fine for sending data over a wire. It is inadequate for a database, for three reasons.
In JSON, 412.50 is just "a number". Is it a 32-bit integer? A 64-bit integer? A float? An exact decimal suitable for money? JSON does not say, so every reader has to guess — and different languages guess differently. BSON records the type explicitly on every value.
| BSON Type | What It Solves | Where It Appears in rides |
|---|---|---|
ObjectId | A compact, globally unique, roughly time-ordered 12-byte key | _id on every document |
Date | A real timestamp (ms since epoch), sortable and range-queryable | booked_at, once converted from its ISO string |
Int32 / Int64 | Exact integers that never drift into floating point | Counts and derived hour-of-day values |
Double | Standard 64-bit float | ride_distance, avg_vtat, ratings.driver |
Decimal128 | Exact decimal arithmetic — no 0.1 + 0.2 surprises | What booking_value should be in a real billing system |
Binary | Raw bytes: images, hashes, encrypted blobs | Not used here; common for documents and KYC images |
Null vs missing | Two distinct states: "known to be nothing" vs "not recorded" | Our dataset uses missing, deliberately |
Why an MBA should care about Decimal128: a Double cannot represent ₹0.10 exactly. Sum 9,343 fares stored as doubles and the total drifts by fractions of a paisa. On a 15,000-row teaching dataset that is harmless. On a payments ledger processing millions of transactions a day, it is an audit finding. Money gets Decimal128; distances and ratings are fine as doubles.
Every BSON element is prefixed with its length. That means the database can skip over a field it does not need instead of parsing the whole document character by character. When you ask for route.pickup from a document with fourteen fields, the storage engine jumps straight to it. On 15,000 documents the saving is small; on 150 million it is the difference between a two-second query and a two-minute one.
JSON object key order is formally undefined. BSON guarantees it, which is what makes compound index key ordering and certain comparison semantics deterministic.
| JSON | BSON | |
|---|---|---|
| Form | Text | Binary |
| Readable by humans | Yes | No — tools render it as JSON for you |
| Type system | 6 types | ~20 types including dates, decimals, binary, ObjectId |
| Field order | Undefined | Preserved |
| Space | Compact for small text | Slightly larger (length prefixes) but far faster to traverse |
| Where you meet it | What you type, what APIs return | What is on disk and on the wire |
The practical rule: you think and write in JSON; the driver converts to BSON on the way in and back on the way out. The only time the distinction surfaces is when you need a type JSON cannot express — a date, a decimal, or an ObjectId — and then you must say so explicitly. We do exactly that in the lab when converting booked_at from a string into a real Date.
_id Field and the ObjectIdEvery document in every MongoDB collection has an _id field. It is the primary key, it is unique within the collection, and it is indexed automatically — you cannot drop that index. If you insert a document without an _id, the driver generates one for you, and by default it is an ObjectId.
ObjectId("65f1a2c4 e8b9d4a1c2 f30142")
-------- ---------- ------
| | |
| | +-- 3 bytes: counter, starts at a random
| | value, increments per process
| +-- 5 bytes: random value, unique per machine+process
+-- 4 bytes: seconds since the Unix epoch (creation time)
| Bytes | Content | Consequence |
|---|---|---|
| 0–3 | Unix timestamp in seconds | Sorting by _id is approximately sorting by creation time — for free, with no extra index |
| 4–8 | Random per-process value | Two application servers generating IDs at the same instant will not collide |
| 9–11 | Incrementing counter | The same process generating thousands of IDs in one second stays unique |
A relational AUTO_INCREMENT key requires a round trip to the database: the application must insert, wait, and read back the assigned ID. That works on one server. On twenty application servers writing to a sharded cluster it becomes a global coordination bottleneck — a single counter everyone must queue behind.
An ObjectId is generated client-side, with no coordination at all, and is still unique. The application can construct a whole object graph, assign IDs, and write it in one batch. That is a throughput property, not a cosmetic one.
// The timestamp is extractable — no separate created_at needed
const id = ObjectId("65f1a2c4e8b9d4a1c2f30142")
id.getTimestamp() // ISODate("2024-03-13T10:34:12Z")
// Which means you can range-query on _id alone
db.rides.find({ _id: { $gt: ObjectId("65f1a2c400000000 00000000") } })
_id_id does not have to be an ObjectId. It can be any unique value: a string, an integer, even a sub-document. In our dataset every booking already carries a natural key — booking_id — so a defensible design would be:
db.rides.insertOne({
_id: "CNR3000142", // natural key as the primary key
status: "Completed",
vehicle_type: "Go Sedan",
route: { pickup: "Connaught Place", drop: "Aerocity" }
})
Trade-off: using the natural key saves 12 bytes and a redundant index, and makes _id meaningful to a human reading the data. It costs you the free time-ordering an ObjectId provides, and it breaks badly if the business ever reissues a booking reference. We keep the generated ObjectId and leave booking_id as an ordinary indexed field — the conventional choice.
"Schemaless" is the single most damaging word in the NoSQL vocabulary. MongoDB collections are not schemaless. They are schema-flexible: the schema is not declared to the database up front, but it absolutely still exists — in your application code, in your analysts' queries, and in your dashboards.
surge_multiplier field to new bookings requires no ALTER TABLE and no maintenance window. Old documents simply do not have it.| Risk | How It Shows Up | Mitigation |
|---|---|---|
| Silent typos | One service writes payment_method, another writes paymentMethod. Both succeed. Reports quietly undercount. | Schema validation + code review |
| Type drift | booking_value arrives as 412.5 from one path and "412.5" from another. Range queries silently skip the strings. | bsonType in the validator |
| Archaeology | Nobody knows what fields exist. Every new analyst starts by sampling documents. | Documented schema; Compass's Schema tab |
| Query complexity | Every aggregation must defend against missing fields. | Deliberate, documented absence rules |
$jsonSchema ValidationMongoDB lets you attach a validator to a collection. It is opt-in, it can be as strict or as loose as you like, and it can be tightened progressively as a product matures. This is the professional answer to "flexible schema is dangerous": you choose, per collection, where on the spectrum to sit.
db.createCollection("rides", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["booking_id", "booked_at", "status", "customer_id",
"vehicle_type", "route"],
properties: {
booking_id: {
bsonType: "string",
pattern: "^CNR[0-9]{7}$",
description: "must match CNR followed by 7 digits"
},
booked_at: { bsonType: "date" },
status: {
enum: ["Completed", "Cancelled by Driver", "Cancelled by Customer",
"No Driver Found", "Incomplete"],
description: "must be one of the five known booking outcomes"
},
vehicle_type: {
enum: ["Go Mini", "Go Sedan", "Auto", "eBike", "Bike",
"Premier Sedan", "Uber XL"]
},
route: {
bsonType: "object",
required: ["pickup", "drop"],
properties: {
pickup: { bsonType: "string" },
drop: { bsonType: "string" }
}
},
booking_value: { bsonType: ["double", "int"], minimum: 0 },
ride_distance: { bsonType: ["double", "int"], minimum: 0 },
ratings: {
bsonType: "object",
properties: {
driver: { bsonType: "double", minimum: 1, maximum: 5 },
customer: { bsonType: "double", minimum: 1, maximum: 5 }
}
}
}
}
},
validationLevel: "moderate",
validationAction: "error"
})
Read that validator as a business contract. It says: every booking must have an ID in the house format, a real timestamp, one of five known outcomes, one of seven vehicle types, and a route with both ends. It says nothing at all about booking_value being required — because for 5,657 of our 15,000 bookings it legitimately does not exist.
| Setting | Options | When to Use Which |
|---|---|---|
validationLevel | off · moderate · strict | moderate checks inserts and updates to already-valid documents only — the right setting when retrofitting rules onto legacy data |
validationAction | error · warn | Start with warn to measure how much existing data would fail, then switch to error once the backlog is clean |
The managerial line to remember: relational databases force the strictness decision on day one, before you know the answer. MongoDB lets you defer it and then apply exactly as much as the business actually needs — but somebody has to make that decision deliberately. A team that never writes a validator has not chosen flexibility; it has chosen neglect.
Everything else in this lecture is vocabulary. This section is the actual skill. In a relational database the model is largely dictated by normalisation rules. In MongoDB you decide, for every relationship, whether the related data lives inside the parent document or in a separate collection pointed at by an identifier. Get it right and reads are single-operation and fast. Get it wrong and you have rebuilt a slow relational database with none of the safety.
The related data is stored inside the parent document. Our route and ratings fields are embedded sub-documents.
{
booking_id: "CNR3000142",
status: "Completed",
route: { pickup: "Connaught Place", drop: "Aerocity" },
ratings: { driver: 4.6, customer: 4.8 }
}
// One query returns everything. No join. Atomic single-document write.
The related data lives elsewhere and the parent stores a pointer. Our customer_id is a reference to a separate customers collection.
// bdma.rides
{ booking_id: "CNR3000142", customer_id: "CID482910", status: "Completed" }
// bdma.customers
{ _id: "CID482910", name: "Priya Sharma", phone: "+91-98xxxxxx21",
home_city: "Delhi", joined: ISODate("2022-06-11T00:00:00Z") }
// Two queries, or one $lookup in an aggregation (Lecture 14).
| Test | Embed when… | Reference when… |
|---|---|---|
| Access pattern | The data is almost always read together with the parent | The data is usually needed on its own, or only occasionally with the parent |
| Size | The embedded part is small and bounded — you can state a maximum | It is large, or grows without a natural limit |
| Change frequency | It changes with the parent, in the same business event | It changes independently, on its own lifecycle |
| Sharing | It belongs to exactly one parent | Many parents refer to the same entity |
| Cardinality | One-to-one, or one-to-few (tens) | One-to-many (thousands) or many-to-many |
The compressed version: embed what is read together, bounded, and changes together. Reference what is large, unbounded, shared, or changes independently. If a relationship satisfies some tests and not others, let access pattern win — it is the one your users feel.
| Relationship | Decision | Reasoning |
|---|---|---|
| Booking → route (pickup, drop) | Embed | Exactly two fields, never read without the booking, fixed at booking time and never updated |
| Booking → ratings | Embed | Two scalars, bounded, written once at trip end, meaningless outside the booking |
| Booking → cancellation reason | Embed | One sub-document, present only when relevant, part of the same event |
| Booking → customer | Reference | One customer has hundreds of bookings; a phone-number change must not require rewriting every historical ride |
| Booking → driver | Reference | Same reason, plus driver records carry licences and documents that no booking needs |
| Customer → all their bookings | Reference (from the booking side) | Unbounded — a daily commuter generates 500 rides a year, forever |
The hybrid pattern (extended reference): you may embed a snapshot of a few referenced fields to avoid the join, while keeping the reference for the full record. Storing customer: { id: "CID482910", name: "Priya S." } on each booking lets a trip-history screen render with no second query. The deliberate trade-off is that if the customer changes their name, historical bookings keep the old one — which for an invoice or a receipt is arguably correct, not a bug.
A single BSON document may not exceed 16 megabytes. This is not a tuning parameter; it cannot be raised. It exists to stop any one document from monopolising memory and network bandwidth.
16 MB is enormous for a booking — our documents are roughly 300 bytes each, so the entire 15,000-record dataset is about 4.5 MB. You will never hit the limit with well-modelled data. You will hit it instantly with badly modelled data, and that is the point of the limit: it is a design smoke alarm.
This is the single most common modelling mistake in document databases, so it is worth seeing explicitly.
// ANTI-PATTERN — do not do this
{
_id: "CID482910",
name: "Priya Sharma",
bookings: [
{ booking_id: "CNR3000142", date: "2024-03-14", value: 412.50 },
{ booking_id: "CNR3000288", date: "2024-03-15", value: 180.00 },
// ... and one more every single day, forever
]
}
Four distinct failures, in the order you meet them:
1. The document grows without bound. A daily commuter adds ~500 entries a year. The array has no natural ceiling, so the only question is when the document breaks, not whether.
2. Every read pays for every booking. Displaying the customer's name pulls their entire ride history off disk and across the network.
3. Every write rewrites the document. As the document outgrows its allocated space the storage engine must relocate it — turning a cheap append into an expensive move.
4. Indexing degrades. A multikey index on a 5,000-element array creates 5,000 index entries per document.
The fix: keep rides as its own collection with customer_id on each booking — which is exactly how our dataset is shaped. Embed the one-to-few; reference the one-to-many.
Arrays are not the problem; unbounded arrays are. These are fine because each has a natural ceiling:
{ tags: ["airport", "night", "surge"] } // a handful
{ saved_addresses: [ {label:"Home",...}, {label:"Work",...} ] } // ~5 max
{ fare_breakdown: [ {component:"base", amount:60},
{component:"distance", amount:294},
{component:"surge", amount:58.50} ] } // fixed set
If you cannot state the maximum number of elements with a straight face, reference instead.
There are three ways into a MongoDB cluster and you will use all three this term.
The official shell. A full JavaScript REPL with the database API attached. Everything in Lectures 13–16 is written for it.
Best for: precise work, scripting, anything you need to reproduce or hand in.
The official GUI. Browse collections, build queries visually, read the automatically inferred schema, and run explain plans with charts.
Best for: exploring unfamiliar data and showing results to people who do not write queries.
Official libraries for Node.js, Python, Java, C#, Go and more. The same operations, expressed in your application's language.
Best for: production code — and for the Node dashboard you build in Lecture 20.
All three take the same SRV connection string you saved in Lecture 11:
mongodb+srv://bdma_student:<password>@cluster0.xxxxx.mongodb.net/
| Part | Meaning |
|---|---|
mongodb+srv:// | Use DNS SRV records to discover every node in the replica set — one hostname instead of three |
bdma_student:<password> | The database user you created. URL-encode special characters: @ becomes %40 |
cluster0.xxxxx.mongodb.net | Your cluster's hostname — unique to your Atlas project |
Trailing /bdma | Optional default database. Supply it for mongoimport; omit it and use use bdma in the shell |
# Connect the shell to your cluster
mongosh "mongodb+srv://bdma_student:<password>@cluster0.xxxxx.mongodb.net/"
# Safer: let the shell prompt for the password instead of typing it
mongosh "mongodb+srv://cluster0.xxxxx.mongodb.net/" \
--username bdma_student
Reminder from Lecture 11: that string contains a live credential. It belongs in a local .env file that is listed in .gitignore — never in a commit, a slide, a screenshot, or a Slack message. Automated scanners find exposed Atlas credentials within minutes of a public push.
From here to the end of the course, every query you write runs against the same data: 15,000 Uber-style ride bookings from Delhi NCR covering calendar year 2024, in database bdma, collection rides. Load it now.
Install the tools. Download MongoDB Compass and the MongoDB Database Tools (which contain mongoimport) from mongodb.com/try/download. mongosh ships with Compass, or installs separately.
Open Compass, paste your connection string, and connect. You should see the built-in admin and local databases — proof the cluster is reachable and your user works.
Now connect from the shell and select a database. use does not create anything yet; the database springs into existence on the first write.
mongosh "mongodb+srv://bdma_student:<password>@cluster0.xxxxx.mongodb.net/"
use bdma
db // -> bdma
show collections // -> (empty)Insert your first document by hand. Watch the response: MongoDB returns the _id it generated.
db.rides.insertOne({
booking_id: "CNR9999001",
booked_at: new Date("2024-03-14T18:22:07Z"),
date: "2024-03-14",
status: "Completed",
customer_id: "CID482910",
vehicle_type: "Go Sedan",
route: { pickup: "Connaught Place", drop: "Aerocity" },
avg_vtat: 7.4,
avg_ctat: 31.2,
booking_value: 412.50,
ride_distance: 18.4,
ratings: { driver: 4.6, customer: 4.8 },
payment_method: "UPI"
})
// { acknowledged: true,
// insertedId: ObjectId("65f1a2c4e8b9d4a1c2f30142") }Read it back, then confirm the collection now exists.
db.rides.find()
db.rides.find().pretty() // formatted output
show collections // -> rides
db.rides.countDocuments() // -> 1Insert a second document with a different shape — a cancellation. It succeeds without complaint. That is flexible schema, demonstrated rather than described.
db.rides.insertOne({
booking_id: "CNR9999002",
booked_at: new Date("2024-03-14T19:05:44Z"),
date: "2024-03-14",
status: "Cancelled by Driver",
customer_id: "CID695170",
vehicle_type: "Premier Sedan",
route: { pickup: "Indira Gandhi International Airport",
drop: "Gurgaon Cyber City" },
avg_vtat: 2.84,
cancellation: { by: "driver", reason: "Personal & Car related issues" }
})Clear your two test documents, then import the real dataset. Run mongoimport from the repository root, in a normal terminal — not inside mongosh.
// in mongosh
db.rides.deleteMany({})
# in your terminal, from the repo root
mongoimport --uri "mongodb+srv://bdma_student:<password>@cluster0.xxxxx.mongodb.net/bdma" \
--collection rides --file data/uber_bookings.json --jsonArrayVerify the import. These four numbers must match exactly before you leave the session.
db.rides.countDocuments() // 15000
db.rides.countDocuments({ status: "Completed" }) // 9343
db.rides.countDocuments({ booking_value: { $exists: false } }) // 4756
db.rides.findOne({ booking_id: "CNR3000142" })Optional but recommended. mongoimport loads booked_at as a string because that is what JSON can express. Convert it to a real BSON Date so that Lectures 13–16 can do time arithmetic on it.
db.rides.updateMany(
{ booked_at: { $type: "string" } },
[ { $set: { booked_at: { $toDate: "$booked_at" } } } ]
)
db.rides.findOne({}, { booked_at: 1, _id: 0 }) // now an ISODateSanity check on what you just loaded. 15,000 bookings; 9,343 completed (62.29%); average completed fare ₹235.73 against a total of ₹22,02,449; average distance 10.87 km; average driver rating 4.41; average VTAT 8.03 minutes. The busiest hour is 18:00 with 1,373 bookings, and the worst failure rate among the hours that actually carry volume is 17:00 at 39.92% — higher than the 18:00 peak's 37.14%, even though 18:00 takes 1,373 bookings to 17:00's 1,062. Top pickup points: Noida Sector 62 (1,209), Indira Gandhi International Airport (1,183), New Delhi Railway Station (1,179), Gurgaon Cyber City (1,178), Connaught Place (1,139). You will reproduce every one of these figures yourself in Lectures 13 and 14.
In Compass, open bdma.rides and click the Schema tab. Compass samples the collection and reports, for every field, its types and how often it appears. You will see booking_value present in only about 68% of documents. That is not an error — it is the dataset telling you the truth about failed bookings, and it is the hook for Lecture 13.
ObjectId, Date, Decimal128, Int32/Int64), guaranteed field order, and length-prefixed traversal. Money belongs in Decimal128._id, and the default ObjectId is 12 bytes — 4-byte timestamp, 5-byte random, 3-byte counter. It is therefore roughly time-sortable and safe to generate client-side with no coordination.$jsonSchema with validator is how a mature team re-tightens it, progressively, where the business actually needs guarantees.With 15,000 rides loaded, we turn the collection into answers. The full CRUD surface, the operator families ($gt, $in, $exists, $elemMatch and the rest), dot notation for nested fields, projection, sorting and paging, and the update operators that change data in place. The lab is eight business questions you must answer in mongosh. Arrive with the dataset imported and verified at 15,000 documents.