How to Write Real Word Tracked Changes in Python

How to emit native Word tracked changes (w:ins and w:del) from Python with lxml, including the delText gotcha that silently breaks Reject.

If you have searched for "python-docx tracked changes" you already know how this goes. You find the feature request. You find the pull request that implements it. You find that the pull request is still open, and that the thread under it is years long. Then you find a Stack Overflow answer suggesting you color the text red and add strikethrough, which is not a tracked change, it is a costume.

This post is the reference I wanted when I started. It covers how to emit native OOXML revisions from Python, why python-docx will not do it for you, and the specific things that will silently eat your output when you get them slightly wrong. The code here is drawn from the redline export we run in production, simplified where the real thing carries detail you do not need.

If you want the product argument for why native revisions matter more than a colored diff, that is a separate post: Real Word Redlines, Not a Diff View. This one is about the XML.

Why python-docx cannot do this

python-docx is a good library. It models the parts of WordprocessingML that most people need: paragraphs, runs, fonts, tables, sections, styles. What it does not model is the revision layer.

There is no run.insert(), no paragraph.delete(), no revision object anywhere in the public API.

The specifics are worth stating, because they explain why every search you run ends the same way. Issue #340, "revisions/track changes", was opened in December 2016. As of writing (July 2026) it is still open, with a comment thread that has been running for years. Two pull requests that would implement it, #1534 (revision management, opened January 2026) and #1538 (native w:ins and w:del support), were both open and unmerged as of July 2026. Check their current state before you rule the library out. So the answer to "does python-docx support tracked changes" is: not yet, and not for want of people asking.

That is a factual limitation of the library today, not a criticism of it. Revisions are genuinely awkward to model, because they are not properties of a run, they are wrappers around runs that can also span paragraphs, tables, and section properties.

The good news is that python-docx exposes the thing you actually need: the underlying lxml element tree. Every run object has a ._r attribute that is the real <w:r> element. Every document has doc.part.package, which is the OPC package you can add parts to. So the working approach is not to fight python-docx or fork it. It is to let python-docx build the document, then reach through to lxml and wrap the runs yourself.

The shape of a revision

A tracked change in OOXML is a wrapper element around one or more runs.

<!-- simplified OOXML: one insertion, one deletion -->
<w:ins w:id="7" w:author="Vaquill AI Contract Review" w:date="2026-07-17T10:00:00Z">
  <w:r><w:rPr><w:sz w:val="21"/></w:rPr><w:t xml:space="preserve">thirty (30) days</w:t></w:r>
</w:ins>
<w:del w:id="8" w:author="Vaquill AI Contract Review" w:date="2026-07-17T10:00:00Z">
  <w:r><w:rPr><w:sz w:val="21"/></w:rPr><w:delText xml:space="preserve">ninety (90) days</w:delText></w:r>
</w:del>

In plain English: "thirty (30) days" is an insertion and "ninety (90) days" is a deletion, each stamped with an author, a timestamp, and a revision id. Three attributes, all required. w:id must be unique across every revision in the document. w:author is the string Word shows in the Review pane and uses to color-code by reviewer. w:date is ISO 8601 with a Z, and Word will accept it happily and then render nothing sensible if you hand it a naive local timestamp.

Note what is not in there: no color, no strikethrough, no underline. You do not style a revision. Word styles it, using the reviewer's assigned color, and it restyles it the moment someone changes the markup view. If you find yourself setting a red font on a <w:del>, you have built the costume again.

The emit flow

Here is the path a single edit travels from proposed text to markup on disk.

Loading diagram...

The step that carries all the weight is "move the existing run inside the wrapper." Not "build a new run inside the wrapper." More on that shortly.

The core primitive

This is the whole trick, in about fifteen lines.

# simplified from our _add_tracked_run()
from docx.oxml import OxmlElement
from docx.oxml.ns import qn

def add_tracked_run(paragraph, text, kind, rev_id, date_iso):
    run = paragraph.add_run(text)      # let python-docx build a valid <w:r> with its <w:rPr>
    run.font.size = Pt(10.5)
    r = run._r                          # reach through to the lxml element

    wrapper = OxmlElement("w:ins" if kind == "insertion" else "w:del")
    wrapper.set(qn("w:id"), str(rev_id))
    wrapper.set(qn("w:author"), "Vaquill AI Contract Review")
    wrapper.set(qn("w:date"), date_iso)

    r.addprevious(wrapper)              # put the wrapper where the run was
    wrapper.append(r)                   # then move the run into it

    if kind == "deletion":
        for t in r.findall(qn("w:t")):
            t.tag = qn("w:delText")     # a deleted run MUST carry delText, or Word ignores it

Read the two lxml calls in the middle carefully, because their order matters. addprevious inserts the empty wrapper into the paragraph at the position the run currently occupies. append then moves the run out of the paragraph and into the wrapper, because in lxml appending an element that already has a parent is a move, not a copy. The net effect is an in-place wrap that preserves document order without you ever computing an index.

The qn() helper is doing real work too. OOXML is namespaced, and the w: prefix in your head is not a string lxml understands. qn("w:del") expands to the Clark-notation {http://schemas.openxmlformats.org/wordprocessingml/2006/main}del. Setting the tag as a literal "w:del" string produces a document Word opens as corrupt, which is at least a loud failure.

Gotcha one: delText, or Word silently ignores your deletion

That last line in the snippet is the single highest-value line in this entire post.

A run inside a <w:del> cannot store its text in <w:t>. It must store it in <w:delText>. This is not a nicety and it is not a hint. If you wrap a run in <w:del> and leave the text in <w:t>, Word does not throw an error, does not show a repair dialog, and does not log anything. It opens the file cleanly and the deletion is simply not there.

That failure mode is worth dwelling on, because it is what makes this a bad problem to debug. An error you can chase. A file that opens fine and is quietly missing half your revisions looks, on a small test document, exactly like a bug in your positioning logic. People lose days here, and it is why the "just color it red" answers propagate: the correct markup is one element name away from the silently wrong markup.

Note that the rewrite is a tag swap on an existing element, not a rebuild. t.tag = qn("w:delText") keeps the element's attributes, which matters because xml:space="preserve" lives on that element and leading or trailing spaces in your deleted text will vanish without it.

The mirror-image rule applies to insertions: a run inside <w:ins> keeps <w:t>. There is no insText. Only deletion has a special text element, which is exactly the kind of asymmetry that does not stick in memory.

Gotcha two: wrap the run, do not rebuild it

The tempting implementation is to build the whole revision as a string of XML and parse it in. It reads cleanly and it throws away all your formatting.

A run carries its properties in a <w:rPr> child: bold, italic, size, font, color, language. When you let python-docx create the run with paragraph.add_run(text) and then set run.font.size, python-docx builds a correct <w:rPr> for you, in the schema-mandated child order, which Word does enforce. By wrapping that element rather than constructing a replacement, the revision inherits every property the run already had, for free.

This matters most when you are editing text that was not plain. A bolded defined term inside a clause you are striking should still be bold inside the deletion, so that when the reviewer rejects the change the original comes back looking like the original. Rebuild the run and you silently normalize the document's formatting every time you touch it, which is the sort of damage nobody notices until a lawyer diffs your output against the source.

The general rule: python-docx is better at building valid runs than you are. Use it for the run, use lxml only for the wrapper.

Gotcha three: revision ids must be unique, and you own that

w:id is a document-scoped unique integer. Nothing in python-docx allocates it, nothing validates it, and Word's behavior on a collision is not something you want to characterize empirically.

The fix is boring and should stay boring.

# simplified: one counter per document render
import itertools

rev = itertools.count(1)
date_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")

for text, seg_type, comment in segments:
    if seg_type in ("deletion", "insertion"):
        add_tracked_run(p, text, seg_type, next(rev), date_iso)

One counter, created at the top of the render, threaded through every call site, never reset. The trap is not the counter itself, it is scope. A counter that lives inside a per-paragraph or per-edit helper restarts and hands out id 1 four separate times. So allocate at the level of the document, and pass it down.

Comment ids are a separate id space with the same rule, and we allocate those from the length of the comment list being accumulated, so a comment's id is its index and cannot drift from the entry it names.

Gotcha four: apply edits in reverse order

This one is not OOXML at all, it is arithmetic, and it will corrupt documents.

You located your edits as (start, end) character offsets into the document text. The moment you apply the first one, every offset after it is wrong, because you just changed the length of the text in front of them. Replace "ninety (90) days" with "thirty (30) days" and everything downstream shifts by one character, which is enough to slice a later edit's span through the middle of a word.

So we sort matches descending by start position and apply from the end of the document backwards. Edits behind the cursor never move, so every offset stays valid.

# simplified from _find_replacement_positions()
accepted: list[tuple[int, int]] = []

def overlaps(start, end):
    return any(start < ie and si < end for (si, ie) in accepted)

# ... find each span, skipping any that intersects one already placed ...

matched.sort(key=lambda m: m[0], reverse=True)   # reverse order = later offsets stay valid

The overlaps check next to it solves the related problem. Two proposed edits whose spans intersect cannot both be applied, and tracking only start offsets is not enough to catch it: you need the half-open intervals. Track starts only and two overlapping edits both get placed, and the segment builder then emits the overlap region twice, producing a file that is worse than useless because it looks plausible. Track intervals and the second edit is skipped cleanly.

Word-level diff versus block replacement

Wrapping runs is mechanical. Deciding what to wrap is the part that makes output look like a human did it.

The naive approach strikes the entire clause and inserts the entire rewritten clause. It is always correct and it is unreadable: a reviewer looking at a forty-word deletion followed by a forty-word insertion cannot see that you changed one number.

So for a small change inside a single line, we diff the original span against the replacement at the word level and emit only the changed words as revisions, leaving the shared words as plain text. The tokenizer detail worth stealing: a token is a word plus its trailing whitespace, so the space between two changed words rides inside the change. Split on whitespace and discard it and your inserted fragments concatenate to "three(3)" instead of "three (3)".

The important half of this is knowing when not to do it. We fall back to whole-block delete-then-insert when the span is multi-line, when the replacement carries markdown the inline renderer cannot style, and when the two sides share no words at all, because a token diff of a full rewrite is all-or-nothing anyway. There is also a similarity floor: two texts that share only scattered stopwords technically have words in common, and interleaving them word by word produces grammatical nonsense. A clean strike and insert always reads better than a surgical-but-scrambled diff.

Comments: a whole new part in the package

Margin comments are not a revision, they are a separate part of the .docx package, and this is where you stop being able to pretend a .docx is one XML file.

You need three things. A word/comments.xml part holding the comment bodies. A relationship from the document part to it. And anchors in the document body: <w:commentRangeStart> before the run, <w:commentRangeEnd> after it, and a <w:commentReference> run after that. All three share the comment's id, and all three must be present. A reference with no range renders as a comment anchored to nothing.

# simplified from _attach_comments_part()
from docx.opc.packuri import PackURI
from docx.opc.part import Part

xml = f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:comments xmlns:w="{NS}">{bodies}</w:comments>'

part = Part(
    PackURI("/word/comments.xml"),
    "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
    xml.encode("utf-8"),
    doc.part.package,
)
doc.part.relate_to(
    part,
    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
)

python-docx has no comments API, but its OPC layer is fully public, so you can add an arbitrary part and relate it without touching internals beyond doc.part.package. The content type string and the relationship type string both have to be exactly right, and both are long enough that a typo reads as correct at a glance. Escape your comment text before interpolating it, since an unescaped ampersand in a lawyer's note is enough to make the file unopenable.

The one-element setting everyone forgets

<w:settings>
  <w:trackChanges/>
  <!-- ... -->
</w:settings>

<w:trackChanges/> in word/settings.xml does not mark your changes as tracked. Your <w:ins> and <w:del> elements already did that. What it does is turn tracking on for whoever opens the file next, so the recipient's edits are captured as revisions too.

For a document that round-trips, this is the difference between a redline and a negotiation. Without it, the counterparty opens your tracked file, types their counter-proposal as untracked body text, and sends back something you cannot diff. It is one element, python-docx exposes the settings element as doc.settings.element, and the schema wants it early in <w:settings>, so insert at position zero rather than appending.

The gotcha checklist

Print this next to your keyboard.

  1. A deleted run carries <w:delText>, never <w:t>. Get this wrong and Word discards the deletion in silence.
  2. Insertions keep <w:t>. There is no insText.
  3. Every w:id is unique document-wide. One counter, allocated at document scope, passed down.
  4. w:date is ISO 8601 UTC with a Z. Not a naive local timestamp.
  5. Use qn() for every tag and attribute name. A raw "w:del" string is not a namespaced tag.
  6. Wrap the run that python-docx built. Do not construct a replacement, or you drop its <w:rPr> and normalize the formatting.
  7. Preserve xml:space="preserve" through the delText swap by retagging the element rather than rebuilding it.
  8. Apply edits in reverse document order, and track half-open intervals so overlapping edits cannot both land.
  9. Comments need all three anchors plus the part plus the relationship. Escape the text.
  10. <w:trackChanges/> in settings is what makes the file round-trip.
  11. Never style a revision. Word owns the color, and it changes with the markup view.
  12. Test in actual Microsoft Word, not just a viewer. Accept one change, reject another. A viewer that renders your colors proves nothing.

That last one is the real test, and it takes thirty seconds. If Accept turns your insertion into ordinary body text and Reject brings your deleted text back, you have built revisions. If clicking Accept does nothing, you have built formatting.

When I would just use a library

Honest answer, because this is a real cost.

If all you need is to produce a redline between two versions of a document you control end to end, and you do not need comments, and you do not need surgical word-level edits, look hard at a document-comparison library or at driving Word itself through COM automation on Windows before you write any of this. The XML is not difficult, but it is unforgiving, and the silent-failure modes mean you will build a test harness that opens real files and asserts on real markup before you trust any of it.

We write it ourselves because the constraints line up against every alternative. The edits are proposed one at a time against arbitrary uploaded contracts rather than diffed between two versions we own. Each edit needs its own anchored margin comment carrying the reason for it. Generation happens in memory inside a Linux service with no Word available and nothing ever written to disk. And precision matters more than convenience, because a lawyer is going to click Accept on this and send it to the other side.

If your constraints look like that, the fifteen lines above are the whole foundation. Everything else in this post is the list of ways that foundation quietly fails.

New legal AI guides, weekly.

Priyansh Khodiyar

Priyansh Khodiyar

Co-Founder & CTO

Priyansh leads engineering and AI at Vaquill, from the matter workbench to drafting, document comparison, document matrix, and citation-verified research.

Research, review, and draft, with a source on every answer.

Vaquill AI reads your documents and knows the law. Every answer shows where it came from. 7-day free trial.