Skip to main content

๐Ÿฆค Mongoose Schemas & Models

MongoDB happily stores whatever shape of document you throw at it โ€” which is freedom right up until it becomes chaos. Mongoose puts a friendly, structured layer on top: you describe the shape your data should have once, and every read and write is type-cast, validated, and easy to reason about. This lesson is where that structure begins.

Week 8 · Day 4 (Thursday: Mongoose ODM) · Lecture 1

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what an ODM is and why Mongoose sits between your app and MongoDB
  • Connect to MongoDB with modern mongoose.connect() and async/await
  • Define a Schema using the built-in SchemaTypes and field options
  • Compile a schema into a model and use it for full CRUD operations
  • Trace the schema โ†’ model โ†’ document relationship in your own code
  • Add instance methods, statics, and virtuals to enrich a model

Estimated Time: 70 minutes

Practice: Model a Book collection and run create/read/update/delete against it.

In This Lesson

Why Mongoose?

MongoDB is schema-less by design: one document in a collection can have ten fields and the next can have three, and the database won't complain. That flexibility is great for prototyping and terrible for a team that expects every user to have an email. Mongoose is an ODM โ€” an Object Data Modeling library โ€” that lets you declare the structure you want and then enforces it for you.

Think of Mongoose as a translator and a bouncer standing between your Node.js code and the database. As a translator, it converts your plain JavaScript objects into MongoDB documents and back, casting types along the way. As a bouncer, it checks every document against the rules you set before it lets anything into the collection.

flowchart LR A[Node.js App
plain JS objects] --> B[Mongoose ODM] B --> C[(MongoDB
documents)] B --> D[Type casting] B --> E[Validation] B --> F[Query building] B --> G[Middleware hooks]

Mongoose vs. the native driver

You can talk to MongoDB with the official driver directly. Mongoose builds on top of it and gives you structure, validation, and convenience for free.

ConcernNative driverMongoose
StructureYou enforce it by handDeclared once in a schema
ValidationRoll your ownBuilt-in + custom validators
Type castingManualAutomatic (e.g. "42" โ†’ 42)
Business logicScattered in app codeAttached to the model (methods, hooks)
Learning curveLowerSlightly higher, pays off fast

๐Ÿ“– A note on versions

This lesson uses Mongoose 7/8 with async/await. Legacy connect flags like useNewUrlParser and useUnifiedTopology are no longer needed โ€” they were removed as no-ops. If you see them in an old tutorial, just delete them.

Installing & Connecting

Install Mongoose from npm:

npm install mongoose

Then connect. In modern Mongoose the connection options object is optional โ€” a clean connection string is usually all you need. Keep the URI in an environment variable so credentials never live in your source code.

// db.js โ€” a small, reusable connection module
import mongoose from 'mongoose';
import 'dotenv/config';

export async function connectDB() {
  try {
    await mongoose.connect(process.env.MONGODB_URI);
    console.log('โœ… MongoDB connected');
  } catch (err) {
    console.error('โŒ MongoDB connection error:', err.message);
    process.exit(1); // fail fast โ€” the app is useless without its database
  }
}

// Surface connection lifecycle events (helpful in production):
mongoose.connection.on('disconnected', () => console.warn('MongoDB disconnected'));
mongoose.connection.on('reconnected', () => console.log('MongoDB reconnected'));

// Close the connection cleanly on shutdown:
process.on('SIGINT', async () => {
  await mongoose.connection.close();
  console.log('MongoDB connection closed โ€” exiting');
  process.exit(0);
});

๐Ÿ’ก One connection for the whole app

You call mongoose.connect() once at startup. Mongoose maintains a shared connection pool behind the scenes, so every model you define automatically uses it โ€” there's no need to pass a connection around.

Defining a Schema

A schema is a blueprint. It describes what fields a document has, what type each field is, and what rules those fields must obey. A schema by itself doesn't touch the database โ€” it's just a description.

import mongoose from 'mongoose';
const { Schema } = mongoose;

const userSchema = new Schema({
  firstName: { type: String, required: true, trim: true },
  lastName:  { type: String, required: true, trim: true },
  email: {
    type: String,
    required: true,
    unique: true,      // builds a unique INDEX (see the caution below)
    lowercase: true,   // normalises "ADA@X.com" โ†’ "ada@x.com" on save
    trim: true
  },
  age:      { type: Number, min: 18, max: 120 },
  isActive: { type: Boolean, default: true },
  birthDate: Date,                    // shorthand: a plain type with no options

  // A nested object:
  address: {
    street: String,
    city:   String,
    country: { type: String, default: 'USA' }
  },

  // An array of strings, and an array of sub-objects:
  hobbies: [String],
  education: [{ school: String, degree: String, year: Number }]
}, {
  timestamps: true   // auto-adds createdAt & updatedAt โ€” use this, don't hand-roll it
});

โš ๏ธ unique is NOT a validator

Despite living among required and min, unique: true does not validate anything. It tells MongoDB to build a unique index. If you insert a duplicate email you won't get a Mongoose ValidationError โ€” you'll get a MongoDB duplicate-key error (E11000), which you must catch separately. Also, the index only exists after MongoDB finishes building it; on a fresh collection, call await Model.syncIndexes() to be sure.

SchemaTypes & Options

Every field has a SchemaType that tells Mongoose how to cast and store the value. These are the ones you'll reach for constantly:

SchemaTypeStoresExample field
StringTextname: String
NumberInts & floatsprice: Number
Booleantrue / falseinStock: Boolean
DateTimestampspublishedAt: Date
ObjectIdA reference to another docauthor: { type: Schema.Types.ObjectId, ref: 'User' }
ArrayListstags: [String]
BufferBinary datathumbnail: Buffer
MixedAnything (no casting)meta: Schema.Types.Mixed

Common field options

The object after type configures the field. These appear everywhere:

const productSchema = new Schema({
  name:  { type: String, required: true, trim: true },
  slug:  { type: String, lowercase: true },
  price: { type: Number, required: true, min: 0, default: 0 },
  sku:   { type: String, immutable: true },          // can't change after creation
  role:  { type: String, enum: ['draft', 'live'], default: 'draft' },
  notes: { type: String, select: false }             // excluded from queries by default
});

Here's how those pieces fit together end to end: a schema definition is a collection of fields, each field is a SchemaType plus options, and the whole thing is what a model will enforce.

A schema is a set of typed, configured fields userSchema email: type: String ยท required ยท unique age: type: Number ยท min: 18 ยท max: 120 isActive: type: Boolean ยท default: true hobbies: [String] ยท an array field
Each field pairs a SchemaType with the options that govern it. The model built from this schema will enforce every one.

From Schema to Model

A schema is inert. To actually create, find, and update documents you compile the schema into a model with mongoose.model(). The model is a constructor bound to a MongoDB collection, and every document you make is an instance of that model.

// The model name 'User' โ†’ Mongoose pluralises & lowercases it to
// the collection 'users' automatically.
const User = mongoose.model('User', userSchema);

// One model, three concepts:
//   Schema   โ†’ the blueprint (userSchema)
//   Model    โ†’ the factory + query interface (User)
//   Document โ†’ a single instance (const u = new User({...}))

This three-step relationship is the mental model to lock in for the entire week:

graph TD A["Schema
(blueprint / rules)"] -->|"mongoose.model()"| B["Model
(collection interface)"] B -->|"new User(...) or User.create()"| C["Document
(one record)"] C -->|".save()"| D[(users collection)] B -->|"User.find()"| D

โš ๏ธ Compile each model only once

Call mongoose.model('User', schema) a single time per model, usually in its own file that you import everywhere else. Compiling the same model name twice throws OverwriteModelError. Export the compiled model, not the schema, from your model files.

CRUD with a Model

Once you have a model, the four basic operations โ€” Create, Read, Update, Delete โ€” are all one-liners. Every method returns a promise, so await it.

Create

// Option A: build then save (gives you the instance first)
const user = new User({ firstName: 'Ada', lastName: 'Lovelace', email: 'ada@x.com', age: 36 });
await user.save();

// Option B: create in one call (validates & saves)
const grace = await User.create({
  firstName: 'Grace', lastName: 'Hopper', email: 'grace@x.com', age: 40
});

Read

await User.find();                          // every user (an array)
await User.find({ isActive: true });        // filtered
await User.findById(id);                     // by _id, or null
await User.findOne({ email: 'ada@x.com' });  // first match, or null

// Chainable query building:
const adults = await User.find({ age: { $gte: 18 } })
  .select('firstName email')   // only these fields
  .sort({ firstName: 1 })      // Aโ†’Z
  .limit(10)
  .lean();                     // plain objects, faster (see best practices)

Update

// Find, mutate, save โ€” runs full document validation & hooks:
const u = await User.findById(id);
u.age = 41;
await u.save();

// Or update in place. Pass { new: true } to return the UPDATED doc,
// and { runValidators: true } to apply schema validation to the update.
const updated = await User.findByIdAndUpdate(
  id,
  { age: 41 },
  { new: true, runValidators: true }
);

Delete

await User.findByIdAndDelete(id);
await User.deleteOne({ email: 'ada@x.com' });
await User.deleteMany({ isActive: false });

What User.create() gives back

{
  _id: ObjectId("6650f1..."),
  firstName: "Grace",
  lastName: "Hopper",
  email: "grace@x.com",
  age: 40,
  isActive: true,          // filled from the schema default
  hobbies: [],
  createdAt: 2026-07-31T...,  // added by timestamps:true
  updatedAt: 2026-07-31T...,
  __v: 0
}

Methods, Statics & Virtuals

Because a model bundles behaviour with data, you can attach reusable logic right to the schema. There are three flavours, and beginners mix them up constantly โ€” so anchor them by who calls them.

Instance methods โ€” called on one document

userSchema.methods.getFullName = function () {
  return `${this.firstName} ${this.lastName}`; // 'this' = the document
};

const u = await User.findById(id);
u.getFullName();  // "Ada Lovelace"

Statics โ€” called on the model itself

userSchema.statics.findByEmail = function (email) {
  return this.findOne({ email }); // 'this' = the model
};

await User.findByEmail('ada@x.com');

Virtuals โ€” computed properties that aren't stored

A virtual is a field that is calculated on the fly and never written to MongoDB. Perfect for derived values like a full name.

userSchema.virtual('fullName').get(function () {
  return `${this.firstName} ${this.lastName}`;
});

// Virtuals are hidden from JSON by default โ€” opt them in on the schema:
const userSchema = new Schema({ /* ... */ }, {
  timestamps: true,
  toJSON:   { virtuals: true },
  toObject: { virtuals: true }
});

โœ… Method vs. static vs. virtual, in one line each

  • Instance method โ€” behaviour for a single record (doc.getFullName()).
  • Static โ€” a query helper on the whole collection (User.findByEmail()).
  • Virtual โ€” a read-only derived value that is never persisted (doc.fullName).

Always use a regular function (not an arrow) so this binds correctly.

Practice & Quiz

๐Ÿ‹๏ธ Exercise 1: Model a Book

Goal: Define a bookSchema and compile it into a Book model. A book has a required title (trimmed), a required author string, a pages number that must be at least 1, a genre restricted to a small list, an inStock boolean defaulting to true, and automatic timestamps.

๐Ÿ’ก Hint

Use required: true and trim: true on the title, min: 1 on pages, and an enum array on genre. Pass { timestamps: true } as the schema's second argument. Compile with mongoose.model('Book', bookSchema).

โœ… Solution
import mongoose from 'mongoose';
const { Schema } = mongoose;

const bookSchema = new Schema({
  title:   { type: String, required: true, trim: true },
  author:  { type: String, required: true },
  pages:   { type: Number, min: 1 },
  genre:   { type: String, enum: ['fiction', 'nonfiction', 'poetry', 'reference'] },
  inStock: { type: Boolean, default: true }
}, { timestamps: true });

const Book = mongoose.model('Book', bookSchema);
export default Book;

๐Ÿ‹๏ธ Exercise 2: Full CRUD run

Goal: Using the Book model, create a book, read it back, raise its page count, then delete it โ€” all with async/await.

โœ… Solution
// Create
const book = await Book.create({
  title: 'Structure and Interpretation of Computer Programs',
  author: 'Abelson & Sussman', pages: 657, genre: 'reference'
});

// Read
const found = await Book.findById(book._id);
console.log(found.title);

// Update (return the updated doc, run validators)
const updated = await Book.findByIdAndUpdate(
  book._id, { pages: 660 }, { new: true, runValidators: true }
);
console.log(updated.pages); // 660

// Delete
await Book.findByIdAndDelete(book._id);

๐ŸŽฏ Quick Quiz

Question 1: What does mongoose.model('User', userSchema) return?

Question 2: Which statement about unique: true is correct?

Question 3: You want a fullName derived from firstName and lastName but never stored in the database. You should use a:

Best Practices & Pitfalls

โœ… Do

  • Keep one model per file and import it wherever needed
  • Use { timestamps: true } instead of hand-managing createdAt/updatedAt
  • Pass { new: true, runValidators: true } to findByIdAndUpdate
  • Use .lean() for read-only queries that don't need document methods
  • Store your connection string in an environment variable

โŒ Don't

  • Rely on unique: true to reject duplicates gracefully โ€” catch E11000 yourself
  • Compile the same model name twice (causes OverwriteModelError)
  • Use arrow functions for methods/virtuals โ€” they break this
  • Forget that findByIdAndUpdate skips validators unless you ask for them

โš ๏ธ Mixed won't track changes

doc.meta = doc.meta || {};
doc.meta.views = 10;
doc.markModified('meta');  // required โ€” Mongoose can't detect changes on Mixed
await doc.save();

Because Mixed fields have no schema, Mongoose can't tell when they change. Call markModified() or the save silently does nothing.

Summary

๐ŸŽ‰ Key Takeaways

  • Mongoose is an ODM that adds structure, casting, and validation on top of MongoDB
  • A schema is the blueprint; mongoose.model() compiles it into a model; instances are documents
  • SchemaTypes (String, Number, Date, ObjectId, โ€ฆ) plus field options define each field
  • CRUD is one-liners: create, find/findById, findByIdAndUpdate, findByIdAndDelete
  • Methods, statics, and virtuals attach behaviour to your model
  • unique is an index, not a validator

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

Your schema can describe shape, but real apps need to reject bad data and run logic at key moments. Next up: Validation & Middleware โ€” built-in and custom validators, and the pre/post hooks that fire around save, find, and update.

๐ŸŽ‰ Schema mastered!

You can now model a collection, compile it into a model, and run full CRUD. Every Mongoose feature from here builds on exactly this.