MongoDB is a NoSQL database that stores data in JSON-like documents. Think of it like a giant filing cabinet where each drawer (collection) holds folders (documents) that can each have a different structure — unlike a SQL database where every row in a table must follow the exact same column layout.Mongoose is an Object Data Modeling (ODM) library for MongoDB and Node.js. It manages relationships between data, provides schema validation, and translates between objects in your code and the representation of those objects in MongoDB. If MongoDB is the filing cabinet, Mongoose is the organizational system that ensures every folder you put in has the right labels and contents before it gets filed away.
const mongoose = require('mongoose');const connectDB = async () => { try { // mongoose.connect() returns a promise -- no need for the old // useNewUrlParser or useUnifiedTopology options (removed in Mongoose 6+) await mongoose.connect(process.env.MONGO_URI); console.log('MongoDB Connected...'); } catch (err) { console.error(err.message); // Exit with failure code -- a database connection is non-negotiable // for most apps, so crashing early is the right call here process.exit(1); }};connectDB();
Production tip: Never hardcode your connection string. Always use environment variables. For local development, use a .env file with dotenv. For production, consider connection strings with retryWrites=true and w=majority for replica set write safety.
Everything in Mongoose starts with a Schema. Each schema maps to a MongoDB collection and defines the shape of the documents within that collection.models/User.js
CRUD stands for Create, Read, Update, Delete — the four fundamental operations you will perform against any database. Mongoose wraps each of these in intuitive methods that return promises, so they work naturally with async/await.
const User = require('./models/User');// Option 1: Instantiate then save (useful when you need to// modify the document before persisting it)const newUser = new User({ name: 'John Doe', email: 'john@example.com', password: 'hashedpassword123'});await newUser.save();// Option 2: Create directly (shorthand -- instantiates and saves in one step)const anotherUser = await User.create({ name: 'Jane Doe', email: 'jane@example.com', password: 'hashedpassword456'});
// Find all -- returns every document in the collection (careful on large datasets!)const users = await User.find();// Find one by criteria -- returns the first match or nullconst user = await User.findOne({ email: 'john@example.com' });// Find by ID -- a shorthand for findOne({ _id: '60d5ec...' })const userById = await User.findById('60d5ec...');// Pitfall: findById returns null (not an error) if the ID does not exist.// Always check the result before using it.
Mongoose allows you to reference documents in other collections. This is conceptually similar to a JOIN in SQL — you store the ID of a related document, and Mongoose can “populate” it by fetching the full document at query time.Think of it like a library card catalog: each book card has the author’s ID number. When you want the author’s full name, you look them up by that ID. Population does that lookup automatically.
const PostSchema = new mongoose.Schema({ user: { type: mongoose.Schema.Types.ObjectId, // Stores only the _id reference ref: 'User' // Tells Mongoose which collection to look up during populate() }, title: String});// ...// Fetch posts and populate user data -- the second argument// limits which fields are returned from the User documentconst posts = await Post.find().populate('user', ['name', 'email']);
Performance pitfall:populate() triggers a separate query for each referenced collection. For deeply nested populations or large result sets, this can cause performance problems. Consider using aggregate() with $lookup for complex joins, or denormalize frequently-accessed data directly into the document.
Virtuals are computed properties that exist on the document in your code but are never persisted to the database. They are like a spreadsheet formula cell — the value is derived on the fly from other fields.
// Virtual property (not stored in database)// Note: virtuals use a regular function (not an arrow function)// because they need access to 'this' -- the document instanceUserSchema.virtual('fullName').get(function() { return `${this.firstName} ${this.lastName}`;});// Virtual populate -- creates a reverse relationship without storing// an array of post IDs on the user documentUserSchema.virtual('posts', { ref: 'Post', localField: '_id', // Match User._id... foreignField: 'author' // ...against Post.author});// Then use: User.findById(id).populate('posts')
Mongoose middleware are functions that run at specific stages of the document lifecycle — before or after save, validate, remove, and query operations. Think of them like airport security checkpoints: every document passes through them at defined stages, and you can inspect, modify, or reject documents at each checkpoint.
// Pre-save middleware -- runs BEFORE the document is written to the database.// This is the most common hook and the standard place to hash passwords.UserSchema.pre('save', async function(next) { // Only hash if password is modified -- without this check, // the password would be re-hashed on every save, corrupting it if (!this.isModified('password')) return next(); this.password = await bcrypt.hash(this.password, 12); next();});// Post-save middlewareUserSchema.post('save', function(doc) { console.log('User saved:', doc._id);});// Pre-find middleware -- the regex /^find/ matches find, findOne, findById, etc.// This is a query middleware (not document middleware), so 'this' refers to the query.UserSchema.pre(/^find/, function(next) { // Exclude inactive users by default -- acts like a global "soft delete" filter this.find({ isActive: { $ne: false } }); next();});// Pre-remove middleware -- useful for cascading deletes.// Caution: this only fires on document.remove(), NOT on Model.deleteMany()// or Model.findByIdAndDelete(). If you use those methods, this hook is silently skipped.UserSchema.pre('remove', async function(next) { // Delete all posts by this user await Post.deleteMany({ author: this._id }); next();});
Mongoose queries are chainable, so you can build complex queries step by step — like assembling a pipeline where each method refines the result set further.
// Query builder pattern -- each chained method returns the query,// so you can compose queries incrementallyconst users = await User .find({ role: 'user' }) .select('name email -_id') // Include name & email, exclude _id .sort('-createdAt') // Sort descending (the '-' prefix means DESC) .skip(10) // Skip first 10 results (for pagination) .limit(5) // Return at most 5 documents .populate('posts') // Replace ObjectId refs with full documents .lean(); // Return plain JS objects instead of Mongoose documents // .lean() is a significant performance win when you only need to read data -- // it skips hydrating full Mongoose document instances and their change tracking// Complex filtersconst results = await Product.find({ price: { $gte: 100, $lte: 500 }, category: { $in: ['electronics', 'computers'] }, $or: [ { stock: { $gt: 0 } }, { preorder: true } ]});// Text search (requires text index)await Product.createIndex({ name: 'text', description: 'text' });const searchResults = await Product.find( { $text: { $search: 'laptop gaming' } }, { score: { $meta: 'textScore' } }).sort({ score: { $meta: 'textScore' } });
The aggregation pipeline is MongoDB’s equivalent of SQL GROUP BY, HAVING, and complex analytical queries. It works like an assembly line: documents enter one end, pass through a series of processing stages, and the transformed results come out the other end. Each stage receives the output of the previous stage.
// Sales report by categoryconst salesReport = await Order.aggregate([ // Stage 1: Match orders from last month -- always filter early // to reduce the number of documents flowing through later stages { $match: { createdAt: { $gte: new Date('2024-01-01') } } }, // Unwind order items { $unwind: '$items' }, // Group by category { $group: { _id: '$items.category', totalRevenue: { $sum: { $multiply: ['$items.price', '$items.quantity'] } }, totalOrders: { $sum: 1 }, avgOrderValue: { $avg: { $multiply: ['$items.price', '$items.quantity'] } } } }, // Sort by revenue { $sort: { totalRevenue: -1 } }, // Format output { $project: { category: '$_id', totalRevenue: { $round: ['$totalRevenue', 2] }, totalOrders: 1, avgOrderValue: { $round: ['$avgOrderValue', 2] }, _id: 0 } }]);
Transactions let you group multiple database operations into a single atomic unit — either all operations succeed, or none of them do. This is critical for operations like placing an order, where you need to create the order AND decrement inventory AND charge the user. If any step fails, you want everything rolled back to a consistent state.Important: MongoDB transactions require a replica set (even for local development). If you are running a standalone mongod, transactions will fail. Use mongosh to initiate a replica set, or use MongoDB Atlas which provides one by default.
Indexes are like the index at the back of a textbook — instead of scanning every page (document) to find what you need, the database jumps directly to the right location. Without indexes, MongoDB performs a “collection scan” (reads every document), which gets painfully slow as your data grows.
// Single field index -- speeds up queries that filter or sort by email// 1 = ascending order, -1 = descendingUserSchema.index({ email: 1 });// Compound index -- covers queries that filter by userId AND sort by createdAt.// The order of fields matters: put fields you filter with equality first,// then range/sort fields second.OrderSchema.index({ userId: 1, createdAt: -1 });// Text index for full-text search -- only one text index per collectionProductSchema.index({ name: 'text', description: 'text' });// Unique index -- also enforces uniqueness at the database levelUserSchema.index({ email: 1 }, { unique: true });// TTL index (auto-delete after time) -- MongoDB automatically removes// documents once createdAt + expireAfterSeconds has passed.// Perfect for session tokens, temporary data, or log entries.SessionSchema.index({ createdAt: 1 }, { expireAfterSeconds: 3600 });
Index pitfall: Every index speeds up reads but slows down writes, because MongoDB must update all relevant indexes on every insert, update, or delete. Do not blindly add indexes on every field. Use explain() on your queries to see whether an index is actually being used, and remove unused indexes with db.collection.dropIndex().