💡 Full working example available on GitHub: sanitize-office-document-pii-python

The Data Nobody Reviews Before Hitting Send

A quarterly board report goes out to an external auditor. The text is spotless; three review cycles made sure of it. The file itself is another story. Its properties still name the analyst who drafted it, the manager who reworked it, the company subsidiary that owns the template, a LastPrinted timestamp from the night before the deadline, and a SharePoint approver ID from the internal sign-off workflow. None of this appears on any page. All of it travels with the file.

PII removal is a GroupDocs.Metadata workflow for Python via .NET that strips these identity-bearing properties from Word, Excel, and PowerPoint files programmatically. This article compares the three approaches the API offers: tag-driven removal for identity fields, name-pattern removal for property families like comments and revisions, and the one-call sanitize() that clears everything. You will also see the step most sanitization scripts skip, a verification scan that proves the cleanup actually held.

Why Metadata PII Deserves Its Own Pipeline

Content review tools check what people read. They do not check what file systems store, and that gap is where compliance incidents come from. A GDPR request covers personal data in Author and Manager fields just as much as data in the text. Legal discovery reads revision counters and editing-time totals to reconstruct how long a position paper was negotiated. Tender reviewers can map your org structure from SharePoint workflow properties, and a press release’s comment fields preserve reviewer names alongside draft-stage remarks. Each of those is a finding. None of them is visible in the document body.

Prerequisites

Before starting, ensure you have:

  • Python 3 with pip
  • GroupDocs.Metadata for Python via .NET, pinned in the sample repository to version 26.5
  • An Office file with real properties to practice on

Installation

pip install groupdocs-metadata-net==26.5

The companion repository seeds a sample DOCX and runs every snippet below as an asserted pipeline.

Method 1: Tag-Driven Identity Removal

The four most sensitive fields, Author, LastSavedBy, Manager, and Company, have different internal names across Office formats. The tag system solves this: instead of naming properties, the predicate asks for everything tagged as a person or a company.

# Match identity properties by meaning, not by format-specific name
with Metadata("board-report.docx") as metadata:
    removed = metadata.remove_properties(lambda p:
        Tags.person.creator in list(p.tags)     # Author, LastSavedBy
        or Tags.person.editor in list(p.tags)
        or Tags.person.manager in list(p.tags)
        or Tags.corporate.company in list(p.tags))
    metadata.save("board-report-clean.docx")

print(f"{removed} identity properties removed")

Key points:

  • Format independence: the same lambda cleans DOCX, XLSX, and PPTX because tags classify by role.
  • Countable outcome: remove_properties returns how many properties matched, which belongs in your audit log.
  • Copy semantics: saving to a new path keeps the original for your records.

💡 Tip: this pass preserves Title, Subject, and other descriptive fields, so the file stays friendly to search and DMS indexing.

Method 2: Name-Pattern Removal for Property Families

Tags cover classified concepts. Whole families of leaky fields sit outside that classification: comment properties, revision counters, SharePoint workflow stamps. For these, match on the property name itself.

# Comment fields often live in custom properties the tag system
# does not classify, so match them by name substring
with Metadata("board-report.docx") as metadata:
    removed = metadata.remove_properties(lambda p:
        p.name is not None and (
            "Comment" in p.name
            or "Reviewer" in p.name
            or "Reviewed" in p.name))
    metadata.save("board-report-no-comments.docx")

The same shape handles the other two families; only the substring list changes:

Family Substrings to match
Revision trail Revision, TrackedChange, LastPrinted, TotalEditingTime, EditTime
Server / workflow Server, Workflow, Approver, ContentType, Template

This trades precision for reach: "Comment" also catches Comments and CommentCount, which is usually what a sanitization pass wants. Broad substrings can match harmless template fields too, so audit the returned count against expectations.

💡 Tip: run each family as its own pass when your audit log needs per-category counts; merge the substrings into one predicate when it does not.

Method 3: The One-Call Full Sanitize

When the file is leaving the organization and nothing in the metadata layer should survive, stop writing predicates.

# One call, every detected metadata package
with Metadata("board-report.docx") as metadata:
    removed = metadata.sanitize()
    metadata.save("board-report-final.docx")

print(f"sanitize() removed {removed} properties")

sanitize() clears every package the library detects: document-info identity fields, comments, revision history, tracked-change authors, and custom OOXML parts. The behavior is documented on the Clean metadata page. Its strength is also its cost. Title and Subject disappear along with the PII, which is why it belongs at the export gate rather than in the middle of a collaboration workflow.

Do I need all four targeted passes?

No. Each pass exists because a different team owns the risk. Identity fields upset privacy officers, comment trails upset legal, revision counters upset negotiators, and server fields upset security. Run the passes that map to your reviewers, in any order, since each writes its own output copy. When nobody needs surviving fields, skip straight to sanitize() and verify.

Comparing the Three Approaches

Method Best For Key Advantages Limitations
Tag-driven removal Working copies, multi-format pipelines Format-independent, preserves descriptive fields Only covers tag-classified concepts
Name-pattern removal Comments, revisions, server fields Reaches custom properties tags miss Substrings need tuning per environment
Full sanitize() Final export outside the organization Cannot miss a forgotten property Wipes harmless fields too

The approaches compose naturally: targeted passes while the document is alive, sanitize() when it ships.

Verify Before You Trust It

A removal call returning a count is not evidence the file is clean. The repository ends every run by re-opening the sanitized output and scanning it with find_properties, using a predicate that combines the tag rules and name rules from all the passes above.

def is_pii(p):
    if p.name is None:
        return False
    return (
        Tags.person.creator in list(p.tags)
        or Tags.person.editor in list(p.tags)
        or Tags.person.manager in list(p.tags)
        or Tags.corporate.company in list(p.tags)
        or any(n in p.name for n in (
            "Comment", "Reviewer", "Revision", "TrackedChange",
            "Classification", "Department", "Server", "Workflow")))

with Metadata("board-report-final.docx") as metadata:
    for p in metadata.find_properties(is_pii):
        value = (str(p.interpreted_value) if p.interpreted_value is not None
                 else (str(p.value) if p.value is not None else ""))
        if value and value not in ("0", "0.0"):
            print(f"LEAK {p.name}={value}")

The full version in the repository sorts survivors into two buckets, and the distinction matters. Metadata leaks must be zero. Content-level remnants, Word comment balloons and tracked changes living inside word/document.xml, are body content that a metadata API cannot reach; removing them takes a content-editing library such as Aspose.Words. An honest report names both buckets instead of declaring victory on the first. The first time I ran this scan on a “clean” file, it flagged a Department field that a corporate template had been quietly re-adding for months.

Best Practices and Tips

  • Sanitize copies, never originals: every snippet here writes to a new path, keeping the source for your records and retention rules.

  • Log the counts: the return values of remove_properties and sanitize() are your audit trail. Store them per file, per pass.

  • Wire verification into CI: a leak check that fails the build catches template regressions the day they happen, not the day a client notices.

  • Mind the metadata/content boundary: never report a file clean while body-level comments remain; surface them as a separate finding.

  • Licensing: evaluation mode reproduces everything in this article; use a license in production so no evaluation marks touch outgoing files.

Conclusion

Three approaches, one decision rule. Match by tag when the concept is classified and the file must stay useful. Match by name when the family lives in custom properties. Call sanitize() when the file crosses the trust boundary, and verify with a read-back scan whichever route you took.

Ready to go deeper? Here are some next steps:

Additional Resources

Have questions or want to share your implementation? Reach out on the support forum.