1. GroupDocs Documentation
  2. /
  3. GroupDocs.Metadata Product Family
  4. /
  5. GroupDocs.Metadata for Python
  6. /
  7. Use Cases
  8. /
  9. Comparing Metadata Between Document Versions - Technical Deep Dive

Comparing Metadata Between Document Versions - Technical Deep Dive

Note
πŸ’‘ Full working example available on GitHub: document-version-metadata-diff-python

Executive Summary

Metadata version comparison is a GroupDocs.Metadata capability for Python via .NET that reads every property of two document revisions and reports exactly what was added, removed, or changed between them. Properties like Creator, RevisionNumber, and the last-printed timestamp never render on the page, yet they are the first thing an auditor asks about β€” and reading them by hand means a new code branch per format. This guide dissects a compact pipeline instead: one extraction call flattens each revision’s property tree, a pure-Python diff classifies the result, two tag-driven detectors isolate identity and activity signals, and two exporters serialize the verdict. Every code block comes from the runnable repository above, which ships with two sample DOCX revisions and asserts all six functions on every run. The audience is engineers building compliance tooling who need evidence-grade output rather than a visual compare.

Warning
For Production Use: run the complete implementation against your own document corpus before deployment.

What This Guide Covers

One question: given two versions of the same file, what changed about it, and can you prove it? For audit-trail, retention, and tampering work β€” not body-text comparison, which is a different problem.

Prerequisites:

  • Python 3 with pip; the demo pins groupdocs-metadata-net==26.5 in requirements.txt
  • A GroupDocs license file (optional β€” without one the demo runs in evaluation mode and says so)

Installation

pip install groupdocs-metadata-net==26.5

Point LICENSE_PATH in main.py at your .lic file, then run python main.py. It diffs the two bundled DOCX revisions, writes output/diff.json and output/diff.csv, and prints ALL PASS when all six stages hold.

Repository Layout

document-version-metadata-diff-python/
β”œβ”€β”€ main.py
β”œβ”€β”€ methods/
β”‚   β”œβ”€β”€ compare_metadata_sets.py
β”‚   β”œβ”€β”€ detect_ownership_changes.py
β”‚   β”œβ”€β”€ detect_revision_history.py
β”‚   β”œβ”€β”€ export_diff_to_csv.py
β”‚   β”œβ”€β”€ export_diff_to_json.py
β”‚   └── extract_all_metadata.py
β”œβ”€β”€ requirements.txt
└── resources/          # document-v1.docx, document-v2.docx

Component Comparison Matrix

ComponentComplexityI/O CostLatencyScales ByResource Efficiency
extract_all_metadataLowOne full file readLow β€” no renderingFile countHigh
compare_metadata_setsLowNone (in-memory)NegligibleProperty countVery high
detect_ownership_changesMediumOne filtered read per fileLowFile countHigh
detect_revision_historyMediumOne filtered read per fileLowFile countHigh
export_diff_to_json / _csvLowOne small writeNegligibleChange countVery high

Component 1: extract_all_metadata β€” Whole-Tree Extraction

Design Overview

Only this component understands file formats, and it delegates that to the Metadata class: one find_properties call walks built-in, custom, and format-specific packages in a single pass, flattened into a dict keyed by qualified name. interpreted_value makes dates and enumerations comparable strings, and nothing here names a format β€” see Extracting metadata for the mechanism.

Design Pattern: Adapter over format-specific packages Concurrency Model: Synchronous, single file handle State Management: Stateless

Implementation

result = {}
with Metadata(document_path) as metadata:
    for prop in metadata.find_properties(lambda p: p.name is not None):
        key = prop.name
        value = (str(prop.interpreted_value)
                 if prop.interpreted_value is not None
                 else (str(prop.value) if prop.value is not None else ""))
        result[key] = value
return result

Performance and Scalability

Cost is dominated by opening the file; body content is never rendered. Scaling is linear and embarrassingly parallel β€” a pool over 10,000 documents shares no state, and storage latency, not CPU, is the bottleneck.

Component 2: compare_metadata_sets β€” Structured Diff Core

Design Overview

The diff core never touches a file: both revisions become name→value maps, classified into three buckets on a MetadataDiff value object — added, removed, changed (old/new pairs) — with total_changes as a one-number verdict. A structured object instead of a report string is the decision the rest of the pipeline leans on.

Design Pattern: Value object with set-classification Concurrency Model: Pure in-memory computation

Implementation

v1 = extract_all_metadata(path_v1)
v2 = extract_all_metadata(path_v2)
diff = MetadataDiff()

for k, v in v2.items():
    if k not in v1:
        diff.added[k] = v
    elif v1[k] != v:
        diff.changed[k] = (v1[k], v)

for k, v in v1.items():
    if k not in v2:
        diff.removed[k] = v

return diff

Performance and Scalability

Two dictionary scans over a few hundred entries β€” unmeasurable next to the file reads. In a batch service, expose this component: two paths in, one serialized diff out.

Component 3: Tag-Filtered Forensic Detectors

Design Overview

The detectors answer what auditors ask first: who touched the file, and when. detect_ownership_changes narrows to identity properties via tag predicates β€” Tags.person.creator, Tags.person.editor, Tags.person.manager, Tags.corporate.company β€” while detect_revision_history targets time tags plus name patterns for revision counters. No format-specific key like dc:creator appears in the code; the tag finds the right property per format, as covered in Find metadata properties. A <missing> sentinel separates a deleted property from a blanked one.

Design Pattern: Predicate-filtered projection Concurrency Model: Synchronous, one filtered read per file

Implementation

The ownership comparison:

v1 = _read_ownership(path_v1)
v2 = _read_ownership(path_v2)
all_keys = set(v1.keys()) | set(v2.keys())
changes = {}
for k in all_keys:
    old_v = v1.get(k, "<missing>")
    new_v = v2.get(k, "<missing>")
    if old_v != new_v:
        changes[k] = (old_v, new_v)
return changes

Its tag-filtered reader:

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:
        value = (str(prop.interpreted_value)
                 if prop.interpreted_value is not None
                 else (str(prop.value) if prop.value is not None else ""))
        result[prop.name] = value
return result

The revision detector swaps the predicate β€” time tags composed with plain string checks:

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)))

Performance and Scalability

Each detector re-opens its files β€” the trade-off for standalone functions. At audit scale, extract once per revision and filter the in-memory dicts instead.

Component 4: Audit Export Layer

Design Overview

Two serializers, one MetadataDiff. JSON keeps the three buckets as top-level maps with explicit from/to fields β€” stable enough to diff across audit runs. CSV flattens to one row per change with four fixed columns for Excel, warehouse jobs, or a SIEM. Both use only the standard library.

Implementation

payload = {
    "added": diff.added,
    "removed": diff.removed,
    "changed": {k: {"from": v[0], "to": v[1]} for k, v in diff.changed.items()},
}
with open(output_path, "w", encoding="utf-8") as f:
    json.dump(payload, f, indent=2, ensure_ascii=False)
with open(output_path, "w", encoding="utf-8", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["change_type", "property", "old_value", "new_value"])
    for k, v in diff.added.items():
        writer.writerow(["added", k, "", v])
    for k, v in diff.removed.items():
        writer.writerow(["removed", k, v, ""])
    for k, (old_v, new_v) in diff.changed.items():
        writer.writerow(["changed", k, old_v, new_v])

Operational Considerations

  • Monitoring: a corpus-wide total_changes spike usually means a bulk process rewrote properties.
  • Error Handling: main.py asserts every stage and exits non-zero β€” a ready-made CI smoke test.
  • Resilience: exports are idempotent; re-runs overwrite the same two files.

How do I prove who changed a document between two versions?

Run both revisions through detect_ownership_changes and detect_revision_history, then export the combined diff. The first reports every identity property whose value differs β€” Creator, Editor, Manager, Company β€” with old and new values side by side; the second shows the activity trail: revision counters, editing time, created, modified, and printed timestamps. Together they answer who and when in two function calls, with output you can attach to a case file.

When to Use This Approach

Reach for a metadata diff when the dispute is about the file, not the words in it: chain-of-custody checks, retention audits, contested authorship, or a screen before full e-discovery review. It also wins on volume β€” no rendering, so a folder of contracts screens in minutes. Use GroupDocs.Comparison for paragraph-level content changes, and both when a claim needs content and property evidence to line up. I default to the metadata pass first; its verdict tells you whether the expensive content review is needed at all.

Common Pitfalls

  • Comparing raw values instead of interpreted_value β€” raw objects differ across formats; the string projection lines up.
  • Hard-coding property names β€” Author in one format is dc:creator in another; tag predicates survive both.
  • Treating “missing” as “empty” β€” a removed property and a blanked one are different findings; hence the sentinel.
  • Forgetting newline="" in the CSV writer β€” without it every row on Windows gains a blank line.
  • Concluding from evaluation mode β€” unlicensed runs limit some properties; license before attaching output to a finding.

Security and Compliance

The pipeline only reads its inputs β€” originals stay untouched and defensible. Store the exports under access control and hash them if they enter a legal hold. For the reverse obligation β€” stripping properties before release β€” the same search engine drives the removing metadata workflow.

FAQ

Does this work for formats other than DOCX? Yes β€” nothing in the pipeline names a format. The Metadata constructor detects the type, and the product documentation lists 170+ formats including PDF, XLSX, PPTX, images, and audio. The samples are DOCX because Office files carry the richest built-in property sets.

Why do the detectors re-open files instead of reusing the extraction? Each function stands alone so it can be lifted into another codebase, at the cost of two extra file opens. At scale, extract once per revision and filter the in-memory dicts. The classification logic stays identical.

Can I diff two arbitrary documents rather than two versions of one file? Mechanically yes β€” the diff has no notion of lineage. But unrelated files report nearly everything as added or changed, so the output only means something for related inputs. Ground the “v1”/“v2” labels in version-control or DMS history first.

Conclusion and Recommendations

Six small functions cover the audit path: flatten, diff, filter for identity, filter for activity, serialize twice. The decisions that make it hold up are unglamorous β€” interpreted values, a structured diff object, tag predicates, an explicit missing sentinel. Before deployment, review the complete source code, extract once per file at batch scale, and store exports with the same controls as the documents they describe.

See Also