Deleting Documents — deleteOne, deleteMany and Soft Deletes

Deleting data sounds simple — but in a production application it is one of the most consequential operations you can perform. A hard delete removes data permanently with no undo. For many MERN applications — particularly content platforms, e-commerce sites, and any system with audit requirements — a soft delete pattern is safer: you mark a document as deleted without actually removing it, which lets you recover accidentally deleted content, maintain audit trails, and satisfy data retention requirements. This lesson covers both approaches, when to use each, and how to implement them cleanly in Mongoose.

Delete Methods Overview

Method What It Does Returns
Model.deleteOne(filter) Delete first matching document permanently { deletedCount }
Model.deleteMany(filter) Delete all matching documents permanently { deletedCount }
Model.findByIdAndDelete(id) Delete by ID, return deleted document Deleted document or null
Model.findOneAndDelete(filter) Delete first match, return deleted document Deleted document or null
doc.deleteOne() Delete a specific fetched document { deletedCount: 1 }
Note: deleteOne() and deleteMany() return only a result object with deletedCount — they do not return the deleted documents. If you need to confirm what was deleted (for logging or to return in the API response), use findOneAndDelete() or findByIdAndDelete(), which return the deleted document before removal.
Tip: For blog posts, use soft delete rather than hard delete. Blog content may be referenced by other documents (comments, analytics, external links), and hard-deleting a post leaves those references dangling. With soft delete, the post remains in the database with deletedAt: new Date() set, and a Mongoose pre-find hook filters it out of all normal queries automatically.
Warning: deleteMany({}) with an empty filter deletes every document in the collection. This is the database equivalent of rm -rf /. Always test deleteMany() calls with a find() using the same filter first to confirm exactly which documents will be deleted. In production, add a check that the filter is not an empty object before calling deleteMany.

Hard Delete — Permanent Removal

// ── deleteOne — permanently delete by filter ──────────────────────────────────
const result = await Post.deleteOne({ _id: postId });
console.log(result.deletedCount); // 1 if found and deleted, 0 if not found

// ── findByIdAndDelete — delete by ID, return the deleted document ─────────────
const deleted = await Post.findByIdAndDelete(postId);
if (!deleted) throw new AppError('Post not found', 404);
// deleted is the document AS IT WAS before deletion

// ── doc.deleteOne() — delete a specific instance you already fetched ──────────
const post = await Post.findById(postId);
if (!post) throw new AppError('Post not found', 404);
// ... ownership check ...
await post.deleteOne(); // runs pre('deleteOne') hook if defined

// ── deleteMany — delete a group of documents ──────────────────────────────────
// Delete all draft posts older than 30 days
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const result = await Post.deleteMany({
  published: false,
  createdAt: { $lt: thirtyDaysAgo },
});
console.log(`Deleted ${result.deletedCount} old drafts`);

Soft Delete Pattern

// ── Step 1: Add deletedAt to the schema ───────────────────────────────────────
const postSchema = new mongoose.Schema({
  // ... other fields ...
  deletedAt: { type: Date, default: null }, // null = not deleted
}, { timestamps: true });

// ── Step 2: Add a query middleware to auto-filter deleted documents ────────────
// This pre-hook runs before EVERY find, findOne, findById, etc.
postSchema.pre(/^find/, function (next) {
  // 'this' is the Mongoose Query object
  // Only apply the filter if includeDeleted is not set
  if (!this.getOptions().includeDeleted) {
    this.where({ deletedAt: null });
  }
  next();
});

// ── Step 3: Soft delete method on the schema ──────────────────────────────────
postSchema.methods.softDelete = async function () {
  this.deletedAt = new Date();
  await this.save();
};

// ── Step 4: Usage in the controller ──────────────────────────────────────────
const deletePost = asyncHandler(async (req, res) => {
  const post = await Post.findById(req.params.id);
  if (!post) throw new AppError('Post not found', 404);
  if (post.author.toString() !== req.user.id && req.user.role !== 'admin') {
    throw new AppError('Not authorised', 403);
  }

  await post.softDelete(); // sets deletedAt — does NOT remove from database

  res.json({ success: true, message: 'Post deleted' });
});

// ── Step 5: Admin restore — clear deletedAt ──────────────────────────────────
const restorePost = asyncHandler(async (req, res) => {
  // Must use includeDeleted option to find soft-deleted documents
  const post = await Post.findById(req.params.id).setOptions({ includeDeleted: true });
  if (!post) throw new AppError('Post not found', 404);
  if (!post.deletedAt) throw new AppError('Post is not deleted', 400);

  post.deletedAt = null;
  await post.save();

  res.json({ success: true, message: 'Post restored', data: post });
});
// When a post is permanently deleted, also delete its comments
postSchema.post('findOneAndDelete', async function (doc) {
  if (doc) {
    // doc is the deleted post document
    await Comment.deleteMany({ post: doc._id });
    console.log(`Deleted comments for post ${doc._id}`);
  }
});

// Or handle it explicitly in the controller
const deletePost = asyncHandler(async (req, res) => {
  const post = await Post.findByIdAndDelete(req.params.id);
  if (!post) throw new AppError('Post not found', 404);

  // Clean up related data
  await Comment.deleteMany({ post: post._id });
  // If you have uploaded files, delete them too:
  // if (post.coverImage) await deleteFileFromStorage(post.coverImage);

  res.json({ success: true, message: 'Post and its comments deleted' });
});

Hard Delete vs Soft Delete — Decision Guide

Scenario Recommended Approach
User deletes their own blog post Soft delete — recoverable by admin
Admin permanently removes spam content Hard delete — intentional permanent removal
User deletes their account Soft delete with data anonymisation after 30 days
Temporary draft post cleaned up by cron Hard delete — no recovery needed
Test data in development Hard delete — clean slate for testing
Content with audit/legal requirements Soft delete (never truly delete)

Common Mistakes

Mistake 1 — Hard-deleting without cleaning up references

❌ Wrong — deleting a post without deleting its comments:

await Post.findByIdAndDelete(postId);
// Comments with postId still exist in the comments collection — orphaned data
// Any query to comments.find({ post: postId }) returns ghost comments

✅ Correct — always clean up related documents when hard deleting:

await Post.findByIdAndDelete(postId);
await Comment.deleteMany({ post: postId }); // ✓

Mistake 2 — Not checking deletedCount after deleteOne

❌ Wrong — assuming the document existed and was deleted:

await Post.deleteOne({ _id: postId });
res.json({ success: true, message: 'Post deleted' }); // may have deleted nothing

✅ Correct — check whether anything was actually deleted:

const result = await Post.deleteOne({ _id: postId });
if (result.deletedCount === 0) throw new AppError('Post not found', 404);
res.json({ success: true, message: 'Post deleted' }); // ✓

Mistake 3 — Forgetting the pre-find hook for soft deletes

❌ Wrong — implementing soft delete but forgetting to filter deleted documents from queries:

await Post.find({ published: true }); // also returns soft-deleted posts!

✅ Correct — the pre(/^find/) hook on the schema automatically adds { deletedAt: null } to all find queries, making soft-deleted documents invisible to normal queries.

Quick Reference

Task Code
Delete by ID (hard) await Post.findByIdAndDelete(id)
Delete first match await Post.deleteOne({ filter })
Delete all matching await Post.deleteMany({ filter })
Check deleted count result.deletedCount
Soft delete doc.deletedAt = new Date(); await doc.save()
Restore soft delete doc.deletedAt = null; await doc.save()
Find including deleted Model.find({}).setOptions({ includeDeleted: true })
Cascade delete await Comment.deleteMany({ post: postId })

🧠 Test Yourself

A blog platform wants users to be able to “delete” their posts but the admin should be able to restore accidentally deleted content within 30 days. Which approach should you implement?