Extract Comments and Tracked Changes from DOCX

Legal returns a contract with thirty comments and a hundred tracked edits, and the request is a spreadsheet: every comment with its author, date and the sentence it is attached to, plus every change showing what was inserted or deleted and by whom. python-docx opens the file without complaint, but paragraph.text shows a strange mixture — inserted wording present, deleted wording absent — and there is no obvious way to reach the comments at all. Copying the review pane by hand takes an afternoon and misses the replies.

Root Cause

Review data is split across the package. Comment text lives in a separate part, word/comments.xml, with one w:comment element per comment. The location of each comment lives in the main document as a pair of markers, w:commentRangeStart and w:commentRangeEnd, sharing the comment's id, with the commented text between them — possibly spanning several runs or paragraphs. Replies and resolved status are stored in yet another part, word/commentsExtended.xml, linked by paragraph ids. Tracked changes are inline: inserted runs are wrapped in w:ins, and deleted runs are wrapped in w:del with their text in w:delText instead of w:t. python-docx's text property reads w:t elements, so it includes inserted text and silently omits deleted text — which is neither the original nor a clearly defined "accepted" view once formatting changes and moved text are involved. Extracting review data means reading these structures directly.

Minimal Diagnostic

Count the review structures so you know what the document actually contains before writing the export.

# pip install lxml
import zipfile
from pathlib import Path
from lxml import etree

SOURCE = Path("in/contract-review-v3.docx")
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
NS = {"w": W}

def review_inventory(path: Path) -> None:
    try:
        zf = zipfile.ZipFile(path)
    except (OSError, zipfile.BadZipFile) as exc:
        raise SystemExit(f"not a readable .docx: {exc}")
    with zf:
        names = set(zf.namelist())
        body = etree.fromstring(zf.read("word/document.xml"))
        print("parts:", sorted(n for n in names if "comment" in n.lower()))
        if "word/comments.xml" in names:
            comments = etree.fromstring(zf.read("word/comments.xml"))
            print("comments:", len(comments.findall("w:comment", NS)))
        print("comment ranges in body:", len(body.findall(".//w:commentRangeStart", NS)))
        print("insertions:", len(body.findall(".//w:ins", NS)),
              "deletions:", len(body.findall(".//w:del", NS)),
              "moves:", len(body.findall(".//w:moveFrom", NS)) + len(body.findall(".//w:moveTo", NS)),
              "format changes:", len(body.findall(".//w:rPrChange", NS)))
        authors = {el.get(f"{{{W}}}author") for el in body.iter()
                   if el.get(f"{{{W}}}author")}
        print("authors:", sorted(authors))

if __name__ == "__main__":
    review_inventory(SOURCE)
parts: ['word/comments.xml', 'word/commentsExtended.xml', 'word/commentsIds.xml']
comments: 34
comment ranges in body: 31
insertions: 58 deletions: 47 moves: 4 format changes: 12
authors: ['A. Patel', 'Legal Review', 'M. Chen']

Thirty-four comments but thirty-one ranges: three are replies, which have no range of their own. Moves and formatting changes will need a decision too.

Where review data lives in the package The docx package feeds four sources. word/comments.xml holds comment text, author and date. document.xml holds commentRangeStart and End markers around the commented text. commentsExtended.xml holds reply parent links and resolved status. document.xml also holds w:ins and w:del wrappers for tracked changes. They combine into a comments sheet and a changes sheet. Reviewed .docx package parts comments.xml text author date Range markers anchored text in body commentsExtended.xml replies and resolved w:ins / w:del tracked edits in body Review workbook comments + changes

Fix: Join Comment Text to Its Anchored Range

Read comment metadata from comments.xml, then walk the body in document order collecting text between each range start and end. Changed lines carry comments.

# pip install lxml "pandas>=2.2"
import zipfile
from pathlib import Path
import pandas as pd
from lxml import etree

W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
W14 = "http://schemas.microsoft.com/office/word/2010/wordml"
W15 = "http://schemas.microsoft.com/office/word/2012/wordml"
NS = {"w": W, "w15": W15}
q = lambda name: f"{{{W}}}{name}"

def comment_table(path: Path) -> pd.DataFrame:
    with zipfile.ZipFile(path) as zf:
        names = set(zf.namelist())
        body = etree.fromstring(zf.read("word/document.xml"))
        comments = etree.fromstring(zf.read("word/comments.xml")) if "word/comments.xml" in names else None
        extended = etree.fromstring(zf.read("word/commentsExtended.xml")) \
            if "word/commentsExtended.xml" in names else None
    if comments is None:
        return pd.DataFrame(columns=["id", "author", "date", "comment", "anchored_text", "parent_id", "resolved"])

    meta = {}
    for c in comments.findall("w:comment", NS):
        paras = c.findall("w:p", NS)
        meta[c.get(q("id"))] = {
            "author": c.get(q("author")), "date": c.get(q("date")),
            "comment": "\n".join("".join(p.itertext()) for p in paras).strip(),
            "para_id": paras[-1].get(f"{{{W14}}}paraId") if paras else None,   # changed: link to replies
        }

    anchored, open_ids = {}, set()
    for el in body.iter():                                                      # changed: document order
        if el.tag == q("commentRangeStart"):
            open_ids.add(el.get(q("id")))
            anchored.setdefault(el.get(q("id")), [])
        elif el.tag == q("commentRangeEnd"):
            open_ids.discard(el.get(q("id")))
        elif el.tag in (q("t"), q("delText")) and open_ids:                     # changed: include deleted text
            for cid in open_ids:
                anchored[cid].append(el.text or "")

    parent_of, resolved = {}, {}
    if extended is not None:
        by_para = {m["para_id"]: cid for cid, m in meta.items() if m["para_id"]}
        for ex in extended.findall("w15:commentEx", NS):
            cid = by_para.get(ex.get(f"{{{W15}}}paraId"))
            if cid is None:
                continue
            resolved[cid] = ex.get(f"{{{W15}}}done") == "1"
            parent = ex.get(f"{{{W15}}}paraIdParent")
            if parent:
                parent_of[cid] = by_para.get(parent)

    rows = [{"id": cid, **{k: v for k, v in m.items() if k != "para_id"},
             "anchored_text": "".join(anchored.get(cid, [])).strip(),
             "parent_id": parent_of.get(cid), "resolved": resolved.get(cid, False)}
            for cid, m in meta.items()]
    return pd.DataFrame(rows)

if __name__ == "__main__":
    try:
        table = comment_table(Path("in/contract-review-v3.docx"))
    except (OSError, KeyError, etree.XMLSyntaxError) as exc:
        raise SystemExit(f"cannot read review data: {exc}")
    print(table[["author", "comment", "anchored_text", "parent_id"]].head().to_string(index=False))

Walking every element with body.iter() preserves document order, so a comment range that starts in one paragraph and ends in the next collects both parts. Replies have no range, so their anchored_text is empty and parent_id points to the comment they answer — fill it from the parent when exporting, so each row in the spreadsheet is self-explanatory.

Comment markers around multi-run text The left panel shows document XML where commentRangeStart with id 7 opens before a run containing payment within, a deleted run containing thirty, an inserted run containing sixty, and a run containing days, followed by commentRangeEnd id 7. The right panel shows the extracted row, with the comment text from comments.xml, the author, and anchored text built from all runs between the markers. document.xml <w:commentRangeStart w:id="7"/> <w:r><w:t>payment within </w:t> <w:del><w:delText>30</w:delText> <w:ins><w:t>60</w:t></w:ins> <w:r><w:t> days</w:t></w:r> <w:commentRangeEnd w:id="7"/> Extracted row id: 7 author: Legal Review comment: Too long - agree 45? anchored: payment within 30 60 days (both views) parent_id: none

Variant Fix 1: List Tracked Changes with Context

Export each insertion and deletion with author, date and the surrounding sentence, so reviewers can read changes without opening Word:

# pip install lxml "pandas>=2.2"
import zipfile
from pathlib import Path
import pandas as pd
from lxml import etree

W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
q = lambda name: f"{{{W}}}{name}"

def tracked_changes(path: Path, context: int = 60) -> pd.DataFrame:
    with zipfile.ZipFile(path) as zf:
        body = etree.fromstring(zf.read("word/document.xml"))
    rows = []
    for p_index, para in enumerate(body.iter(q("p"))):
        accepted = "".join(t.text or "" for t in para.iter(q("t")))
        for change in para:
            if change.tag not in (q("ins"), q("del")):
                continue
            kind = "insert" if change.tag == q("ins") else "delete"
            text_tag = q("t") if kind == "insert" else q("delText")
            text = "".join(t.text or "" for t in change.iter(text_tag))
            rows.append({
                "paragraph": p_index, "kind": kind, "text": text,
                "author": change.get(q("author")), "date": change.get(q("date")),
                "context": accepted[:context] + ("…" if len(accepted) > context else ""),
            })
    return pd.DataFrame(rows)

The loop inspects only direct children of each paragraph, which is where run-level changes live. Changes inside tables are found because table cells contain paragraphs visited by body.iter. Paragraph-mark changes (a whole paragraph inserted) are recorded in w:pPr/w:rPr/w:ins and need a separate check if they matter.

Variant Fix 2: Produce Original or Accepted Text

For comparisons and text mining you often need a clean version of the document with all changes accepted, or the original before review. Build either view by choosing which wrappers to keep:

# pip install lxml
import zipfile
from pathlib import Path
from lxml import etree

W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
q = lambda name: f"{{{W}}}{name}"

def document_text(path: Path, view: str = "accepted") -> str:
    if view not in ("accepted", "original"):
        raise ValueError("view must be 'accepted' or 'original'")
    with zipfile.ZipFile(path) as zf:
        body = etree.fromstring(zf.read("word/document.xml"))
    skip = q("del") if view == "accepted" else q("ins")
    skip_move = q("moveFrom") if view == "accepted" else q("moveTo")
    paragraphs = []
    for para in body.iter(q("p")):
        parts = []
        for el in para.iter(q("t"), q("delText")):
            ancestors = {a.tag for a in el.iterancestors()}
            if skip in ancestors or skip_move in ancestors:
                continue
            if el.tag == q("delText") and view == "accepted":
                continue
            parts.append(el.text or "")
        paragraphs.append("".join(parts))
    return "\n".join(p for p in paragraphs if p.strip())

Moves count as a deletion at the old position and an insertion at the new one, so they are handled by the same choice. For a full semantic diff between versions of the text, feed both views into difflib or into the comparison approach from Comparing and Reconciling Spreadsheets after splitting into sentences.

Which runs each text view keeps Plain runs appear in all three. Inserted runs appear in the accepted view and in paragraph.text but not the original view. Deleted runs appear only in the original view. Moved-from text appears only in the original view, and moved-to text appears in the accepted view and paragraph.text. paragraph.text therefore matches the accepted view for insertions and deletions but is not a defined view in general. Run type Accepted view Original view paragraph.text Plain run kept kept kept w:ins kept dropped kept w:del / delText dropped kept dropped w:moveFrom dropped kept kept as w:t w:moveTo kept dropped kept

Writing the Review Workbook

Combine both tables into one workbook reviewers can filter by author and status. Fill replies' anchored text from their parent comment so each row stands alone:

# pip install "pandas>=2.2" xlsxwriter
from pathlib import Path
import pandas as pd

def write_review(path: Path, dest: Path) -> None:
    comments = comment_table(path)
    parents = comments.set_index("id")["anchored_text"]
    comments["anchored_text"] = comments.apply(
        lambda r: r["anchored_text"] or parents.get(r["parent_id"], ""), axis=1)
    comments["date"] = pd.to_datetime(comments["date"], errors="coerce").dt.tz_localize(None)
    changes = tracked_changes(path)
    changes["date"] = pd.to_datetime(changes["date"], errors="coerce").dt.tz_localize(None)
    dest.parent.mkdir(parents=True, exist_ok=True)
    with pd.ExcelWriter(dest, engine="xlsxwriter", datetime_format="yyyy-mm-dd hh:mm") as writer:
        comments.to_excel(writer, sheet_name="Comments", index=False)
        changes.to_excel(writer, sheet_name="Changes", index=False)
        for ws in writer.sheets.values():
            ws.freeze_panes(1, 0)
            ws.set_column(0, 8, 22)

Comment dates are ISO-8601 strings with a Z suffix; converting them to naive datetimes before writing avoids the Excel timezone error covered in fix Excel does not support timezones error.

Verification

Cross-check counts against the inventory, confirm every non-reply comment has anchored text, and confirm the accepted and original views differ exactly where changes exist:

# pip install lxml "pandas>=2.2"
from pathlib import Path

def verify_review(path: Path) -> None:
    comments = comment_table(path)
    changes = tracked_changes(path)
    top_level = comments[comments["parent_id"].isna()]
    empty = top_level[top_level["anchored_text"] == ""]
    assert empty.empty, f"{len(empty)} top-level comment(s) have no anchored text: ids {list(empty['id'])}"
    accepted, original = document_text(path, "accepted"), document_text(path, "original")
    if changes.empty:
        assert accepted == original, "views differ although no tracked changes were found"
    else:
        assert accepted != original, "tracked changes found but views are identical"
    print(f"{len(comments)} comments ({len(comments) - len(top_level)} replies), {len(changes)} changes verified")

A top-level comment with no anchored text usually means its range markers sit inside a text box or header, outside document.xml's body paragraphs; extend the walk to those parts when the check fires.

FAQ

Does python-docx support comments now? Recent python-docx releases have added comment APIs for creating and reading comments, but reply threads, resolved status and tracked changes still need the XML approach shown here.

Can I accept all tracked changes programmatically? Yes: remove w:del and w:moveFrom elements, unwrap w:ins and w:moveTo (keeping their runs), and drop w:rPrChange/w:pPrChange. Save to a new file and compare views to confirm.

Why are some authors shown as "Author"? Word anonymises names when "Remove personal information from file properties on save" is enabled. The original names are not recoverable from the file.

Are comments in headers and footnotes included? Their range markers live in those parts. Run the range walk over word/header*.xml and word/footnotes.xml as well if reviewers comment there.

Part of Extracting Data from Word Documents with Python.