Documents, queries, updates, and aggregation — the database Theebug itself runs on.
MongoDB is a document database — instead of rows in a fixed-schema table, each record is a document: a flexible, JSON-like object. { name: "Ada", age: 30 } is a complete, valid document on its own, no separate table definition required before you can store it.
Documents are grouped into collections (roughly analogous to a SQL table), and collections live inside a database. A single MongoDB server can host many databases, each with many collections, each holding many documents.
Documents in the same collection don't need identical fields — one user document could have a phone field while another doesn't. Real applications usually keep a consistent shape by convention and application-level validation, not because MongoDB itself enforces one.
{ name: "Ada", age: 30, roles: ["admin"] }insertOne(doc) adds exactly one new document and returns { acknowledged, insertedId } — insertedId is the auto-generated (or your own, if supplied) unique identifier for that new document.
insertMany([doc1, doc2, ...]) adds several documents in a single call — more efficient than looping and calling insertOne repeatedly when you already have all the data ready at once, since it's one round-trip to the database instead of many.
Neither insert method requires declaring a schema up front — the very first document ever inserted into a brand-new collection can simply be inserted, and the collection is created implicitly at that point if it didn't already exist.
const result = await users.insertOne({ name: "Ada", age: 30 });
console.log(result.insertedId);await users.insertMany([
{ name: "Ada" },
{ name: "Bo" },
]);findOne(filter) returns a single matching document directly (or null if nothing matches) — ready to use immediately, no extra step needed. An empty filter, {}, matches the first document in the collection with no real filtering at all.
find(filter) returns a Cursor, not documents directly — the actual query only really executes once you call .toArray() (or otherwise iterate the cursor), at which point you get a real array of every matching document.
A filter with multiple fields is an implicit AND: { name: "Ada", age: 30 } matches only a document where both fields match — combining conditions on different fields doesn't need an explicit $and unless you're combining several conditions on the very same field.
const user = await users.findOne({ name: "Ada" });const admins = await users.find({ role: "admin" }).toArray();Query operators (always prefixed with $) go inside a field's filter value to express something beyond an exact match: { age: { $gte: 18 } } means "age is 18 or more," not "age equals the object { $gte: 18 }."
The core comparison operators are $gt (greater than), $gte (greater than or equal), $lt (less than), $lte (less than or equal), and $ne (not equal) — the same comparisons every language has, spelled as operator names instead of symbols.
$in matches if a field's value is any one of a list: { status: { $in: ["active", "pending"] } }. Multiple operators can combine on one field too: { age: { $gte: 13, $lt: 20 } } expresses an inclusive-exclusive range in a single filter.
await users.find({ age: { $gte: 18, $lt: 65 } }).toArray();await orders.find({ status: { $in: ["shipped", "delivered"] } }).toArray();updateOne(filter, update) takes two separate objects: the filter decides *which* document to change, and the update — almost always wrapped in an operator like $set — decides *what* changes about it.
$set changes only the specific fields listed inside it, leaving every other field on the document untouched: { $set: { age: 31 } } changes just age. Passing a bare object with no operator is invalid in modern MongoDB, specifically to prevent accidentally replacing an entire document's contents.
$inc changes a numeric field relative to its current value rather than requiring you to read it first: { $inc: { age: 1 } } increases age by exactly 1, whatever it currently is — useful for counters and scores without a separate read-then-write round trip.
await products.updateOne({ name: "Widget" }, { $set: { price: 12.99 } });await posts.updateOne({ _id: postId }, { $inc: { views: 1 } });deleteOne(filter) removes the first document matching the filter and returns { deletedCount: 1 } (or 0 if nothing matched). deleteMany(filter) removes every matching document instead — picking the wrong one of these two is a real, common, hard-to-undo mistake.
deleteMany({}) with a genuinely empty filter deletes every document in the collection — a real one-line, immediate, unrecoverable mistake (absent a backup) worth double-checking before ever running, especially against a production database.
A soft-delete pattern (setting a deletedAt or isDeleted field with $set, instead of actually removing the document) is a common alternative when "deleted" data still needs to be recoverable or auditable later — a deliberate design choice, not a MongoDB feature by itself.
await users.deleteOne({ name: "Ada" });await sessions.deleteMany({ expiresAt: { $lt: new Date() } });An aggregation pipeline processes documents through an ordered series of stages, each stage's output feeding into the next — $match filters documents, $group collapses many documents into one per key, $sort orders the results, and more.
$group requires an _id specifying what to group by: { $group: { _id: "$userId", total: { $sum: "$amount" } } } produces one output document per distinct userId, with total summing amount across every document in that group — the aggregation equivalent of a SQL GROUP BY with SUM().
Stage order matters and affects performance, not just the final shape of the result: a $match placed *before* $group filters documents out early and cheaply (the group stage never even sees them), while a $sort placed *after* $group orders the already-summarized results, not the original raw documents.
await products.aggregate([
{ $group: { _id: "$category", count: { $sum: 1 } } },
]).toArray();await orders.aggregate([
{ $match: { status: "completed" } },
{ $group: { _id: "$userId", total: { $sum: "$amount" } } },
]).toArray();An index is a data structure that lets MongoDB find matching documents without scanning every single one — the same purpose an index serves in a SQL database. Without an index on a field you query often, MongoDB falls back to a full collection scan for every query on it.
createIndex({ email: 1 }) creates an ascending index on email (1 for ascending, -1 for descending — the direction rarely matters for a single-field index, but matters more for sorting or multi-field indexes). Every collection already has one index automatically, on _id.
A unique index (createIndex({ email: 1 }, { unique: true })) additionally enforces that no two documents can share the same value for that field — inserting a duplicate throws an error instead of silently succeeding.
await users.createIndex({ email: 1 });await users.createIndex({ email: 1 }, { unique: true });Without enforced schemas, a real modeling decision still exists: embed related data directly inside a document, or reference it by storing another document's _id and querying separately — MongoDB's equivalent of the "one big object" vs. "a foreign key" choice.
Embedding (a blog post document containing its comments directly, as an array) is fast to read (one query gets everything) and is the natural fit for data that's always accessed together and doesn't grow unboundedly.
Referencing (storing a userId inside an order document, looking up the user separately, or joining via $lookup in an aggregation) fits data that's shared across many documents, changes independently, or would make an embedding document grow too large or too often.
{ title: "Post", comments: [{ text: "Nice!", author: "Bo" }] }{ title: "Post", authorId: ObjectId("...") }
// looked up separately, or joined via $lookupThe official mongodb npm package provides MongoClient — MongoClient.connect(uri) (or new MongoClient(uri).connect()) opens a connection using a connection string that includes the host, credentials, and options.
A real application typically creates one MongoClient and reuses it for the lifetime of the process, rather than connecting and disconnecting per request — connecting is relatively expensive, and the driver already manages a connection pool internally once connected.
client.db("myapp").collection("users") is how you get a handle to a specific collection to run operations against — db() picks the database, collection() picks the collection within it, both cheap, synchronous calls (no network round-trip) that just return a reference object.
const { MongoClient } = require("mongodb");
const client = new MongoClient(process.env.MONGO_URI);
await client.connect();
const users = client.db("myapp").collection("users");