Word tracked changes in a .docx: python-docx cannot do it, raw XML can
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:t → w: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:
- Allocate ids from a single counter that starts above the highest id already present in the document:
max(existing) + 1. - When you split an existing run that already sits inside somebody else’s
w:ins, do not copy itsrPrChangeid into your new fragment. Give the copy a fresh id or drop the element.
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:
- accept: unwrap every
w:ins(keep contents), drop everyw:delsubtree → must equal your intended final text. - reject: drop every
w:inssubtree, unwrap everyw:deland renamew:delTextback tow:t→ must reproduce the original document byte-for-byte in text content.
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.