Connecting to MongoDB
MySQL stores data in tables with fixed columns. MongoDB stores documents: JSON-like objects that can contain nested objects and arrays, grouped into collections. A student document can hold the student's marks object and hobbies array directly, with no extra tables. Because documents look like JavaScript objects, MongoDB is a popular partner for Node.js; the "M" in the MERN stack (MongoDB, Express, React, Node.js) stands for it.
In this lesson you will connect with the official mongodb driver and run create, read, update and delete queries with MongoDB's query operators. Then you will use Mongoose, a library that adds schemas and validation, and build an Express API on top of it.
Getting a MongoDB server
You need a running MongoDB server. Two common options:
- MongoDB Community Server on your own computer (the current major version is MongoDB 9.0, from September 2026). It listens on port 27017. Install MongoDB Shell (
mongosh) and Compass (a graphical viewer) alongside it. - MongoDB Atlas, the hosted cloud service, which has a free tier. It gives you a connection string that starts with
mongodb+srv://.
Put the connection string in .env. The part after the last / is the database name; MongoDB creates databases and collections automatically the first time you write to them.
# .env
MONGODB_URI=mongodb://127.0.0.1:27017/schoolDocuments and the mongosh shell
You can try queries in mongosh before writing any Node.js code. Its syntax is almost the same as the Node.js driver's:
use school
db.students.insertOne({ name: "Aarav", city: "Rohtak", marks: { maths: 88 } })
db.students.find({ city: "Rohtak" })Every document gets an _id field. If you do not supply one, MongoDB creates an ObjectId: a 12-byte unique value, shown as a 24-character hex string such as 6ae4722caebc3f8fcf685d1c.
The official driver
npm install mongodb// driver.js (run with: node --env-file=.env driver.js)
import { MongoClient, ObjectId } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI);
await client.connect();
const db = client.db(); // the database named in the URI ("school")
const students = db.collection("students");
// Create
const one = await students.insertOne({ name: "Aarav", city: "Rohtak", marks: { maths: 88, science: 92 }, hobbies: ["cricket"] });
console.log(one.insertedId instanceof ObjectId);
await students.insertMany([
{ name: "Priya", city: "Jaipur", marks: { maths: 95, science: 89 }, hobbies: ["dance", "chess"] },
{ name: "Kabir", city: "Pune", marks: { maths: 67, science: 74 } },
{ name: "Meera", city: "Kochi", marks: { maths: 91, science: 97 }, hobbies: ["chess"] },
]);
// Read
const toppers = await students
.find({ "marks.maths": { $gte: 85 } }, { projection: { _id: 0, name: 1, "marks.maths": 1 } })
.sort({ "marks.maths": -1 })
.toArray();
console.log(toppers);
console.log(await students.findOne({ hobbies: "chess", city: "Kochi" }, { projection: { _id: 0, name: 1 } }));
console.log(await students.countDocuments({ city: { $in: ["Pune", "Jaipur"] } }));
// Update
const upd = await students.updateOne({ name: "Kabir" }, { $set: { city: "Mumbai" }, $inc: { "marks.maths": 5 }, $push: { hobbies: "football" } });
console.log(upd.matchedCount, upd.modifiedCount);
console.log(await students.findOne({ name: "Kabir" }, { projection: { _id: 0 } }));
// Delete
const del = await students.deleteOne({ name: "Aarav" });
console.log(del.deletedCount);
await client.close();true
[
{ name: 'Priya', marks: { maths: 95 } },
{ name: 'Meera', marks: { maths: 91 } },
{ name: 'Aarav', marks: { maths: 88 } }
]
{ name: 'Meera' }
2
1 1
{
name: 'Kabir',
city: 'Mumbai',
marks: { maths: 72, science: 74 },
hobbies: [ 'football' ]
}
1How MongoDB queries work:
- A filter is an object describing the documents you want.
{ city: "Kochi" }means "city equals Kochi". Dot notation such as"marks.maths"reaches inside nested objects, and matching an array field such ashobbies: "chess"means "the array contains chess". - Operators start with
$:$gt,$gte,$lt,$lte,$ne(not equal),$in(one of a list),$regexand more. - A projection chooses which fields to return:
1includes,0excludes. find()returns a cursor; chain.sort(),.limit()and.skip(), then.toArray()to get the results.- Update operators change parts of a document:
$setsets fields,$incadds to a number,$pushadds to an array (creating it if missing),$unsetremoves a field.
| SQL | MongoDB driver |
|---|---|
INSERT INTO students ... | insertOne({...}), insertMany([...]) |
SELECT * FROM students WHERE city = 'Pune' | find({ city: "Pune" }).toArray() |
... WHERE maths >= 85 ORDER BY maths DESC | find({ maths: { $gte: 85 } }).sort({ maths: -1 }) |
UPDATE students SET city = 'Pune' WHERE ... | updateOne(filter, { $set: { city: "Pune" } }) |
DELETE FROM students WHERE ... | deleteOne(filter), deleteMany(filter) |
IDs, indexes and duplicate errors
IDs from URLs are strings, but _id values are ObjectIds, so convert them, and check the string first:
await students.createIndex({ name: 1 }, { unique: true });
try {
await students.insertOne({ name: "Priya", city: "Delhi" });
} catch (err) {
console.log(err.code, err.name);
}
const idString = (await students.findOne({ name: "Meera" }))._id.toString();
console.log(ObjectId.isValid(idString), ObjectId.isValid("12345"));
console.log((await students.findOne({ _id: new ObjectId(idString) })).name);11000 MongoServerError true false Meera
An index makes searches on a field fast, and a unique index refuses duplicates with error code 11000, which your API can turn into 409 Conflict. Create indexes for the fields you filter and sort by most often.
Mongoose: schemas and models
MongoDB does not force documents in a collection to have the same shape. That freedom is convenient but risky: a typo such as nmae silently creates a new field. Mongoose adds a schema that describes each field's type and rules, and a model class for querying.
npm install mongoose// models.js
import mongoose from "mongoose";
const studentSchema = new mongoose.Schema(
{
name: { type: String, required: [true, "Name is required"], trim: true, maxlength: 60 },
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
course: { type: String, enum: ["BCA", "BSc", "BTech"], default: "BCA" },
fees: { type: Number, min: 0 },
city: String,
},
{ timestamps: true }, // adds createdAt and updatedAt
);
export const Student = mongoose.model("Student", studentSchema);The model Student works with the collection students (Mongoose makes the name lowercase and plural).
import mongoose from "mongoose";
import { Student } from "./models.js";
await mongoose.connect(process.env.MONGODB_URI);
await Student.init(); // wait until the unique index on email exists
const rohan = await Student.create({ name: " Rohan Verma ", email: "Rohan@Example.in", fees: 45000, city: "Lucknow" });
console.log(rohan.name, rohan.email, rohan.course, rohan.createdAt instanceof Date);
try {
await Student.create({ email: "x@example.in", course: "MBA", fees: -10 });
} catch (err) {
console.log(err.name);
for (const [field, e] of Object.entries(err.errors)) console.log(` ${field}: ${e.message}`);
}
await Student.create({ name: "Ananya", email: "ananya@example.in", course: "BTech", fees: 120000, city: "Kochi" });
const list = await Student.find({ fees: { $lt: 100000 } }).select("name course -_id").lean();
console.log(list);
const updated = await Student.findOneAndUpdate(
{ email: "rohan@example.in" },
{ $set: { city: "Pune" } },
{ returnDocument: "after", runValidators: true },
);
console.log(updated.city);
await mongoose.disconnect();Rohan Verma rohan@example.in BCA true
ValidationError
name: Name is required
course: `MBA` is not a valid enum value for path `course`.
fees: Path `fees` (-10) is less than minimum allowed value (0).
[ { name: 'Rohan Verma', course: 'BCA' } ]
PuneMongoose trimmed the name, lowercased the email, filled in the default course and added timestamps. Invalid data was rejected with a ValidationError listing every problem. .lean() returns plain objects instead of full Mongoose documents, which is faster for read-only API responses. runValidators: true applies the schema rules to updates too, because Mongoose skips them on updates by default.
Mongoose in an Express API
import express from "express";
import mongoose from "mongoose";
import { Student } from "./models.js";
await mongoose.connect(process.env.MONGODB_URI);
const app = express();
app.use(express.json());
app.get("/api/students/:id", async (req, res) => {
if (!mongoose.isValidObjectId(req.params.id)) return res.status(400).json({ error: "Invalid id" });
const student = await Student.findById(req.params.id).lean();
if (!student) return res.status(404).json({ error: "Student not found" });
res.json(student);
});
app.post("/api/students", async (req, res) => {
const { name, email, course, fees, city } = req.body; // pick fields, never pass req.body directly
try {
const student = await Student.create({ name, email, course, fees, city });
res.status(201).json(student);
} catch (err) {
if (err.name === "ValidationError") return res.status(400).json({ error: err.message });
if (err.code === 11000) return res.status(409).json({ error: "Email already registered" });
throw err;
}
});
app.listen(3000);POST /api/students -> 201 {"name":"Isha","email":"isha@example.in","course":"BSc","city":"Jaipur","_id":"6ae4722caebc3f8fcf685d1c","createdAt":"2026-10-30T10:32:12.943Z","updatedAt":"2026-10-30T10:32:12.943Z","__v":0}
POST /api/students -> 409 {"error":"Email already registered"}
GET /api/students/12345 -> 400 {"error":"Invalid id"}
GET /api/students/6ae4722daebc3f8fcf685d1f -> 404 {"error":"Student not found"}__v is Mongoose's internal version key. Pick the fields you allow from req.body instead of passing the whole object, so nobody can set fields such as role or fees that they should not control.
Common mistakes
- Comparing
_idwith a string.{ _id: "6ae4..." }finds nothing with the driver; usenew ObjectId(id)(Mongoose converts for you). - Passing
req.bodystraight into a filter orcreate(). This allows NoSQL injection and mass assignment. Validate and pick fields. - Forgetting
.toArray()with the driver'sfind(). You get a cursor, not documents. - Opening a new connection per request. Connect once at startup and reuse the client or the Mongoose connection.
- No indexes. Queries scan every document; add indexes for fields you filter and sort on, and unique indexes for emails.
Key points
- MongoDB stores JSON-like documents in collections; every document has a unique
_id, usually an ObjectId. - The
mongodbdriver givesinsertOne,find,findOne,updateOnewith$set/$inc/$push, anddeleteOne. - Filters use operators such as
$gteand$in; projections choose fields; cursors supportsortandlimit. - Unique indexes reject duplicates with error code 11000.
- Mongoose adds schemas, validation, defaults and timestamps; use
.lean()for fast reads andrunValidatorson updates. - Validate input types before querying to prevent NoSQL injection.
Exercise
Start with the four students inserted in the driver example (Aarav, Priya, Kabir and Meera, before any updates or deletes). Write a query that returns the names and science marks of students with science marks of 80 or more, sorted from highest to lowest, without the _id field. Then write an update that adds the hobby "music" to every student from Jaipur.
Show solution
const goodInScience = await students
.find({ "marks.science": { $gte: 80 } }, { projection: { _id: 0, name: 1, "marks.science": 1 } })
.sort({ "marks.science": -1 })
.toArray();
console.log(goodInScience);
const result = await students.updateMany({ city: "Jaipur" }, { $push: { hobbies: "music" } });
console.log(`Updated ${result.modifiedCount} student(s)`);[
{ name: 'Meera', marks: { science: 97 } },
{ name: 'Aarav', marks: { science: 92 } },
{ name: 'Priya', marks: { science: 89 } }
]
Updated 1 student(s)Dot notation reaches the nested science field, -1 sorts in descending order, and updateMany() changes every matching document instead of only the first.
Quick Quiz
1. What is a collection in MongoDB?
Documents are stored in collections, and collections belong to a database.
2. Which filter finds students whose maths marks are 90 or more?
Comparison operators such as $gte go inside an object for the field.
3. What does the update { $inc: { stock: -2 } } do?
$inc adds the given number to the current value.
4. An attacker sends { "email": { "$ne": null } } to a login route. What prevents this NoSQL injection?
Query operators hidden in input only work if objects reach the filter; validation stops them.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
