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 } |
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.deletedAt: new Date() set, and a Mongoose pre-find hook filters it out of all normal queries automatically.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 });
});
Cascade Deletes — Cleaning Up Related Documents
// 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 }) |