errno

Word tracked changes in a .docx: python-docx cannot do it, raw XML can

· tested on MS Word (OOXML), python-docx 1.x, Python 3, lxml, zipfile

The constraint

A release-management document had to come back with native Word tracked changes — the reviewer wanted to accept or reject each edit in Word, not read a changelog. The obvious tool does not do it:

python-docx exposes runs, paragraphs and styles, but no API for revisions. There is no run.insert_tracked(), and writing the text through python-docx produces a document where your edits are indistinguishable from the original. If tracked changes are a requirement, you are editing OOXML directly.

The file is a ZIP, the text lives in one part

unzip -o report.docx -d unpacked/     # word/document.xml is what you edit

An insertion is a run wrapped in w:ins:

<w:ins w:id="9001" w:author="Attila Csontos" w:date="2026-07-28T09:14:00Z">
  <w:r><w:t xml:space="preserve">deployed to HC TEST</w:t></w:r>
</w:ins>

A deletion wraps the run in w:del and the text element changes from w:t to w:delText:

<w:del w:id="9002" w:author="Attila Csontos" w:date="2026-07-28T09:14:00Z">
  <w:r><w:delText xml:space="preserve">deployed to DEV</w:delText></w:r>
</w:del>

Miss the w:tw:delText rename and Word shows the deleted text as normal body text: the revision is recorded but the content stays visible after accepting. That is the single most common way this goes wrong.

If you insert a new paragraph, the paragraph mark itself is a tracked insertion. Mark it inside the paragraph properties, otherwise rejecting the change leaves an empty paragraph behind:

<w:p>
  <w:pPr><w:rPr><w:ins w:id="9003" w:author="Attila Csontos" w:date="2026-07-28T09:14:00Z"/></w:rPr></w:pPr>
  ...
</w:p>

The rule that costs you an afternoon: every w:id must be unique

w:id is document-wide. It applies not only to w:ins and w:del but also to w:rPrChange, w:pPrChange, bookmarkStart/bookmarkEnd and friends. Duplicate an id and Word does not error — it mis-associates revisions, so accepting one change can flip an unrelated one, or a change becomes un-rejectable.

Two practical consequences:

Repack without breaking the package

Rewrite only the part you touched and preserve each entry’s compression:

import shutil, zipfile

shutil.copy("report.docx", "report-tracked.docx")
with zipfile.ZipFile("report.docx") as src:
    infos = {i.filename: i for i in src.infolist()}

with zipfile.ZipFile("report.docx") as src, \
     zipfile.ZipFile("report-tracked.docx", "w") as dst:
    for info in src.infolist():
        data = (new_document_xml.encode("utf-8")
                if info.filename == "word/document.xml"
                else src.read(info.filename))
        dst.writestr(info, data, compress_type=info.compress_type)

Re-zipping the whole directory with default settings also usually opens, but you lose the original per-entry compression and ordering — and some consumers of the file are pickier than Word.

Prove it before you send it

Two checks, both cheap, and neither is “it opened on my machine”:

1. Well-formed XML. Catches the truncated tag you introduced with a regex:

import xml.dom.minidom
xml.dom.minidom.parseString(new_document_xml)

2. Simulate accept and reject. With lxml, filter the revision elements by author and assert the result:

The reject test is the one that finds real bugs. If rejecting your changes does not return the original, the reviewer cannot undo your edits, which is the entire point of tracked changes.

Why the reviewer says “I do not see any changes”

Word’s default review mode is Simple Markup: inserted text renders as ordinary black text with a change bar in the margin. Underlines and author colours appear only in Review → Display for Review → All Markup. Write that sentence into the mail you send with the document — it saves one round trip every single time.

docx ooxml python word