Databases, Collections, and Documents
Every piece of data you store in MongoDB lives inside a three-level hierarchy: a database contains collections, and each collection holds documents. Understanding this hierarchy — and how it differs from the tables-and-rows world of relational databases — is the foundation for everything else you’ll do with MongoDB, from writing your first query to designing a production schema. This lesson walks through what databases, collections, and documents actually are, how MongoDB stores them under the hood as BSON, and the practical rules for working with them well.
Overview: How MongoDB Organizes Data
MongoDB is a document database. Instead of storing data in rigid tables made of rows and columns, it stores self-contained JSON-like records called documents inside groupings called collections, which themselves live inside a database. A single MongoDB server (or replica set) can host many databases; each database holds any number of collections; each collection holds any number of documents.
If you already know SQL, the closest mapping is: database ≈ database, collection ≈ table, document ≈ row. But the analogy breaks down in one crucial way — a relational table enforces a fixed set of columns and types for every row, while a MongoDB collection does not require every document to share the same fields or structure by default. One students document might have a major field and another might not; one might store phone as a string and another as an array of strings. This is often called being “schemaless,” though a more accurate description is schema-flexible — the schema exists (your application code expects certain fields), it’s just not enforced by the storage engine unless you explicitly turn on validation. Schema flexibility is a real feature: it lets you evolve your data model without slow, blocking migrations. But it also means the discipline of keeping documents consistent shifts from the database engine onto you and your application (or onto MongoDB’s optional schema validation, covered below) — flexibility is not an excuse for chaos.
Under the hood, documents are not stored as text-based JSON. They are stored as BSON (Binary JSON), a binary-encoded superset of JSON. BSON adds several types that plain JSON has no native way to represent: ObjectId (a compact, globally unique identifier), Date (a true 64-bit millisecond timestamp, not a string), Decimal128 (high-precision decimal for financial data), Timestamp (an internal type used by the oplog), Binary (raw byte data), and distinct 32-bit and 64-bit integer types. This is why, when you run a query in mongosh and get back a field like ObjectId('66b1f2a1c8e4a2d3f4b5c6d7') or ISODate('2026-08-03T00:00:00.000Z'), you’re seeing BSON types rendered as shell-friendly text, not literal JSON.
A single document has a hard limit of 16MB and can nest objects and arrays up to 100 levels deep. The 16MB limit is a design signal, not just a technical ceiling: it’s MongoDB telling you that documents are meant to represent one cohesive “thing” (an order, a user profile, a blog post with its comments) — not an ever-growing log or an entire dataset crammed into one record.
Every document has a unique _id field, which acts as its primary key within the collection. If you don’t supply one, MongoDB automatically generates an ObjectId — a 12-byte value made of a 4-byte timestamp, a 5-byte random value (unique per process), and a 3-byte incrementing counter. Because the first 4 bytes are a timestamp, ObjectIds are roughly sortable by creation time, and you can extract that timestamp with .getTimestamp(). MongoDB automatically creates a unique index on _id for every collection, so looking up a document by _id is always index-backed and fast.
Databases and collections in MongoDB are created lazily: running use mydb just switches your current context to that database name — it does not persist anything. The database (and any collection you reference) is only actually created the first time you write data to it (an insert, or an explicit db.createCollection() call). This trips up a lot of newcomers, and it’s covered in Common Mistakes below.
MongoDB reserves three database names for its own use: admin (superuser/authentication data), local (replication metadata, not itself replicated), and config (sharding metadata). You’ll see these in show dbs output even on a fresh install — avoid naming your own databases after them.
Syntax
The core commands for navigating and creating databases and collections:
use <databaseName>
db.createCollection(<name>, <options>)
show dbs
show collections
db.<collectionName>.insertOne(<document>)
| Command | Purpose |
|---|---|
use <db> |
Switches the shell’s current database context. Shell-only shortcut — not valid JavaScript. |
db.createCollection(name, options) |
Explicitly creates a collection. Needed for non-default options like a validator or a capped size; otherwise collections are created implicitly on first insert. |
show dbs |
Lists databases that have at least one collection with data (empty databases are hidden). Shell-only shortcut. |
show collections |
Lists collections in the current database. Shell-only shortcut. |
db.<name>.insertOne(doc) |
Inserts a single document, implicitly creating the database and/or collection if either doesn’t exist yet. |
db.createCollection()‘s most useful options:
capped(boolean) — if true, creates a fixed-size collection that overwrites its oldest documents when full, preserving insertion order (used for logs, caches).size(number) — maximum size in bytes for a capped collection (required ifcappedis true).validator(object) — a query-style or$jsonSchemaexpression that every inserted/updated document must satisfy.validationLevel(string) —"strict"(validate all writes, the default) or"moderate"(only validate writes to documents that already pass the validator).validationAction(string) —"error"(reject invalid writes, the default) or"warn"(log a warning but allow the write).
Examples
Example 1: Creating a Database and Collection Implicitly
Databases and collections don’t need to be explicitly created — the first write creates both. Watch what show dbs reports before and after an insert:
use bookstore
show dbs
admin 40.00 KiB
config 72.00 KiB
local 40.00 KiB
Notice bookstore doesn’t appear yet — use only changed the shell’s context, it didn’t create anything. Now insert a document:
db.books.insertOne({
title: "Dune",
author: "Frank Herbert",
price: 12.99,
tags: ["sci-fi", "classic"]
});
{
acknowledged: true,
insertedId: ObjectId('66b1f2a1c8e4a2d3f4b5c6d7')
}
The insert returns acknowledged: true and the auto-generated _id. Because the bookstore database and books collection didn’t exist, MongoDB created both on the fly, then inserted the document. Run show dbs again and bookstore now appears:
show dbs
show collections
admin 40.00 KiB
bookstore 8.00 KiB
config 72.00 KiB
local 40.00 KiB
books
show collections lists collections in the currently selected database (bookstore), confirming books was created.
Example 2: Enforcing Structure with Schema Validation
Schema flexibility doesn’t mean you can’t enforce rules. For a collection where consistency matters, attach a $jsonSchema validator:
db.createCollection("students", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["name", "email"],
properties: {
name: { bsonType: "string", description: "must be a string and is required" },
email: { bsonType: "string", pattern: "^.+@.+$", description: "must be a valid email and is required" },
age: { bsonType: "int", minimum: 0, maximum: 120, description: "must be an integer between 0 and 120" }
}
}
},
validationLevel: "strict",
validationAction: "error"
});
{ ok: 1 }
A document that satisfies the schema inserts normally:
db.students.insertOne({ name: "Priya Shah", email: "priya@example.com", age: 21 });
{
acknowledged: true,
insertedId: ObjectId('66b1f2b7c8e4a2d3f4b5c6d8')
}
But a document missing a required field is rejected before it ever reaches storage:
db.students.insertOne({ name: "Missing Email" });
MongoServerError: Document failed validation
Additional information: {
failingDocumentId: ...,
details: { ... 'email' is a required property ... }
}
This is the middle ground between rigid SQL schemas and total schema freedom: the collection still accepts flexible, evolving documents, but a baseline shape is guaranteed.
Example 3: Inspecting a Document’s BSON Types
Read the document back and inspect the _id MongoDB generated:
const doc = db.students.findOne({ name: "Priya Shah" });
doc;
{
_id: ObjectId('66b1f2b7c8e4a2d3f4b5c6d8'),
name: 'Priya Shah',
email: 'priya@example.com',
age: 21
}
typeof doc._id;
doc._id instanceof ObjectId;
doc._id.getTimestamp();
object
true
ISODate('2026-08-03T00:00:00.000Z')
_id isn’t a plain string — it’s an ObjectId instance, and calling .getTimestamp() extracts the creation time encoded in its first four bytes, with no separate createdAt field required. This is a genuinely useful property of ObjectIds: you get a rough creation timestamp for free.
How It Works Step by Step
When you run the sequence from Example 1, here’s what actually happens inside the server:
use bookstore— the mongosh client storesbookstoreas the current database name locally. No request is sent to the server; nothing is created.db.books.insertOne(...)— mongosh sends an insert command to the server for namespacebookstore.books.- The server checks its catalog for a database named
bookstore. It doesn’t exist, so the storage engine (WiredTiger, by default) creates the on-disk structures for it. - The server checks for a collection named
bookswithin that database. It doesn’t exist either, so it’s created implicitly with default options, along with its default index on_id. - Because the document has no
_idfield, the driver generates anObjectIdclient-side and adds it before the document is sent — this is why you always get theinsertedIdback even though the server never “chose” it. - The document is BSON-encoded and written to the collection’s storage table, and the
_idindex is updated to point to it. - The server returns an acknowledgment (assuming default write concern
w: "majority"on a replica set, orw: 1on a standalone), which is what producesacknowledged: true.
When a validator is present (Example 2), an extra step is inserted between step 5 and 6: the document is checked against the $jsonSchema expression before the write is applied. If it fails and validationAction is "error", the write is rejected outright and nothing is persisted; if it’s "warn", the write proceeds and the failure is only logged.
Common Mistakes
Mistake 1: Expecting use to create the database immediately
New users often assume switching context creates the database:
use inventory_tracker
show dbs
admin 40.00 KiB
config 72.00 KiB
local 40.00 KiB
inventory_tracker is nowhere to be seen — an empty database doesn’t really exist on disk yet, and show dbs never lists it. The fix is simply to write something to it:
db.items.insertOne({ sku: "ABC-123", quantity: 50 });
{
acknowledged: true,
insertedId: ObjectId('66b1f2c1c8e4a2d3f4b5c6d9')
}
show dbs
admin 40.00 KiB
config 72.00 KiB
inventory_tracker 8.00 KiB
local 40.00 KiB
Now it’s real. If you need to guarantee a database and collection exist with specific options (like a validator) before any application code writes to them, use db.createCollection() explicitly instead of relying on an implicit insert.
Mistake 2: Comparing _id to a plain string
A very common bug happens when an _id comes in from a URL parameter or a form field — it arrives as a string, but it’s stored as an ObjectId. Querying with the raw string silently returns nothing:
const studentId = "66b1f2b7c8e4a2d3f4b5c6d8"; // e.g. from a URL param
db.students.find({ _id: studentId });
// returns no documents — string does not equal ObjectId, even though they look the same
Because BSON distinguishes types, a string and an ObjectId are never equal, even when their printed form matches. Wrap the string in ObjectId() to fix it:
db.students.find({ _id: new ObjectId(studentId) });
[
{
_id: ObjectId('66b1f2b7c8e4a2d3f4b5c6d8'),
name: 'Priya Shah',
email: 'priya@example.com',
age: 21
}
]
The Node.js driver and Mongoose both throw a clear error if you pass a malformed ObjectId string, so this bug usually shows up as “works in the shell where I copy-pasted a real ObjectId, breaks in the app where the id came from a request” — always convert at the boundary where the string enters your query.
Best Practices
- Name databases and collections in a consistent, descriptive style — lowercase, plural nouns for collections (
orders, notOrderororderCollection). - Add a
$jsonSchemavalidator to any collection where data integrity matters (users, orders, billing) — schema flexibility is for evolving your model, not for tolerating garbage data. - Keep individual documents well under the 16MB limit; if a document’s array (reviews, log entries, comments) can grow without bound, that’s a signal to reference a separate collection instead of embedding.
- Never assume a database or collection exists just because your application inserted into it once — a typo’d name in
useor an insert silently creates a new, empty-looking namespace rather than erroring. - Avoid creating a database or collection per tenant/customer at scale — thousands of collections add real overhead to the server’s catalog and to operations like backups; model multi-tenancy with a
tenantIdfield instead in most cases. - Use
db.<collection>.stats()ordb.stats()periodically to understand storage size and document counts, especially before deciding whether a collection needs indexing or sharding attention.
Practice Exercises
- Create a database called
bookstore(if you haven’t already) and, inside it, create a collection calledauthorswith a$jsonSchemavalidator requiring a stringnameand an integerbirthYear. Insert one valid author document and confirm an invalid one (missingname) is rejected. - Insert a document into any collection without specifying
_id, then read it back and call.getTimestamp()on its_id. Confirm the timestamp roughly matches when you ran the insert. - Run
show dbsbefore and after creating a brand-new database withuseonly (no insert). Explain in your own words why the database doesn’t appear the first time.
Summary
- MongoDB organizes data as database → collection → document, roughly analogous to database → table → row in SQL, but without a rigid, enforced schema by default.
- Documents are stored as BSON, a binary superset of JSON with extra types like
ObjectId,Date, andDecimal128. - Every document needs a unique
_id, auto-generated as a sortable, timestamp-embeddingObjectIdif you don’t supply one. - Databases and collections are created lazily on first write unless you use
db.createCollection()explicitly. - Schema validation via
$jsonSchemalets you enforce structure on an otherwise flexible collection. - Documents are capped at 16MB and 100 levels of nesting — a hint that they should model one cohesive entity, not unbounded growth.
- Always convert a string
_idtoObjectIdbefore querying with it — BSON types don’t compare equal across type boundaries.
