đź’ˇ Full working example available on GitHub: document-version-metadata-diff-python
What You’ll Build
In this guide you’ll diff every metadata property between two versions of a document and print exactly what was added, removed, or changed. A metadata version diff is a property-level comparison of two revisions of one file, and it catches the signals a text compare never sees: a new Creator, a bumped RevisionNumber, an editing session logged after the review closed. By the end you’ll have a working solution plus two focused detectors and two export formats, all drawn from a runnable repository seeded with a sample revision pair.
Skill level: intermediate Python developer What you need: Python 3, pip, and two revisions of one document
My own first run of this script flagged a Company value change nobody on the team remembered making; that one line paid for the setup. Everything below is copy-paste ready and totals well under a hundred lines.
The pipeline is deliberately boring: two file opens, three dict comprehensions, a print loop. Boring is the point. Version disputes get decided on whether the method can be explained and repeated, and a script this small can be read in full by whoever challenges the finding.
1. Install
pip install groupdocs-metadata-net==26.5
The companion repository pins this version and ships document-v1.docx and document-v2.docx so the code below runs as-is. Pin the version your audit ran with; reproducibility is part of the evidence.
2. The Core Code
Read both property trees, then classify the delta with set logic. This is the whole diff:
# Flatten a file's complete property tree into a dict
def read_props(path):
props = {}
with Metadata(path) as metadata:
for p in metadata.find_properties(lambda p: p.name is not None):
props[p.name] = (str(p.interpreted_value) if p.interpreted_value is not None
else (str(p.value) if p.value is not None else ""))
return props
v1 = read_props("resources/document-v1.docx")
v2 = read_props("resources/document-v2.docx")
# Classify every key; changed entries keep both values
added = {k: v for k, v in v2.items() if k not in v1}
removed = {k: v for k, v in v1.items() if k not in v2}
changed = {k: (v1[k], v2[k]) for k in v1 if k in v2 and v1[k] != v2[k]}
print(f"added={len(added)} removed={len(removed)} changed={len(changed)}")
for k, (old_v, new_v) in changed.items():
print(f" {k}: {old_v} -> {new_v}")
That’s the minimum you need. Expect small counts on genuine revision pairs; a delta in the dozens usually means the file passed through a template change or a storage migration on the way. The next sections explain the key calls and show the customizations most teams add first.
3. How It Works
Metadata: the context manager that opens a file and releases it on exit; one instance per revision.find_properties: walks built-in fields, custom properties, and XMP in one pass, returning everything the predicate accepts.interpreted_value: the human-readable form of a property; preferring it means dates and enumerations compare as strings you can print in a report.- Qualified names as keys: built-in and custom fields cannot collide in the dict, so the set logic stays safe.
Nothing here parses DOCX structures. The product documentation lists 170+ formats behind the same call, so the identical script diffs PDF or XLSX pairs.
One more property of the design is worth naming: the API boundary ends at the two read_props calls. Everything after them is standard-library Python, so unit tests, thresholds, and alert rules never touch the document layer. Teams that wrap this in a service usually cache the extracted dicts per revision and let every downstream check reuse them, keeping file IO at one open per version no matter how many questions get asked.
4. Common Customizations
Detect ownership changes only
When the question is “who touched this file”, filter at read time with tag predicates instead of post-filtering the full diff:
# Identity fields only, whatever the format calls them
def read_ownership(path):
result = {}
with Metadata(path) as metadata:
props = metadata.find_properties(lambda p:
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))
for prop in props:
result[prop.name] = (str(prop.interpreted_value)
if prop.interpreted_value is not None
else (str(prop.value) if prop.value is not None else ""))
return result
Run the same delta loop over two of these dicts, using <missing> as the default so a field that vanished still surfaces. The predicate names no field, which is what lets one detector serve every format the library reads.
Track the editing timeline
Swap the predicate for Tags.time plus counter name rules and the detector reports RevisionNumber, TotalEditingTime, and LastPrinted movements:
props = metadata.find_properties(lambda p:
Tags.time.modified in list(p.tags)
or Tags.time.created in list(p.tags)
or Tags.time.printed in list(p.tags)
or (p.name is not None and ("Revision" in p.name
or "EditTime" in p.name or "EditingTime" in p.name)))
Export an audit report
Findings that stay in the console die there. Four columns cover the spreadsheet and SIEM case:
with open("output/diff.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["change_type", "property", "old_value", "new_value"])
for k, v in added.items():
writer.writerow(["added", k, "", v])
for k, v in removed.items():
writer.writerow(["removed", k, v, ""])
for k, (old_v, new_v) in changed.items():
writer.writerow(["changed", k, old_v, new_v])
The repository also includes a JSON exporter with a stable three-map schema for dashboards and case-management APIs.
Where This Runs in Practice
Three deployments keep showing up. Intake pipelines diff each arriving document against the copy already on record and quarantine pairs with identity changes. Compliance jobs run the diff on a schedule and archive the CSV per pair, building a property timeline nobody has to reconstruct later. And dispute tooling runs both detectors on demand, because when a claim lands the opening question is always who touched the file and when, not what changed in paragraph four.
A fourth pattern, diffing a file against its own last-known-good snapshot, reuses the same code with a stored dict on one side. In all of them, the export file is the deliverable; console output is progress noise. The script’s exit-code pattern follows the repository’s main.py, so schedulers and CI treat a failed assert as a failed run without extra wiring. None of them needed code beyond what this page shows.
What counts as a change worth flagging?
Anything the diff classifies plus context you add. Added and removed properties are always worth a look because they mean the structure changed rather than a value. For changed entries, most teams alert on the identity and revision groups first and treat the rest as informational. The detectors exist so that first pass costs one function call.
5. Quick Reference: Key Calls
| Call | What It Does |
|---|---|
Metadata(path) |
Opens the file; context manager handles release |
find_properties(predicate) |
Returns every property the predicate accepts, across all layers |
p.interpreted_value |
Human-readable value; falls back to p.value |
Tags.person.* / Tags.corporate.company |
Identity classification, format-independent |
Tags.time.* |
Timestamp classification for the revision detector |
See the complete API reference for the full search and tagging surface. The tag vocabulary is bigger than these rows; origin, content, and legal tag groups follow the same membership test.
6. Common Issues & Fixes
The diff is enormous and reads like noise → The two paths are probably not revisions of one document. Fix: validate provenance before diffing; unrelated files produce meaningless deltas.
A known author field never shows up in the ownership detector → Some producers store identity in untagged custom fields. Fix: run the full diff once, find the real field name, and extend the predicate with a name rule.
Console shows an evaluation-mode warning
→ No license file was found. Fix: point LICENSE_PATH in main.py at your .lic file, or keep evaluation mode for development; the logic is identical.
Dates print as raw serial numbers
→ The raw p.value slipped into a reader somewhere. Fix: keep the interpreted_value-first pattern from read_props; it is the reason reports stay readable.
What’s Next?
You have a working metadata diff. Here’s where to go from here:
- Batch it: loop the script over document pairs and store the CSV per pair; the cost per pair is two file opens, and the CSVs concatenate cleanly for a library-wide view.
- Schedule it: the repository’s
main.pyasserts every step and returns a proper exit code, which slots straight into CI or a scheduler. - Walk the tutorial version: the use case guide builds the same pipeline in three graded tutorials.
- See the whole project: document-version-metadata-diff-python with the seeded revision pair.