MongoDB with Mongoose
Reviewed & published by Brayan K
Mongoose is an Object Data Modeling library that gives MongoDB structure: you define a Schema for your documents, compile it into a Model, and use it to create, find, update, and delete data with built-in validation.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll connect to MongoDB, design a schema with types and validators, run the full set of CRUD operations and queries, validate input, and link documents together with refs and populate — the data layer behind most Node.js APIs.
What You'll Learn in This Lesson
- How to connect to MongoDB with mongoose.connect
- How to define a Schema with types, required, and defaults
- How a Schema compiles into a Model that maps to a collection
- CRUD: create, find, findById, updateOne, deleteOne
- How to filter, sort, and limit queries
- How refs and populate link documents together
1️⃣ Connect, Schema, Model
Three steps get you started. mongoose.connect(uri) opens a (cached) connection to your database. A Schema describes each field's type and rules. And mongoose.model('User', schema) compiles that schema into a Model — the object you actually use to read and write the users collection.
// Install once: npm install mongoose
const mongoose = require('mongoose');
// 1) Connect. The connection is cached, so call connect once at startup.
await mongoose.connect('mongodb://127.0.0.1:27017/shop');
// 2) Describe the shape of your documents with a Schema.
// Types, "required", defaults, and validators all live here.
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
age: { type: Number, min: 18 },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
}, { timestamps: true }); // adds createdAt / updatedAt automatically
// 3) Compile the schema into a Model — your gateway to the collection.
// Mongoose pluralises the name, so this maps to the "users" collection.
const User = mongoose.model('User', userSchema);
module.exports = User;2️⃣ CRUD: Create, Read, Update, Delete
Every Model gives you the same core methods: create to insert, find / findById to read, updateOne to change, and deleteOne to remove. The runnable block below uses a stand-in with the identical API, so you can see real output for the full create → read → update → delete cycle:
// A small stand-in that mimics the Mongoose Model API over an in-memory
// array, so the OUTPUT here is real. The TRUE Mongoose calls are identical —
// see the read-only block below.
let nextId = 1;
function makeModel() {
const docs = [];
return {
async create(data) { const doc = { _id: 'id' + nextId++, ...data }; docs.push(doc); return doc; },
async find(filter = {}) {
return docs.filter(d => Object.entries(filter).every(([k, v]) => d[k] === v));
},
async findById(id) { return docs.find(d => d._id === id) || null; },
async updateOne(filter, update) {
const d = docs.find(x => Object.entries(filter).every(([k, v]) => x[k] === v));
if (d) Object.assign(d, update.$set || update);
return { matchedCount: d ? 1 : 0, modifiedCount: d ? 1 : 0 };
},
async deleteOne(filter) {
const i = docs.findIndex(x => Object.entries(filter).every(([k, v]) => x[k] === v));
if (i >= 0) docs.splice(i, 1);
return { deletedCount: i >= 0 ? 1 : 0 };
},
};
}
const User = makeModel();
async function main() {
const ada = await User.create({ name: 'Ada', age: 36, role: 'admin' });
await User.create({ name: 'Bo', age: 24, role: 'user' });
console.log('Created:', ada.name, 'with id', ada._id);
const admins = await User.find({ role: 'admin' });
console.log('Admins found:', admins.length);
const found = await User.findById(ada._id);
console.log('findById name:', found.name);
const up = await User.updateOne({ name: 'Ada' }, { $set: { age: 37 } });
console.log('Updated:', up.modifiedCount, '-> new age', (await User.findById(ada._id)).age);
const del = await User.deleteOne({ name: 'Bo' });
console.log('Deleted:', del.deletedCount);
console.log('Remaining users:', (await User.find()).length);
}
main();Here are the real Mongoose calls — note the method names are exactly the same as above, plus query helpers like $gte, .sort(), .limit(), and .select():
const User = require('./User'); // the model from section 1
// CREATE — insert a new document (runs schema validation first).
const ada = await User.create({ name: 'Ada', email: 'ada@x.com', age: 36, role: 'admin' });
// READ — find() returns an array; pass a filter object to narrow it.
const admins = await User.find({ role: 'admin' });
const everyone = await User.find(); // all documents
const one = await User.findById(ada._id); // one by its _id
const byEmail = await User.findOne({ email: 'ada@x.com' });
// QUERY operators: gte/lte/in, sorting, limiting, picking fields.
const adults = await User.find({ age: { $gte: 18 } })
.sort({ age: -1 }) // descending
.limit(10)
.select('name age'); // only these fields
// UPDATE — change matching documents.
await User.updateOne({ _id: ada._id }, { $set: { age: 37 } });
const updated = await User.findByIdAndUpdate(ada._id, { role: 'user' }, { new: true });
// DELETE — remove matching documents.
await User.deleteOne({ _id: ada._id });3️⃣ Validation, Refs & Populate
A big reason to use Mongoose is schema validation. Rules like required, min, and enum are checked before a document is saved, and a failure throws a ValidationError listing what's wrong. The stand-in below produces the same kind of messages so you can see validation in action:
// Stand-in for Mongoose schema validation (required, min, enum).
// Mongoose throws a ValidationError with exactly these kinds of messages.
function validate(schema, data) {
const errors = [];
for (const [field, rule] of Object.entries(schema)) {
const v = data[field];
if (rule.required && (v === undefined || v === null)) errors.push(field + ' is required');
else if (v !== undefined) {
if (rule.min !== undefined && v < rule.min) errors.push(field + ' must be >= ' + rule.min);
if (rule.enum && !rule.enum.includes(v)) errors.push(field + ' must be one of ' + rule.enum.join('/'));
}
}
return errors;
}
const userSchema = {
name: { required: true },
age: { min: 18 },
role: { enum: ['user', 'admin'] },
};
console.log('Valid:', validate(userSchema, { name: 'Ada', age: 30, role: 'admin' }));
console.log('Bad: ', validate(userSchema, { age: 12, role: 'ghost' }));Finally, documents relate to each other through refs. A field typed as an ObjectId with a ref stores another document's id; calling .populate('field') swaps that id for the full document — Mongoose's answer to a join:
// A Post stores the author's _id (a reference), not the whole user.
const postSchema = new mongoose.Schema({
title: String,
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, // ref!
});
const Post = mongoose.model('Post', postSchema);
// Without populate, post.author is just an ObjectId.
const post = await Post.findOne();
console.log(post.author); // -> new ObjectId("...")
// populate() swaps the id for the full User document in one query.
const full = await Post.findOne().populate('author');
console.log(full.author.name); // -> "Ada"Your turn. Fill in the two ___ blanks so the program creates and finds a book, then run it.
// A tiny model stand-in (same API as Mongoose).
let id = 1;
const docs = [];
const Book = {
async create(d) { const doc = { _id: 'b' + id++, ...d }; docs.push(doc); return doc; },
async find(f = {}) { return docs.filter(x => Object.entries(f).every(([k, v]) => x[k] === v)); },
};
async function main() {
// 🎯 YOUR TURN — replace each ___ then run it.
// 1) Create a book by Ada.
await Book.___({ title: 'Node Basics', author: 'Ada' }); // 👉 create
// 2) Find all books whose author is 'Ada'.
const books = await Book.find(___); // 👉 { author: 'Ada' }
console.log('Books by Ada:', books.length);
console.log('First title:', books[0].title);
}
main();
// ✅ Expected output:
// Books by Ada: 1
// First title: Node BasicsMini-Challenge: A Tiny Task List
No blanks this time — just a brief and an outline. Write it yourself, run it, and check your output. This ties together create, filtered find, and delete — the same methods real Mongoose models expose.
// A model stand-in with create / find / deleteOne.
let id = 1;
const rows = [];
const Task = {
async create(d) { const t = { _id: 't' + id++, done: false, ...d }; rows.push(t); return t; },
async find(f = {}) { return rows.filter(x => Object.entries(f).every(([k, v]) => x[k] === v)); },
async deleteOne(f) {
const i = rows.findIndex(x => Object.entries(f).every(([k, v]) => x[k] === v));
if (i >= 0) rows.splice(i, 1);
return { deletedCount: i >= 0 ? 1 : 0 };
},
};
// 🎯 MINI-CHALLENGE: a tiny task list.
// 1. Create two tasks: { title: 'Write tests' } and { title: 'Ship it', done: true }.
// 2. Find only the tasks where done is false and print how many.
// 3. Delete the task titled 'Write tests' and print how many remain.
//
// ✅ Example output:
// Pending tasks: 1
// Remaining: 1
async function main() {
// your code here
}
main();Common Errors (and the fix)
- "MongooseServerSelectionError" — Mongoose can't reach the database. Check the connection URI, that MongoDB is actually running, and any IP allow-list (Atlas) or firewall.
- "ValidationError: Path `name` is required" — You tried to save a document missing a required field. Validate input first and return a 400 with the message.
- "E11000 duplicate key error" — A unique field (like email) already exists. This is a database index error, not a schema validation error — catch it separately.
- Update ignored my validators — updateOne/findByIdAndUpdate skip validators by default. Pass { runValidators: true }.
- find() result has no .name — find() returns an array. Use findOne/findById for a single doc, or index into the array.
Pro Tips
- 💡 Connect once at startup: Mongoose caches the connection, so call connect a single time, not per request.
- 💡 Let the schema guard your data: mark fields required and add enum/min so bad data is rejected before it lands in the DB.
- 💡 Use lean() for read-heavy queries: find().lean() returns plain objects (no Mongoose overhead) when you only need to read.
📋 Quick Reference — Mongoose
| Task | Syntax |
|---|---|
| Connect | await mongoose.connect(uri) |
| Model | mongoose.model('User', schema) |
| Create | await User.create({ ... }) |
| Find many | await User.find({ role: 'admin' }) |
| Find one by id | await User.findById(id) |
| Update | await User.updateOne(filter, { $set }) |
| Delete | await User.deleteOne(filter) |
| Populate ref | .populate('author') |
🎉 Lesson Complete!
- ✅ mongoose.connect opens one cached connection at startup
- ✅ A Schema defines field types and rules; a Model reads and writes a collection
- ✅ create, find, findById, updateOne, deleteOne cover CRUD
- ✅ Queries take filters and chain .sort(), .limit(), .select()
- ✅ Schema validators reject bad data before it's saved
- ✅ ref + populate link documents together like a join
Practice quiz
What is the purpose of a Mongoose Schema?
- To connect to the database
- To describe the shape of your documents
- To start a server
- To hash passwords
Answer: To describe the shape of your documents. A Schema defines field types, required rules, defaults, and validators.
Which Mongoose call connects to a MongoDB database?
- mongoose.open()
- mongoose.start()
- mongoose.connect()
- mongoose.link()
Answer: mongoose.connect(). mongoose.connect('mongodb://...') opens the connection.
How do you turn a schema into a Model named User?
- mongoose.model('User', userSchema)
- new User(schema)
- schema.toModel('User')
- mongoose.schema('User')
Answer: mongoose.model('User', userSchema). mongoose.model('User', userSchema) compiles the schema into a Model.
Mongoose maps the model 'User' to which collection name?
- User
- USERS
- users
- User_collection
Answer: users. Mongoose lowercases and pluralizes, so 'User' maps to the 'users' collection.
Which method inserts a new document and runs validation first?
- User.insert()
- User.create()
- User.add()
- User.new()
Answer: User.create(). User.create(data) inserts a document after running schema validation.
Which method finds a single document by its _id?
- User.find()
- User.getOne()
- User.byId()
- User.findById()
Answer: User.findById(). User.findById(id) returns one document by its _id (or null).
What does User.find() with NO filter return?
- All documents in the collection
- Just the first document
- An error
- Only documents created today
Answer: All documents in the collection. find() with no filter returns every document in the collection.
Which schema option auto-adds createdAt and updatedAt fields?
- { versionKey: true }
- { timestamps: true }
- { dates: true }
- { auto: true }
Answer: { timestamps: true }. Passing { timestamps: true } adds createdAt and updatedAt automatically.
Which query operator selects ages greater than or equal to 18?
- { age: { $eq: 18 } }
- { age: { $lt: 18 } }
- { age: { $gte: 18 } }
- { age: '>=18' }
Answer: { age: { $gte: 18 } }. $gte means greater-than-or-equal, e.g. { age: { $gte: 18 } }.
What error does Mongoose throw when a required field is missing or a value breaks a rule?
- TypeError
- SyntaxError
- RangeError
- ValidationError
Answer: ValidationError. Schema rules (required, min, enum) cause a ValidationError.
Continue this course
- Previous: Logging with morgan & pino
- Next: Deploying Node (PM2 & Docker) — Production config, PM2, a Dockerfile, health checks, and graceful shutdown
- Quick reference: Node.js cheat sheet
Frequently asked questions
What is Mongoose and how does it relate to MongoDB?
MongoDB is the database — it stores JSON-like documents in collections. Mongoose is an ODM (Object Data Modeling) library that sits on top of the official MongoDB driver and gives that schemaless database structure. You define a Schema describing each field's type and rules, compile it into a Model, and then use friendly methods like create, find, and updateOne. Mongoose adds schema validation, default values, middleware (hooks), population of references, and a clean query API, so you get the flexibility of MongoDB with guardrails that keep your data consistent.
What's the difference between a Schema and a Model?
A Schema is the blueprint: it declares the fields, their types, whether they're required, defaults, validators, and options like timestamps. It describes shape but can't touch the database by itself. A Model is what you get when you compile a schema with mongoose.model('User', userSchema). The Model is the actual gateway to a collection — it's the object you call create, find, findById, updateOne, and deleteOne on. One schema, compiled into one model, maps to one MongoDB collection (Mongoose pluralises the name, so 'User' becomes the 'users' collection).
How do refs and populate work?
MongoDB doesn't have SQL-style joins, so to relate documents you store a reference — the other document's _id — in a field typed as ObjectId with a ref to the target model. By default, reading the document gives you just that id. Calling .populate('fieldName') tells Mongoose to run a second query and replace the id with the full referenced document, so post.author goes from an ObjectId to the whole User object. It's the idiomatic way to model relationships in Mongoose without embedding everything in one document.
Why is my Mongoose validation not running on an update?
By default Mongoose runs schema validators on .save() and on .create(), but update operations like updateOne and findByIdAndUpdate skip them unless you opt in. Pass { runValidators: true } in the options to make those validators fire on updates. Also remember that some checks, like a unique email, are enforced by a MongoDB index rather than a schema validator — those surface as a duplicate-key (E11000) error from the database, not as a Mongoose ValidationError, so handle both when saving user input.