MongoDB $redact Stage

Beginner
⏱️ 12 min read
📚 Updated: Jul 2026
🎯 5 Examples
Aggregation

What You’ll Learn

The $redact stage conditionally hides document content based on rules you define—using three system variables ($$KEEP, $$PRUNE, $$DESCEND) to control what users see at each nesting level. It is the go-to stage for tag-based clearance and hierarchical data redaction.

01

$$KEEP

Keep level.

02

$$PRUNE

Remove level.

03

$$DESCEND

Check nested.

04

$cond

If/then/else.

05

Tag access

$setIntersection.

06

vs $match

Nested trim.

Definition and Usage

$redact evaluates an expression on each document—and on nested subdocuments when you use $$DESCEND—returning one of three outcomes that control visibility at that level.

💡
Beginner tip

Think of three actions at each level:
$$KEEP — show this level; don’t inspect children further.
$$PRUNE — hide this entire level (and everything inside it).
$$DESCEND — show this level; keep checking nested parts.
Most pipelines wrap a $cond inside $redact.

Typical pattern: $match to filter the collection, then $redact to enforce per-user or per-role visibility on nested reports, comments, or payment details.

📝 Syntax

$redact takes an expression that must resolve to a system variable:

mongosh
{
  $redact: {
    $cond: {
      if: <boolean expression>,
      then: "$$KEEP" | "$$PRUNE" | "$$DESCEND",
      else: "$$KEEP" | "$$PRUNE" | "$$DESCEND"
    }
  }
}

Syntax Rules

  • $$KEEP — retain the current document/subdocument level; do not evaluate nested content further.
  • $$PRUNE — drop the current level entirely—including all nested fields, even if children would pass on their own.
  • $$DESCEND — retain the current level and continue applying $redact logic to embedded documents and array elements.
  • $cond — the usual wrapper: if access check passes, $$DESCEND or $$KEEP; else $$PRUNE.
  • Tag checks{ $gt: [{ $size: { $setIntersection: ["$tags", userAccess] } }, 0] }.
  • Level checks{ $lte: ["$clearanceLevel", userLevel] } or { $eq: ["$level", 5] } for prune rules.
mongosh
var userAccess = ["public", "staff"]

db.reports.aggregate([
  { $match: { year: 2025 } },
  {
    $redact: {
      $cond: {
        if: {
          $gt: [
            { $size: { $setIntersection: ["$tags", userAccess] } },
            0
          ]
        },
        then: "$$DESCEND",
        else: "$$PRUNE"
      }
    }
  }
])

⚡ Quick Reference

QuestionAnswer
What it doesConditionally keep, prune, or descend into document levels
System variables$$KEEP, $$PRUNE, $$DESCEND
Common wrapper$cond with if / then / else
Nested sectionsUse $$DESCEND on parent when children have own tags
Hide whole branch$$PRUNE removes level and all descendants
vs $match$redact can trim inside a document; $match is all-or-nothing
Keep all
then: "$$KEEP"

No nested check

Remove
else: "$$PRUNE"

Drop branch

Go deeper
then:
"$$DESCEND"

Nested rules

Tag check
$setIntersection
+ $size

Role tags

🧰 System Variables & Access Patterns

The $redact expression must evaluate to one of these variables. Pair them with conditions that match your security model:

$$KEEP Allow

Keep the current level and stop—nested content is not re-evaluated. Use when the whole subtree is safe once the parent passes.

if: { $gte: ["$rating", 9] },
then: "$$KEEP",
else: "$$PRUNE"
$$PRUNE Deny

Remove the current level completely. Nested documents inside a pruned branch are never shown—even if they would pass alone.

if: { $eq: ["$level", 5] },
then: "$$PRUNE",
else: "$$DESCEND"
$$DESCEND Nested

Keep the current level and apply redact logic to embedded docs and arrays. Essential when each subsection has its own tags or level.

then: "$$DESCEND"
// Re-evaluates subsections[]
Tag intersection Pattern

Check whether any user clearance tag appears in the document’s tags array—MongoDB’s classic access-control idiom.

$gt: [
  { $size: {
    $setIntersection: ["$tags", userAccess]
  }}, 0
]

Examples Gallery

Nested reports with tag-based clearance, level-based payment redaction, simple keep/prune filters, and comparison with $match and $project.

📚 Getting Started

Reports and accounts with nested clearance metadata.

Example 1 — Reports and accounts collections

mongosh
db.reports.drop()
db.accounts.drop()

db.reports.insertOne({
  _id: 1,
  title: "Q1 Department Report",
  tags: ["general", "staff"],
  year: 2025,
  subsections: [
    {
      subtitle: "Overview",
      tags: ["general", "staff"],
      content: "Public overview text."
    },
    {
      subtitle: "Financial Detail",
      tags: ["finance"],
      content: "Confidential budget figures."
    },
    {
      subtitle: "Staff Notes",
      tags: ["staff"],
      content: "Internal staffing updates."
    }
  ]
})

db.accounts.insertOne({
  _id: 1,
  level: 1,
  acct_id: "acct-1001",
  status: "active",
  payment: {
    level: 5,
    cardLast4: "4242",
    billing: {
      level: 5,
      street: "123 Main St",
      city: "Springfield"
    }
  }
})

How It Works

The report has nested subsections each tagged for different audiences. The account embeds payment data at level: 5—ideal for demonstrating $$DESCEND vs $$PRUNE.

Example 2 — Tag-based redaction with $$DESCEND

mongosh
var userAccess = ["general", "staff"]

db.reports.aggregate([
  { $match: { year: 2025 } },
  {
    $redact: {
      $cond: {
        if: {
          $gt: [
            { $size: { $setIntersection: ["$tags", userAccess] } },
            0
          ]
        },
        then: "$$DESCEND",
        else: "$$PRUNE"
      }
    }
  }
])

// User sees report (tags include "general", "staff")
// Overview + Staff Notes kept (matching tags)
// Financial Detail pruned (tags: ["finance"] only)
// $$DESCEND re-evaluates each subsection independently

How It Works

$setIntersection finds shared tags between the document and userAccess. $$DESCEND on the parent lets MongoDB apply the same rule to each element in subsections.

Example 3 — Level-based prune (hide payment block)

mongosh
// User clearance level 3 — cannot see level-5 data
var maxVisibleLevel = 3

db.accounts.aggregate([
  { $match: { status: "active" } },
  {
    $redact: {
      $cond: {
        if: { $lte: ["$level", maxVisibleLevel] },
        then: "$$DESCEND",
        else: "$$PRUNE"
      }
    }
  }
])

// Top level (level 1) → DESCEND into fields
// payment (level 5) → PRUNE entire payment object
// billing inside payment never evaluated (parent pruned)
// Result: acct_id, status — no card or address data

How It Works

When a node fails the level check, $$PRUNE removes the whole subtree. This is why billing inside payment disappears even though it could theoretically be evaluated separately—parent prune wins.

📈 Practical Patterns

Simple filters and comparison with other stages.

Example 4 — $$KEEP vs $$PRUNE (top-rated only)

mongosh
db.products.drop()
db.products.insertMany([
  { name: "Widget Pro",  rating: 4.8, inStock: true  },
  { name: "Widget Lite", rating: 4.2, inStock: true  },
  { name: "Budget Item", rating: 3.1, inStock: false }
])

db.products.aggregate([
  {
    $redact: {
      $cond: {
        if: { $gte: ["$rating", 4.5] },
        then: "$$KEEP",
        else: "$$PRUNE"
      }
    }
  }
])

// Only Widget Pro (rating 4.8) survives
// $$KEEP = document passes, no nested re-check
// Similar outcome to $match — but $redact pattern
// scales to nested tag/level rules

How It Works

On flat documents without nested clearance fields, $$KEEP/$$PRUNE behaves similarly to $match. The power of $redact appears when documents have nested structures with per-level rules.

Example 5 — Why $redact beats $match for nested content

mongosh
// $match: all-or-nothing — drop entire report if ANY rule fails
db.reports.aggregate([
  { $match: { "subsections.tags": "staff" } }
])
// Still returns full report including Financial Detail
// $match only filters TOP-LEVEL docs, not array elements

// $redact: trim individual subsections
var userAccess = ["general", "staff"]
db.reports.aggregate([
  { $match: { year: 2025 } },
  {
    $redact: {
      $cond: {
        if: {
          $gt: [
            { $size: { $setIntersection: ["$tags", userAccess] } },
            0
          ]
        },
        then: "$$DESCEND",
        else: "$$PRUNE"
      }
    }
  }
])
// Report kept; only unauthorized subsections removed

// $project cannot conditionally drop array elements by tag
// $redact is the right tool for nested conditional visibility

How It Works

$match cannot remove individual array elements based on per-element tags. $project lacks conditional nested traversal. $redact with $$DESCEND fills that gap for hierarchical access control.

🧠 How $redact Works

1

Document enters

Each document from the prior stage (often after $match) is evaluated at the root level.

Input
2

Condition tested

The $redact expression runs—typically $cond checking tags, levels, or roles.

Evaluate
3

Action applied

$$KEEP retains, $$PRUNE removes, or $$DESCEND recurses into nested docs and arrays.

Redact
=

Sanitized output

Clients receive documents with unauthorized nested content removed—not blank entire records.

📝 Notes

  • The expression inside $redact must resolve to $$KEEP, $$PRUNE, or $$DESCEND.
  • $$PRUNE on a parent removes all descendants—children are not evaluated individually.
  • Use $$DESCEND when nested subdocuments or array elements carry their own tags or level fields.
  • Define userAccess or clearance variables before the pipeline—never hard-code secrets in production.
  • $redact is not a substitute for database authentication—combine with proper auth and MongoDB RBAC.
  • Previous topic: $project. Next: $replaceRoot.

Conclusion

$redact gives pipelines fine-grained control over visibility: keep allowed levels, prune sensitive branches, and descend into nested content when each part has its own access rules.

Start with $match, apply tag or level checks with $setIntersection, and choose the right system variable for each outcome. Next: $replaceRoot to promote nested documents to the top level.

💡 Best Practices

✅ Do

  • Put $match before $redact to reduce documents processed
  • Use $$DESCEND for arrays of tagged subsections or comments
  • Store clearance metadata (tags, level) consistently on every nest level
  • Test with multiple user role variables to verify prune behavior
  • Prefer server-side redaction over exposing raw docs to the application

❌ Don’t

  • Expect $$PRUNE on a parent to expose safe nested fields
  • Use $redact as your only security layer—enforce auth at the database
  • Hard-code production role arrays in shared pipeline code without parameterization
  • Assume $match on array fields trims individual elements—it does not
  • Forget that $$KEEP skips nested re-evaluation—unsafe if children need checks

Key Takeaways

Knowledge Unlocked

Five things to remember about $redact

Use these points whenever you implement conditional document visibility.

5
Core concepts
🔎 02

Nested trim

Not all-or-nothing.

Power
🏷 03

Tag check

$setIntersection.

Pattern
🚫 04

$$PRUNE

Whole branch gone.

Rule
📈 05

vs $match

Per-level control.

Compare

❓ Frequently Asked Questions

$redact conditionally includes or excludes document content based on an expression that resolves to $$KEEP, $$PRUNE, or $$DESCEND. Unlike $match (all-or-nothing per document) or $project (field lists), $redact can evaluate access at each nested level—ideal for hierarchical or tag-based security.
$$KEEP keeps the current level and stops further inspection at that level. $$PRUNE removes the current level entirely without checking nested content. $$DESCEND keeps the current level but continues evaluating embedded documents and array elements inside it.
$match drops or keeps entire documents based on a filter. $redact can prune nested subdocuments while keeping the parent—e.g. hide one confidential section inside a report but keep other sections. Use $match first to narrow the set, then $redact for fine-grained trimming.
$project shapes fields at one document level with fixed include/exclude rules. $redact applies conditional logic per level and can recurse into nested structures with $$DESCEND. Use $project for simple column selection; use $redact for role-based or tag-based nested redaction.
Use $$DESCEND when the current level passes the access check but nested subdocuments or array elements may have different access rules—e.g. a report the user can see, but individual sections tagged with different clearance levels inside subsections.
Any expression resolving to $$KEEP, $$PRUNE, or $$DESCEND—most commonly $cond: { if: condition, then: "$$DESCEND", else: "$$PRUNE" }. Tag checks often use $setIntersection with $size; level checks use $eq or $lte on a level field.
Did you know?

MongoDB’s classic forecasts example in the official docs uses exactly this pattern: $$DESCEND when user tags intersect document tags, $$PRUNE otherwise—allowing a user to read a report while individual subsections with higher clearance are stripped. See the official $redact docs.

Continue the Stages Series

Redact sensitive nested fields, then reshape documents with $replaceRoot.

Next: $replaceRoot →

About the author

Mari Selvan M P
Mari Selvan M P 🔗

Developer, cloud engineer, and technical writer

  • Experience 12 years building web and cloud systems
  • Focus Full Stack Development, AWS, and Developer Education

I write practical tutorials so students and working developers can learn by doing—from databases and APIs to deployment on AWS.

5 people found this page helpful