Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 099 / 100

Soft deletes in MongoDB: real-world lessons and examples

Recoverable deletes.

Expona Admin7 min read

We've all faced that moment: a user requests their data be deleted, or a business requirement changes and you need to recover information that was supposed to be gone. Hard deletes feel clean in theory, but they break audit trails, complicate recoveries, and can violate compliance rules. Soft deletes in MongoDB offer a pragmatic middle ground—keep the data but mark it as deleted—and this article walks through real-world lessons and examples for handling soft deletes effectively.

The case for soft deletes: when removal isn't really removal

In production systems, permanent deletion is often a luxury we cannot afford. Financial records, user activity logs, and order histories need to remain intact for audits, chargebacks, and investigations. A soft delete strategy marks records as inactive without removing them from the database, preserving referential integrity and enabling recovery if a mistake occurs.

The simplest approach is adding a deletedAt field to your documents. When a user or process initiates a delete, you set deletedAt to the current timestamp instead of removing the record. This approach scales across collections, works well with MongoDB's flexible schema, and gives you a timestamp for compliance reporting.

Consider an e-commerce order management system. Rather than removing an order when a customer requests deletion, you record when it was deleted and filter it from active queries. This keeps your revenue reporting intact, allows customer service to investigate disputes, and satisfies GDPR-style "right to be forgotten" requests by deferring actual removal to a compliance worker after retention periods expire.

Add deletedAt instead of removing

The foundation of soft deletes is straightforward: replace the delete operation with an update that sets a timestamp. Here's how it looks in practice:

javascript

// Hard delete (bad for production)
await ordersCollection.deleteOne({ _id: orderId });

// Soft delete (better)
await ordersCollection.updateOne(
  { _id: orderId },
  { $set: { deletedAt: new Date() } }
);

When you need to retrieve active documents, filter them out:

javascript

// Retrieve active orders only
const activeOrders = await ordersCollection
  .find({ deletedAt: { $exists: false } })
  .toArray();

// Or explicitly check for null
const activeOrders = await ordersCollection
  .find({ deletedAt: null })
  .toArray();

The deletedAt field serves multiple purposes. It acts as a boolean flag (presence or absence), provides an audit trail (when was this deleted?), and enables time-based cleanup policies (delete permanently after 90 days). Many teams store the ID of the user who triggered the delete as well:

javascript

await ordersCollection.updateOne(
  { _id: orderId },
  { 
    $set: { 
      deletedAt: new Date(),
      deletedBy: userId,
      deletionReason: "user_request"
    } 
  }
);

This enriched metadata proves invaluable during incident response. If a data integrity issue surfaces, you can track which deletes happened when and by whom, then decide whether to restore them.

Partial indexes on active documents

Here's where soft deletes get expensive: if you're always filtering out deleted records, you're scanning documents you don't need. A partial index solves this by indexing only active (non-deleted) documents, making queries faster and reducing index storage.

Create a partial index like this:

javascript

await ordersCollection.createIndex(
  { customerId: 1 },
  { 
    partialFilterExpression: { 
      deletedAt: { $exists: false } 
    } 
  }
);

MongoDB will only index orders where deletedAt does not exist. When you query for a customer's active orders, the index covers the query efficiently:

javascript

// This query uses the partial index
const customerOrders = await ordersCollection
  .find({ 
    customerId: customerId, 
    deletedAt: { $exists: false } 
  })
  .toArray();

The performance gain compounds as your collection grows. A e-commerce platform with millions of orders sees query latency drop dramatically because the index is smaller and more focused. Without a partial index, the database wastes effort indexing rows that are never queried.

Create partial indexes on every query pattern you use for active records: customer lookups, status filtering, date ranges. For composite queries, combine filters:

javascript

// Partial index for active orders by customer and status
await ordersCollection.createIndex(
  { customerId: 1, status: 1 },
  { 
    partialFilterExpression: { 
      deletedAt: { $exists: false } 
    } 
  }
);

Filter deleted documents in a data layer

Repeating the soft-delete filter across your application is error-prone. The team forgets it in one query, returns deleted data, and the bug surfaces in production. The solution is centralizing the filter in your data access layer.

Create a helper function that wraps common queries:

javascript

class OrderRepository {
  async findActiveById(orderId) {
    return await ordersCollection.findOne({
      _id: orderId,
      deletedAt: { $exists: false }
    });
  }

  async findByCustomer(customerId) {
    return await ordersCollection
      .find({
        customerId: customerId,
        deletedAt: { $exists: false }
      })
      .toArray();
  }

  async softDelete(orderId, userId) {
    return await ordersCollection.updateOne(
      { _id: orderId },
      {
        $set: {
          deletedAt: new Date(),
          deletedBy: userId
        }
      }
    );
  }

  async restore(orderId) {
    return await ordersCollection.updateOne(
      { _id: orderId },
      { $unset: { deletedAt: "", deletedBy: "" } }
    );
  }
}

This repository encapsulates the soft-delete logic. Your business logic never calls find() directly; it uses findActiveById(), which automatically excludes deleted records. A developer new to the codebase doesn't need to remember the filter—the interface enforces it.

For more complex scenarios, build query helpers that compose filters:

javascript

const activeFilter = { deletedAt: { $exists: false } };

async function findOrdersByStatusAndCustomer(customerId, status) {
  return await ordersCollection
    .find({
      ...activeFilter,
      customerId,
      status
    })
    .toArray();
}

If your codebase uses an ODM like Mongoose, you can attach middleware to apply the filter automatically:

javascript

orderSchema.query.active = function() {
  return this.where({ deletedAt: { $exists: false } });
};

// Usage: Order.find({...}).active()

The data layer is your contract. Keep it consistent, and you eliminate entire categories of soft-delete bugs.

TTL to purge after a period

Soft deletes are not permanent. Eventually, compliance regulations (GDPR, HIPAA) require you to purge data after a retention period. MongoDB's TTL (time-to-live) index automates this cleanup without requiring scheduled jobs.

Set up a TTL index on deletedAt:

javascript

// Automatically remove documents 90 days after soft delete
await ordersCollection.createIndex(
  { deletedAt: 1 },
  { expireAfterSeconds: 7776000 } // 90 days
);

MongoDB runs a background cleanup process (usually once a minute) and removes documents where deletedAt is older than the specified duration. No cron jobs, no manual cleanup—the database handles it.

Be careful with TTL indexes: they only trigger on fields that exist. If you use deletedAt: null to mark active records, the TTL index won't work. Stick with { $exists: false } for active records and only set deletedAt when deleting.

For fine-grained retention policies, store retention metadata in the document:

javascript

await ordersCollection.updateOne(
  { _id: orderId },
  {
    $set: {
      deletedAt: new Date(),
      retentionDays: 90, // Configurable per order
      deletedBy: userId
    }
  }
);

Then calculate the actual purge time in your application logic or use a scheduled job. TTL indexes with variable expiry require more infrastructure, but they let you honor different retention rules for different data.

Common mistakes

Forgetting the filter in one query. A developer queries find({ status: "pending" }) without filtering deleted records, and the API returns ghost data. Prevent this by centralizing queries in a repository layer and requiring code review for any direct collection access.

Using deletedAt: null instead of { $exists: false }. If you set deletedAt: null on active records, TTL indexes won't trigger, and the query filter becomes less efficient. Reserve null for explicit "no value" states; use field absence for active records.

Missing partial indexes. Partial indexes are optional in small collections but essential at scale. Skipping them means your active queries degrade as the soft-deleted dataset grows. Always index based on your query patterns, not just on the soft-delete flag.

No audit trail. Storing only deletedAt without deletedBy or deletionReason hampers debugging. Include context so the team can understand why data was deleted and by whom.

Key takeaways

  • Soft deletes preserve data integrity, enable recovery, and satisfy compliance requirements. Use a deletedAt timestamp instead of permanent removal.
  • Create partial indexes on deletedAt to keep active-record queries fast as your collection grows and soft-deleted data accumulates.
  • Centralize soft-delete logic in your data layer (repository, service, or middleware) so the entire team applies the filter consistently.
  • Configure TTL indexes to purge data after retention periods expire, automating compliance cleanup without manual intervention.
  • Include metadata—deletedBy, deletionReason—to build an audit trail that helps with incident response and compliance reporting.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles