How to Implement Soft Deletes and Data Retention Policies in MongoDB with Mongoose

How to Implement Soft Deletes and Data Retention Policies in MongoDB with Mongoose

by | Oct 28, 2026 | Uncategorized | 0 comments

Soft deleting a document sounds trivial: set a flag instead of removing the row. In practice, a mongoose soft delete implementation that survives production has to deal with query middleware gaps, aggregation pipelines that ignore your hooks, unique indexes that block re-registration, and a retention policy that actually erases data when GDPR says it must.

This guide is the MongoDB counterpart to our Prisma and PostgreSQL soft delete guide. Same philosophy, very different traps. Everything below runs on Mongoose 8+ and MongoDB 6/7/8, with no third-party plugin required (we still compare the popular ones at the end). A longer analysis is available for anyone who wants it.

Why soft delete at all?

  • Undo and recovery: users delete things by accident, support wants a one-click restore.
  • Referential sanity: an invoice that points to a removed customer should still render.
  • Audit trails: knowing when and by whom something was removed.
  • Legal holds: some records must survive a delete request until a retention window closes.

And when not to use it: security logs you must keep immutable, very high-churn collections (sessions, rate-limit counters, webhook payloads), and any case where a real erasure is legally mandatory with no retention window. Soft delete is a UX feature, not a compliance feature. We come back to that in the retention section.

mongodb database

deletedAt or isDeleted? Use the timestamp

A boolean tells you the state. A timestamp tells you the state and when it changed, which is exactly what a retention policy and a TTL index need.

Approach Filter query TTL purge possible Verdict
isDeleted: Boolean { isDeleted: false } No Avoid
deletedAt: Date | null { deletedAt: null } Yes Recommended
Both fields Either Yes Redundant, they drift out of sync

Always store an explicit null default rather than leaving the field missing. Partial indexes and TTL behaviour become far more predictable when the field always exists. For the wider picture, see Mongoose Delete Plugin.

Step 1: a reusable soft delete plugin

Do not copy the same three hooks into twelve schemas. Write one plugin and register it where needed.

// plugins/soft-delete.js
const mongoose = require('mongoose');

// Every query method that should hide deleted documents by default.
const READ_HOOKS = [
  'count',
  'countDocuments',
  'estimatedDocumentCount', // see the warning below
  'distinct',
  'find',
  'findOne',
  'findOneAndUpdate',
  'findOneAndReplace',
  'findOneAndDelete',
  'replaceOne',
  'update',
  'updateOne',
  'updateMany'
];

module.exports = function softDeletePlugin(schema, options = {}) {
  const { trackUser = true } = options;

  schema.add({
    deletedAt: { type: Date, default: null },
    ...(trackUser ? { deletedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null } } : {})
  });

  // 1. Hide soft-deleted documents from every read and write query.
  schema.pre(READ_HOOKS, function () {
    if (this.getOptions().withDeleted === true) return;
    if (this.getOptions().onlyDeleted === true) {
      this.where({ deletedAt: { $ne: null } });
      return;
    }
    this.where({ deletedAt: null });
  });

  // 2. Aggregations do not go through query middleware, so patch them too.
  schema.pre('aggregate', function () {
    if (this.options && this.options.withDeleted === true) return;
    const first = this.pipeline()[0] || {};
    if (first.$geoNear || first.$search || first.$vectorSearch) {
      // Those stages must stay first: append the filter right after.
      this.pipeline().splice(1, 0, { $match: { deletedAt: null } });
      return;
    }
    this.pipeline().unshift({ $match: { deletedAt: null } });
  });

  // 3. Explicit, readable API instead of hijacking deleteOne().
  schema.methods.softDelete = function (userId = null) {
    this.deletedAt = new Date();
    if (trackUser) this.deletedBy = userId;
    return this.save();
  };

  schema.methods.restore = function () {
    this.deletedAt = null;
    if (trackUser) this.deletedBy = null;
    return this.save();
  };

  schema.statics.softDeleteOne = function (filter, userId = null) {
    return this.updateOne(filter, { $set: { deletedAt: new Date(), deletedBy: userId } });
  };

  schema.statics.softDeleteMany = function (filter, userId = null) {
    return this.updateMany(filter, { $set: { deletedAt: new Date(), deletedBy: userId } });
  };

  schema.statics.restoreMany = function (filter) {
    return this.updateMany(
      { ...filter, deletedAt: { $ne: null } },
      { $set: { deletedAt: null, deletedBy: null } },
      { withDeleted: true }
    );
  };
};

Register it on a schema:

const softDeletePlugin = require('./plugins/soft-delete');

const userSchema = new mongoose.Schema({
  email: { type: String, required: true, lowercase: true, trim: true },
  name: String,
  orgId: { type: mongoose.Schema.Types.ObjectId, ref: 'Org', required: true }
}, { timestamps: true });

userSchema.plugin(softDeletePlugin);

module.exports = mongoose.model('User', userSchema);

You can also apply it globally with mongoose.plugin(softDeletePlugin), but that is rarely what you want: logs, jobs and outbox collections should keep hard deletes.

Why not override deleteOne() and deleteMany()?

Some plugins make Model.deleteOne() silently perform an update. It is convenient until a junior developer reads the code and believes the document is gone, or until you genuinely need a hard delete during a GDPR purge. Our recommendation: keep deleteOne and deleteMany meaning hard delete, and expose softDelete() / softDeleteMany(). Explicit beats magic in the one part of your code base that touches user data destruction.

If you still want the magic, register a query hook with the correct signature (Mongoose 7+ treats deleteOne as document middleware by default):

schema.pre('deleteOne', { document: false, query: true }, function (next) {
  next(new Error('Use softDelete() or pass { hard: true } explicitly.'));
});
mongodb database

Step 2: opting out of the filter

Admin panels, restore screens and purge jobs need to see deleted rows. Query options are the cleanest escape hatch because they travel with the query object.

// Everything, including deleted
await User.find({ orgId }).setOptions({ withDeleted: true });

// Only deleted (recycle bin view)
await User.find({ orgId }).setOptions({ onlyDeleted: true });

// Aggregation opt-out
await User.aggregate([{ $group: { _id: '$orgId', total: { $sum: 1 } } }])
  .option({ withDeleted: true });

Pitfall: estimatedDocumentCount() uses collection metadata and cannot accept a filter. If you keep it in the hook list, Mongoose will throw when a filter is applied. Either remove it from READ_HOOKS and document that it counts deleted documents too, or ban it in code review in favour of countDocuments().

Step 3: aggregation pipelines, the number one leak

Query middleware never fires for Model.aggregate(), and the pre('aggregate') hook above only protects the root collection. Sub-pipelines are on you.

$lookup

// WRONG: pulls in soft-deleted users
{ $lookup: { from: 'users', localField: 'ownerId', foreignField: '_id', as: 'owner' } }

// RIGHT: filter inside the sub-pipeline
{
  $lookup: {
    from: 'users',
    let: { ownerId: '$ownerId' },
    pipeline: [
      { $match: { $expr: { $eq: ['$_id', '$$ownerId'] }, deletedAt: null } }
    ],
    as: 'owner'
  }
}

Other stages that bypass your hook

Operation Middleware applied? Fix
find / findOne / updateMany Yes Nothing to do
populate() Yes (it runs a find) Handle null refs in your UI
aggregate() root Only with pre(‘aggregate’) Unshift a $match stage
$lookup / $graphLookup / $unionWith No Add deletedAt: null inside the sub-pipeline or restrictSearchWithMatch
bulkWrite() No Add the filter to each operation manually
Change streams / raw driver collection() No Filter on deletedAt yourself
Atlas Search ($search) No Add a compound filter clause in the $search stage

For $graphLookup, use restrictSearchWithMatch: { deletedAt: null }, which is the only supported filter there.

mongodb database

Step 4: unique indexes and soft-deleted documents

Classic bug report: “a user deleted their account and now cannot sign up again with the same email”. Your unique index still sees the soft-deleted document.

The clean MongoDB answer is a partial unique index:

// Only living documents participate in the uniqueness constraint
userSchema.index(
  { orgId: 1, email: 1 },
  { unique: true, partialFilterExpression: { deletedAt: null } }
);

Points to watch:

  1. Remove unique: true from the field definition, otherwise Mongoose builds a second, non-partial unique index that reintroduces the bug.
  2. Drop the old index in production before building the new one. autoIndex does not modify an existing index with different options, it just fails silently in the logs.
  3. Because the filter is an equality on null, the field must always exist. That is why the plugin sets default: null.
  4. A partial index is only used by queries whose filter is compatible with it. Since your middleware always injects deletedAt: null, your normal reads stay covered, but admin queries using withDeleted will not use it.

Alternative when partial indexes are not an option (for example a legacy compound key used by other systems): tombstone the natural key at soft delete time, moving email to email_deleted_1727337600 and keeping the original in an archivedEmail field. It is uglier but it also removes the personal data from the searchable field, which auditors like.

Step 5: retention policies and TTL purges

Soft delete alone is not compliant with a right-to-erasure request. GDPR expects data to disappear after a defined, documented retention window. The good news: deletedAt is exactly the anchor a TTL index needs.

Option A: TTL index (simple, hands-off)

// Purge soft-deleted users 30 days after deletion
userSchema.index(
  { deletedAt: 1 },
  {
    expireAfterSeconds: 60 * 60 * 24 * 30,
    partialFilterExpression: { deletedAt: { $type: 'date' } },
    name: 'deletedAt_ttl_30d'
  }
);

What you need to know about TTL indexes:

  • The background task runs roughly every 60 seconds, so deletion is “within a minute or so after expiry”, not instant.
  • Documents with deletedAt: null are never expired, which is why the partial filter on $type: 'date' keeps the index small and explicit.
  • You cannot have two indexes on { deletedAt: 1 } with different options, so do not also write index: true on the field.
  • TTL deletes are real deletes performed by the server: no Mongoose middleware fires, no cascade, no audit entry, no S3 cleanup.
  • Changing the window later is an collMod operation, not a schema redeploy. Plan a migration script.

Option B: scheduled purge job (auditable, cascade-friendly)

When deletion has side effects (files in object storage, rows in another service, an audit log to write), run your own job and keep TTL as a safety net only.

// jobs/purge-deleted.js
const RETENTION_DAYS = Number(process.env.RETENTION_DAYS || 30);

async function purgeDeletedUsers() {
  const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000);

  const batch = await User.find({ deletedAt: { $ne: null, $lte: cutoff } })
    .setOptions({ withDeleted: true })
    .limit(500)
    .lean();

  for (const user of batch) {
    await deleteAvatarFromStorage(user._id);
    await Session.deleteMany({ userId: user._id });
    await AuditLog.create({ action: 'gdpr.purge', subjectId: user._id, at: new Date() });
    await User.deleteOne({ _id: user._id }); // real delete
  }

  return batch.length;
}

Run it with node-cron, Agenda, BullMQ or a Kubernetes CronJob, and make it idempotent and batched so a long backlog cannot lock up your cluster.

Option C: anonymise instead of delete

Financial records often must be kept for 7 or 10 years even after an erasure request. The compliant pattern is to strip the personal identifiers and keep the row:

await User.updateOne({ _id }, {
  $set: {
    email: `anon-${_id}@invalid.local`,
    name: 'Deleted user',
    phone: null,
    anonymizedAt: new Date()
  },
  $unset: { address: '', ipAddress: '' }
}, { withDeleted: true });

Choosing a retention window

Data type Typical window after soft delete Mechanism
User content (docs, projects) 30 days recycle bin TTL index
User accounts with files 30 to 90 days Cron job + cascade
Invoices, accounting Legal minimum (often 7 to 10 years) Anonymisation only
Sessions, tokens, logs Hours to 90 days TTL index, no soft delete
mongodb database

Step 6: cascading soft deletes

MongoDB has no foreign keys, so cascading is application logic. Two workable patterns:

  • Explicit cascade in a transaction (replica set required): soft delete the parent and its children in one session so you never end up half deleted.
  • Tenant-scoped filter: instead of stamping every child, resolve the parent state at query time. Cheaper to write, more expensive to read, and it breaks as soon as a child is queried directly.
const session = await mongoose.startSession();
await session.withTransaction(async () => {
  const now = new Date();
  await Project.updateOne({ _id: projectId }, { $set: { deletedAt: now, deletedBy: userId } }, { session });
  await Task.updateMany({ projectId }, { $set: { deletedAt: now, deletedBy: userId } }, { session });
  await Comment.updateMany({ projectId }, { $set: { deletedAt: now, deletedBy: userId } }, { session });
});
session.endSession();

Stamping children with the same timestamp has a bonus: your restore operation can target { projectId, deletedAt: now } and bring back exactly what was removed, not items deleted earlier.

Performance and indexing notes

  • Every normal query now carries deletedAt: null. Put deletedAt into your existing compound indexes, or better, make those indexes partial with partialFilterExpression: { deletedAt: null } so they only index live documents.
  • Check with explain('executionStats') that totalKeysExamined did not explode after adding the filter.
  • If more than half of a collection becomes soft deleted, consider moving old records to an archive collection instead of growing the working set.
  • $search and $vectorSearch indexes on Atlas keep returning deleted documents unless you add a filter clause inside the stage, and re-index when deletedAt changes.
mongodb database

Custom plugin or npm package?

Option Pros Cons
Custom plugin (this article) ~60 lines, no dependency, you control hooks, aggregation and TTL You own the edge cases and the tests
mongoose-delete Mature, deletedBy and deletedAt support, overridden static methods Boolean-first model, no aggregation hook, no retention story
soft-delete-plugin-mongoose TypeScript typings, restore helpers Separate API to learn, aggregations still bypass it
@stenneepro/mongoose-soft-delete Feels native, deleteOne becomes a soft delete Implicit behaviour is dangerous during real GDPR purges

Whatever you pick, none of them solves unique indexes, aggregation sub-pipelines or retention for you. Those three pieces are yours. This reference is the one worth keeping handy.

Testing checklist before you ship

  1. find, findById, countDocuments and distinct all hide soft-deleted documents.
  2. populate() returns null for a soft-deleted reference and your UI handles it.
  3. An aggregation with $lookup does not leak deleted documents.
  4. Deleting and re-creating a record with the same unique key succeeds.
  5. updateMany cannot accidentally resurrect or modify deleted documents.
  6. Restore brings back the record and its children with the same timestamp.
  7. Fast-forward test: set deletedAt older than the retention window and assert the purge job removes the document and its side artefacts.
  8. The TTL index exists in production with the expected expireAfterSeconds (check db.users.getIndexes() after deploy).

FAQ

Does Mongoose have a built-in soft delete?

No. Mongoose provides the building blocks (schema plugins, query middleware, aggregation hooks) but there is no native softDelete(). You either write the 60-line plugin shown above or install a community package.

Will query middleware also filter populate() calls?

Yes. populate() executes a find under the hood, so your pre('find') hook applies and soft-deleted references resolve to null or are omitted from arrays. Make sure your serializers tolerate that.

Why does my unique index still reject the email of a deleted user?

Because the standard unique index covers every document in the collection. Replace it with a partial unique index filtered on deletedAt: null, and remember to drop the previous index manually, since Mongoose will not recreate an existing index with new options.

Does a TTL index trigger Mongoose hooks or cascades?

No. The TTL monitor deletes documents server side, bypassing the driver entirely. If you need cascades, audit entries or file cleanup, run a scheduled purge job and keep the TTL index only as a backstop.

Is a soft delete enough for a GDPR erasure request?

No. Hiding a record is not erasing it. A soft delete is acceptable as a short, documented grace period, after which the data must be hard deleted or anonymised. Document the window, automate the purge, and log the purge event.

How do I query deleted documents for an admin recycle bin?

Use the escape hatch built into the plugin: Model.find(filter).setOptions({ onlyDeleted: true }) for the bin, and { withDeleted: true } when an admin needs the full picture. Keep those options behind a permission check.

Wrapping up

A robust mongoose soft delete is three layers, not one: a deletedAt timestamp, middleware that filters reads and aggregations by default, and a retention policy that genuinely removes data when the window closes. Add partial unique indexes and an explicit API, and you get recoverability without turning your database into an unpurgeable archive.

Need a review of your MongoDB data model, your retention policy or your GDPR deletion flows? The Box Software team builds and audits Node.js and MongoDB backends. Get in touch and we will look at it with you.