How-to
Getting Started With Python Excel AutomationDeep dive

Convert Word Document Tables to Excel with Python

Pull every table out of a .docx file into a workbook with python-docx and pandas: one sheet per table named after the heading above it, merged cells that python-docx repeats, line breaks inside cells, prices and percentages stored as text, a whole folder of documents, and old .doc files.

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

Bash
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%.

From a Word document to a workbook The document body is walked in order, each table's cells are read with merged duplicates removed, values are converted from text, and each table is written to its own sheet named after the heading above it. Walk the body headings and tables in order Read cells skip merged duplicates Convert text '£1,200.00' → 1200.0 One sheet per table named after its heading

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:

Python
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:

Python
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.

A merged row repeated by python-docx, and after de-duplication On the left the note row as python-docx returns it, with the note text repeated in every cell. On the right the same row with the note once in the first cell and the other cells empty. A B C 1 Part Price Disc. 2 excl. VAT excl. VAT excl. VAT A B C 1 Part Price Disc. 2 excl. VAT before after cell._tc is seen

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:

Python
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:

Python
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

Python
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)
The Standard parts sheet in price_list.xlsx The first table written to Excel. Unit price is a currency number and Discount a percentage; the note row has been removed. price_list.xlsx A B C D 1 Part Description Unit price Discount 2 HC-100 Hinge, steel £1,200.00 5.0% 3 HC-140 Hinge, stainless (marine grade) £1,480.50 5.0% 4 HC-210 Bracket, small £85.50 0.0% 5 HC-220 Bracket, large £112.00 2.5% Standard parts Delivery charges

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:

Python
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:

Python
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 through cell.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.

More on file formats & conversion

More on file formats & conversion →