Convert Word Document Tables to Excel with Python
Price lists, rate cards, contract schedules and survey results often live in Word documents as tables. When the numbers are needed in a spreadsheet — to compare suppliers, load into a system, or check against invoices — copying and pasting table by table loses formatting, merges cells in odd ways and turns every number into text. python-docx reads a .docx file's tables directly, cell by cell, so a short script can move every table into its own sheet and convert the values on the way. This page is part of Handling Excel File Formats and Conversions.
Prerequisites
pip install python-docx pandas openpyxl
python-docx is installed as python-docx and imported as docx. The example document, price_list.docx, has a title, an introductory paragraph and two tables, each under its own heading. The first table has a note row merged across all four columns, a description that wraps onto a second line, prices like £1,200.00 and discounts like 2.5%.
Step 1 — Read the tables as they are
Document(path).tables lists every top-level table. Each table has rows, each row has cells, and each cell has text:
from docx import Document
doc = Document("price_list.docx")
print(len(doc.tables), "tables")
for row in doc.tables[0].rows:
print([cell.text for cell in row.cells])
Two things stand out. The description cell for HC-140 contains a newline, because it was typed as two lines. And the merged note row appears four times: python-docx returns one cell object per grid column, and for a merged cell every position it covers returns the same content. Written straight to Excel, that row would show the note in four adjacent cells.
Step 2 — Detect merged cells
The cells a merged cell covers all point at the same underlying XML element, cell._tc. Keep a cell's text only the first time its element appears in the row:
def row_values(row):
values, seen = [], []
for cell in row.cells:
if any(cell._tc is tc for tc in seen):
values.append(None) # covered by a merge to the left
else:
seen.append(cell._tc)
values.append(" ".join(cell.text.split()))
return values
for row in doc.tables[0].rows:
print(row_values(row))
Now the note appears once, in the first column, with None in the positions the merge covered — the same shape Excel itself uses for merged cells, where only the top-left cell holds a value. " ".join(cell.text.split()) collapses the line break and any repeated spaces into single spaces, so Hinge, stainless (marine grade) fits in one cell. Compare the elements with is rather than id(): python-docx creates new wrapper objects on every access, and the identity of the XML element is the reliable test. The same check catches cells merged vertically, where the covered positions sit in the rows below.
Step 3 — Name each table after its heading
A document with several tables usually introduces each one with a heading. doc.iter_inner_content() yields paragraphs and tables in the order they appear, so the most recent heading before a table is its title:
import pandas as pd
from docx.table import Table
def tables_with_titles(doc):
title, out = "Table", []
for block in doc.iter_inner_content():
if isinstance(block, Table):
rows = [row_values(r) for r in block.rows]
out.append((title, pd.DataFrame(rows[1:], columns=rows[0])))
elif block.style.name.startswith("Heading"):
title = block.text.strip()
return out
found = tables_with_titles(doc)
for title, frame in found:
print(title, frame.shape)
iter_inner_content arrived in python-docx 1.0; with older versions, walk doc.element.body and wrap w:tbl and w:p elements yourself. Headings are recognised by their paragraph style — Heading 1, Heading 2 and so on. Documents where people used bold text instead of real headings need a different rule, for example "the last non-empty paragraph before the table".
Step 4 — Convert text to numbers
Every cell value is a string. Convert prices and percentages into numbers, and turn the merged note row into something useful — here it is a footnote, so move it out of the data:
def to_number(series):
cleaned = series.str.replace(r"[£$€,\s]", "", regex=True)
pct = cleaned.str.endswith("%")
nums = pd.to_numeric(cleaned.str.rstrip("%"), errors="coerce")
return nums.where(~pct, nums / 100)
sheets = {}
for title, frame in found:
note_rows = frame.iloc[:, 1:].isna().all(axis=1)
notes = frame.loc[note_rows, frame.columns[0]].tolist()
frame = frame.loc[~note_rows].copy()
for col in frame.columns[1:]:
converted = to_number(frame[col])
if converted.notna().all():
frame[col] = converted
sheets[title[:31]] = frame
print(title, "notes:", notes)
print(frame.to_string(index=False), "\n")
The conversion is applied only when every value in the column parses, so the Description column stays text while Unit price and Discount become numbers. 2.5% becomes 0.025, which Excel displays as a percentage once the column has a percent format. Sheet names are cut to 31 characters, Excel's limit — see Fix Invalid Worksheet Name Errors for the characters that are also forbidden.
Step 5 — Write one sheet per table
with pd.ExcelWriter("price_list.xlsx", engine="xlsxwriter") as xw:
money = xw.book.add_format({"num_format": "£#,##0.00"})
pct = xw.book.add_format({"num_format": "0.0%"})
for name, frame in sheets.items():
frame.to_excel(xw, sheet_name=name, index=False)
ws = xw.sheets[name]
for i, col in enumerate(frame.columns):
fmt = pct if col == "Discount" else money if frame[col].dtype == "float64" else None
ws.set_column(i, i, max(12, len(col) + 2, int(frame[col].astype(str).str.len().max()) + 2), fmt)
with pd.ExcelFile("price_list.xlsx") as xl:
print(xl.sheet_names)
Step 6 — A folder of documents
Contracts from several suppliers, each with a "Schedule of rates" table, combine into one comparison sheet by adding the file name as a column:
from pathlib import Path
frames = []
for path in sorted(Path("contracts").glob("*.docx")):
for title, frame in tables_with_titles(Document(path)):
if title == "Schedule of rates":
frames.append(frame.assign(Supplier=path.stem))
rates = pd.concat(frames, ignore_index=True)
rates["Day rate"] = to_number(rates["Day rate"])
print(rates.sort_values("Day rate").to_string(index=False))
Old binary .doc files cannot be opened by python-docx. Convert them first with LibreOffice — soffice --headless --convert-to docx *.doc — and then read the .docx copies.
Step 7 — Check that nothing was missed
doc.tables returns only tables in the main body at the top level. A table nested inside another table's cell, or placed in a page header or footer, is skipped without any warning — and supplier documents do use both, for letterhead contact blocks and for sub-tables inside a schedule. Count what is there before trusting the conversion:
def audit(doc):
nested = sum(len(cell.tables) for t in doc.tables for row in t.rows for cell in row.cells)
in_headers = sum(len(s.header.tables) + len(s.footer.tables) for s in doc.sections)
return {"body": len(doc.tables), "nested": nested, "headers/footers": in_headers}
print(audit(doc))
For this document all the tables are in the body. When nested or headers/footers is not zero, decide whether those tables matter: nested tables are reached through cell.tables and can be read with the same row_values function, and header tables through section.header.tables. Logging the audit alongside each converted file also makes it easy to spot the one document in a batch that was laid out differently.
It is also worth comparing a few values against the document by eye the first time a new template comes through — a price in the wrong column is a plausible number, and no type check will catch it.
Common pitfalls
- Merged cells repeated. python-docx returns the merged content in every position it covers; de-duplicate by the cell's XML element.
- Nested tables. A table inside a cell is not in
doc.tables; reach it throughcell.tables. - Tables in headers, footers and text boxes. These are outside the document body and not returned by
doc.tables. - Numbers left as text. Every value arrives as a string; convert columns explicitly, and only when every value parses.
- Tracked changes. python-docx reads the XML as stored and does not interpret revisions, so a document that is mid-review can produce text that matches neither the old nor the new version. Accept or reject the changes in Word first.
Performance notes
python-docx loads the whole document into memory, but Word documents are small compared with workbooks, and a typical price list or contract opens in a fraction of a second. The slow part of a batch job is usually opening thousands of files, which parallelises well with a process pool. cell.text is recomputed from the XML on every access, so read it once per cell, as row_values does, rather than repeatedly in a loop.
Conclusion
Walk the document body with iter_inner_content() to pair each table with the heading above it, read cells row by row while skipping positions that belong to a merge, collapse line breaks, and convert money and percentage columns only when every value parses. Write one sheet per table with suitable number formats, and loop over a folder to build comparisons across documents.
Frequently asked questions
How do I extract tables from a Word document with Python? Install python-docx, open the file with Document("file.docx") and loop over doc.tables; each table has rows, each row has cells, and each cell has text. Build a DataFrame from the rows and write it with to_excel.
Why does merged cell text appear several times? python-docx returns one cell per grid column, and every position a merged cell covers returns the same content. Compare each cell's underlying element (cell._tc) with the ones already seen in the row and keep the text only once.
Can python-docx read .doc files? No, only .docx. Convert .doc files to .docx with LibreOffice in headless mode, or with Word through COM on Windows, and read the converted copies.
How do I keep the table titles when converting to Excel? Iterate over doc.iter_inner_content(), which yields paragraphs and tables in document order, and remember the last heading paragraph seen before each table. Use it as the sheet name, cut to 31 characters.
Related
- Up one level: Handling Excel File Formats and Conversions — every format that ends up in a workbook.
- Generate Word Documents from Excel Rows — the opposite direction.
- Extract Tables from a PDF into Excel with Python — when the document arrives as a PDF.
- Convert Percentage Strings to Numbers with pandas — the percentage rules in detail.
More on file formats & conversion
- How-toConvert Excel Files to Parquet with Python
- How-toConvert Excel to CSV with Python
- ComparisonExcel vs CSV vs Parquet for Python Data Pipelines
- ComparisonPick an Excel Engine for .xlsx, .xlsm, .xls, .xlsb and .ods
- FixFix Garbled Characters Like "Café" and "’" in Excel Data
- How-toExtract VBA Macro Code from an Excel File