Tutorials Logic, IN info@tutorialslogic.com

Mongoose ODM Schema, Models, CRUD

What is Mongoose?

Mongoose adds schemas, casting, validation, middleware, change tracking, and model-based queries to the MongoDB Node.js driver.

A schema is an application contract, not a replacement for database indexes, authorization, or validation performed by other writers.

Production code should reuse connections, project only needed fields, inspect query plans, and use transactions only for invariants spanning multiple operations.

Mongoose is an Object Data Modeling (ODM) library for MongoDB and Node.js. It provides a schema-based solution to model your application data, with built-in type casting, validation, query building, and business logic hooks. Think of it as an ORM for MongoDB.

Use Mongoose when document lifecycle behavior and a consistent application model justify the abstraction. Use the MongoDB driver directly when a thin data-access layer, driver-first features, or minimum abstraction is more important. Both approaches still require MongoDB data modeling and index design.

  • Schemas cast and validate values written through the model.
  • Models bind a schema to a MongoDB collection and expose query operations.
  • Documents are hydrated model instances with change tracking and save behavior.
  • Queries are thenable builders; execute or await them once the filter, projection, sort, and limit are complete.

Installation and Connection

Install Mongoose with npm install mongoose, load the connection string from environment or secret configuration, and connect once during application startup. mongoose.connect uses the default connection and an internal driver pool; opening a connection for each request adds latency and exhausts sockets.

Modern Mongoose does not need the former useNewUrlParser or useUnifiedTopology options. Set bounded server-selection and operation timeouts that match the service contract. Listen for connection errors after startup and close the pool during graceful process shutdown.

  • Keep credentials out of source code and redact connection strings from logs.
  • Use separate connections only when databases, credentials, tenancy, or lifecycle ownership truly differ.
  • Fail readiness while the required database is unavailable; do not advertise a healthy service that cannot satisfy requests.

Connect Once and Shut Down Cleanly

Connect Once and Shut Down Cleanly
const mongoose = require("mongoose")

async function connectDatabase() {
  mongoose.connection.on("error", error => {
    console.error("MongoDB connection error", error)
  })

  await mongoose.connect(process.env.MONGODB_URI, {
    serverSelectionTimeoutMS: 5000,
    maxPoolSize: 20
  })

  console.log("MongoDB connected")
}

async function shutdown(signal) {
  console.log(`Closing MongoDB after ${signal}`)
  await mongoose.disconnect()
  process.exit(0)
}

process.on("SIGTERM", () => shutdown("SIGTERM"))
process.on("SIGINT", () => shutdown("SIGINT"))

connectDatabase().catch(error => {
  console.error("Initial connection failed", error)
  process.exit(1)
})
Output
MongoDB connected, followed by Closing MongoDB after SIGTERM or SIGINT during graceful shutdown.

The process creates one pooled default connection, applies a bounded selection timeout, and disconnects on normal termination signals.

Defining Schemas and Models

Define the document shape, required values, defaults, enums, and application-level validators in a Schema. Casting happens before validation. Validation runs before save middleware by default, but update operations need runValidators: true when their changed values should be checked.

unique is shorthand for creating a unique index; it is not a validator and duplicate-key failures come from MongoDB. Build and monitor indexes deliberately in deployment. Mongoose validation also cannot repair documents written directly by another client, so use MongoDB schema validation when every writer must obey a database-level rule.

  • Use immutable fields for values the application must not change after creation.
  • Prefer explicit subdocuments when nested data has its own validation or middleware.
  • Avoid Schema.Types.Mixed unless the loss of casting and change detection is intentional.
  • Normalize a value only when the domain contract permits that transformation.

Schema Definition with Types and Options

Schema Definition with Types and Options
const { Schema, model } = require("mongoose")

const userSchema = new Schema({
  name: {
    type: String,
    required: [true, "Name is required"],
    minlength: 2,
    maxlength: 100,
    trim: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    match: [/^\S+@\S+\.\S+$/, "Invalid email format"]
  },
  age: {
    type: Number,
    min: [0, "Age cannot be negative"],
    max: [150, "Age seems too high"]
  },
  role: {
    type: String,
    enum: ["admin", "editor", "user"],
    default: "user"
  },
  active: {
    type: Boolean,
    default: true
  },
  hobbies: [String],
  address: {
    street: String,
    city: String,
    zip: String
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
}, {
  timestamps: true   // auto-adds createdAt and updatedAt
})

// Create the model (maps to "users" collection)
const User = model("User", userSchema)
module.exports = User
Output
Valid documents can be saved as users; missing required fields or invalid enum, range, and pattern values produce validation errors.

The schema combines casting and validation rules, while the model supplies the collection-facing API. The unique email option requires a database index and duplicate-key error handling.

CRUD with Mongoose

Build filters from allow-listed request fields and pass values as data, not executable query fragments. Apply projection, sort, a stable tie-breaker, and a bounded limit before awaiting a list query. Offset pagination is simple for small result sets; keyset pagination scales better for deep, changing collections.

findOneAndUpdate is a database update operation and returns the pre-update document unless returnDocument: "after" or the supported Mongoose equivalent is requested. Include runValidators: true where update validation is required. Check the returned value because no matching document is a normal outcome, not an exception.

  • Use select to avoid returning secrets and large unused fields.
  • Inspect explain("executionStats") when a production query scans far more documents than it returns.
  • Treat deleteMany and broad updates as privileged operations with explicit filters and audit evidence.
  • Handle duplicate-key, validation, timeout, and transient transaction errors separately.

Create, Read, Update, Delete with Mongoose

Create, Read, Update, Delete with Mongoose
// CREATE
const alice = new User({ name: "Alice", email: "alice@example.com", age: 29 })
await alice.save()

// Or use create() shorthand
const bob = await User.create({ name: "Bob", email: "bob@example.com", age: 34 })

// READ
const allUsers = await User.find()
const activeUsers = await User.find({ active: true }).sort({ name: 1 }).limit(10)
const alice = await User.findOne({ email: "alice@example.com" })
const userById = await User.findById("64a1f2c3e4b0a1b2c3d4e5f6")

// Projection - select specific fields
const names = await User.find({}, "name email -_id")

// UPDATE
await User.findByIdAndUpdate(
  "64a1f2c3e4b0a1b2c3d4e5f6",
  { $set: { age: 30 } },
  { new: true, runValidators: true }  // new: true returns updated doc
)

await User.updateMany({ role: "user" }, { $set: { active: false } })

// DELETE
await User.findByIdAndDelete("64a1f2c3e4b0a1b2c3d4e5f6")
await User.deleteMany({ active: false })
Output
Each awaited operation returns a document, document list, update result, deletion result, or null according to the selected model method.

The examples show model-level CRUD. Real endpoints also need allow-listed filters, projection, bounded pagination, authorization, and distinct handling for a missing match.

Virtuals, Middleware, and Populate

Virtuals compute values that are not stored. Document middleware attaches behavior to lifecycle operations such as save, while query middleware observes query operations. Middleware registration order matters: add hooks before compiling the model, and do not assume save middleware runs for findOneAndUpdate.

populate resolves referenced documents with additional query work. Select only required fields and watch for unbounded fan-out. Embedding is often better for small data read with its parent; references fit independently changing entities. Aggregation $lookup may be clearer for server-side pipelines.

  • Use a normal function, not an arrow, when middleware or a virtual needs Mongoose to bind this.
  • Keep hooks deterministic and avoid hidden network work that surprises every save.
  • Do not return password hashes or private fields through populate projections.
  • Test document saves and query updates separately because they trigger different middleware paths.

Virtuals, Pre/Post Hooks, and populate()

Virtuals, Pre/Post Hooks, and populate()
// VIRTUAL - computed property not stored in DB
userSchema.virtual("fullName").get(function() {
  return `${this.firstName} ${this.lastName}`
})

// PRE HOOK - runs before save
userSchema.pre("save", async function(next) {
  if (this.isModified("password")) {
    this.password = await bcrypt.hash(this.password, 12)
  }
  next()
})

// POST HOOK - runs after save
userSchema.post("save", function(doc) {
  console.log("User saved:", doc._id)
})

// POPULATE - resolve references to other collections
const orderSchema = new Schema({
  userId: { type: Schema.Types.ObjectId, ref: "User" },
  total: Number
})
const Order = model("Order", orderSchema)

// Populate userId with the full User document
const orders = await Order.find().populate("userId", "name email")
// orders[0].userId is now the full User object, not just an ObjectId
Output
A populated order exposes the selected user name and email while the stored order still contains a user ObjectId reference.

The virtual is computed, save middleware runs around document persistence, and populate replaces selected ObjectId references with projected User documents.

Mongoose Schema Types Reference

Mongoose Schema Types Reference
// Mongoose Schema Types:
// String, Number, Date, Buffer, Boolean, Mixed, ObjectId, Array, Decimal128, Map

// Common field options:
// type        - data type
// required    - true or [true, "error message"]
// default     - default value or function
// unique      - creates a unique index
// index       - creates an index
// sparse      - sparse index
// min/max     - for Number and Date
// minlength/maxlength - for String
// enum        - array of allowed values
// match       - regex pattern for String
// trim        - remove whitespace
// lowercase   - convert to lowercase
// uppercase   - convert to uppercase
// immutable   - cannot be changed after creation
// validate    - custom validator function

// Example with custom validator
score: {
  type: Number,
  validate: {
    validator: (v) => v >= 0 && v <= 100,
    message: "Score must be between 0 and 100"
  }
}
Output
Values outside the custom score range fail Mongoose validation before a document save.

Schema types control casting, while field options apply validation, indexing, normalization, defaults, or immutability. Each option should represent an actual domain rule.

Lean Queries, Sessions, and Transactions

A normal query hydrates Mongoose Documents with getters, virtuals, change tracking, validation, and save methods. Add lean to read-only queries that only need plain JavaScript objects. Lean usually reduces allocation, but it intentionally skips document behavior; do not call save on the result or assume getters, defaults, and virtuals were applied.

Use a transaction when one business invariant spans multiple documents or collections and cannot be expressed with a single atomic document update. Pass the session to every participating operation and use connection.transaction or withTransaction so commit, abort, and eligible retries are handled correctly. Avoid parallel operations such as Promise.all inside one transaction.

Transactions add latency and operational requirements. Model data so one document contains values that must change atomically when practical. For counters and conditional transitions, use atomic update operators with a filter that represents the expected current state.

  • Use lean for projected read responses, not for code that relies on document methods or virtuals.
  • Keep transaction callbacks idempotent because a transient failure may cause a retry.
  • Pass the same session through repositories instead of reading outside the transaction accidentally.
  • Test rollback, duplicate-key, timeout, and process-shutdown behavior, not only the commit path.
Before you move on

Mongoose ODM Schema, Models, CRUD Mastery Check

5 checks
  • I can distinguish a Schema, Model, Document, Query, and MongoDB collection.
  • I can connect once, configure bounded timeouts, and close the pool during shutdown.
  • I know that unique creates an index rather than a Mongoose validator.
  • I can choose between hydrated documents and lean plain objects.
  • I can explain when embedding, references, populate, aggregation, or a transaction fits the data invariant.

MongoDB Questions Learners Ask

It is the class-like interface used to create and query documents for one schema.

No. It validates writes through Mongoose, not documents inserted by other clients.

Opening a new connection per request wastes sockets and adds latency.

Browse Free Tutorials

Explore 500+ free tutorials across 20+ languages and frameworks.