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 if capped is true).
  • validator (object) — a query-style or $jsonSchema expression 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:

  1. use bookstore — the mongosh client stores bookstore as the current database name locally. No request is sent to the server; nothing is created.
  2. db.books.insertOne(...) — mongosh sends an insert command to the server for namespace bookstore.books.
  3. 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.
  4. The server checks for a collection named books within that database. It doesn’t exist either, so it’s created implicitly with default options, along with its default index on _id.
  5. Because the document has no _id field, the driver generates an ObjectId client-side and adds it before the document is sent — this is why you always get the insertedId back even though the server never “chose” it.
  6. The document is BSON-encoded and written to the collection’s storage table, and the _id index is updated to point to it.
  7. The server returns an acknowledgment (assuming default write concern w: "majority" on a replica set, or w: 1 on a standalone), which is what produces acknowledged: 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, not Order or orderCollection).
  • Add a $jsonSchema validator 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 use or 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 tenantId field instead in most cases.
  • Use db.<collection>.stats() or db.stats() periodically to understand storage size and document counts, especially before deciding whether a collection needs indexing or sharding attention.

Practice Exercises

  1. Create a database called bookstore (if you haven’t already) and, inside it, create a collection called authors with a $jsonSchema validator requiring a string name and an integer birthYear. Insert one valid author document and confirm an invalid one (missing name) is rejected.
  2. 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.
  3. Run show dbs before and after creating a brand-new database with use only (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, and Decimal128.
  • Every document needs a unique _id, auto-generated as a sortable, timestamp-embedding ObjectId if you don’t supply one.
  • Databases and collections are created lazily on first write unless you use db.createCollection() explicitly.
  • Schema validation via $jsonSchema lets 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 _id to ObjectId before querying with it — BSON types don’t compare equal across type boundaries.