Building and Editing Word Tables with Python
A generated contract, invoice pack or compliance report is mostly tables, and the table is where document automation usually breaks down. Writing paragraphs with python-docx is straightforward; writing a table that has the right widths, repeats its header across a page break, merges the cells it should merge and keeps the house style is a different job, because Word's table model is a grid of cell objects whose layout is governed by XML that python-docx only partly exposes.
Generic approaches fail here for a specific reason. Converting a DataFrame to HTML and pasting it produces a table Word renders but cannot restyle. Building the table row by row without setting widths lets Word autofit, which silently redistributes columns per page and makes a 40-page pack look like forty different templates. And the properties that matter most — repeat-header, cell merge, borders — live in the underlying XML, so any workflow that avoids touching it hits a wall almost immediately.
Prerequisites
python -m venv .venv
source .venv/bin/activate
pip install "python-docx>=1.1,<2" "pandas>=2.2,<3"
mkdir -p data out
python-docx==1.1.2
pandas==2.2.3
One vocabulary note that saves confusion later. A Table owns rows and columns, but the authoritative storage is the cell grid: table.cell(row, column) always works, whereas iterating row.cells on a table with merged cells returns the merged cell several times, once per grid position it occupies.
Step 1: Inspect the Document Before Editing
When appending to an existing template, find out what tables it already has and which style names are available. Applying a style Word does not know raises KeyError, which is the single most common first failure.
# pip install "python-docx>=1.1,<2"
from pathlib import Path
from docx import Document
TEMPLATE = Path("data/report_template.docx")
def table_report(docx_path: Path) -> None:
"""List tables, their shapes and the table styles the document defines."""
if not docx_path.exists():
raise FileNotFoundError(f"No such document: {docx_path}")
try:
document = Document(str(docx_path))
except Exception as exc:
raise RuntimeError(f"Could not open {docx_path}: {exc}") from exc
for index, table in enumerate(document.tables):
style = table.style.name if table.style else "(none)"
first_row = [cell.text.strip()[:18] for cell in table.rows[0].cells]
print(f"table {index}: {len(table.rows)}x{len(table.columns)} style={style!r}")
print(f" header: {first_row}")
available = sorted(s.name for s in document.styles if s.type == 3) # 3 = table styles
print(f"\ntable styles available: {available[:12]}")
if __name__ == "__main__":
table_report(TEMPLATE)
table 0: 12x4 style='Table Grid'
header: ['Region', 'Units', 'Value', 'Share']
table styles available: ['Light Grid Accent 1', 'Light List', 'Normal Table', 'Table Grid']
A default Document() created from scratch ships a small style set — Table Grid and Normal Table plus a handful of built-ins. Styles such as Light Grid Accent 1 exist only if the template defines them, which is why a script that works against a corporate template fails against a blank document. The failure and its workarounds are covered in fix python-docx table style not found.
Step 2: Write a DataFrame to a Table
The core operation. Build the table once with the right shape, then fill it — repeatedly calling add_row inside the loop is both slower and harder to style.
# pip install "python-docx>=1.1,<2" "pandas>=2.2,<3"
from pathlib import Path
import pandas as pd
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Pt
OUT = Path("out/report.docx")
def frame_to_table(document: Document, frame: pd.DataFrame, style: str = "Table Grid"):
"""Write a DataFrame as a Word table with a bold header row."""
if frame.empty:
raise ValueError("Refusing to write an empty table")
table = document.add_table(rows=len(frame) + 1, cols=len(frame.columns))
try:
table.style = style
except KeyError as exc:
raise KeyError(f"Style {style!r} not in this document; run the style report") from exc
for column_index, name in enumerate(frame.columns):
cell = table.cell(0, column_index)
cell.text = str(name)
for paragraph in cell.paragraphs:
for run in paragraph.runs:
run.bold = True
run.font.size = Pt(10)
for row_index, record in enumerate(frame.itertuples(index=False), start=1):
for column_index, value in enumerate(record):
cell = table.cell(row_index, column_index)
cell.text = "" if pd.isna(value) else f"{value:,.2f}" if isinstance(value, float) else str(value)
if isinstance(value, (int, float)):
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
return table
if __name__ == "__main__":
df = pd.DataFrame({
"Region": ["EMEA", "AMER", "APAC"],
"Units": [1204, 980, 655],
"Value": [98400.0, 74210.5, 51002.25],
})
document = Document()
document.add_heading("Regional summary", level=1)
frame_to_table(document, df)
OUT.parent.mkdir(parents=True, exist_ok=True)
document.save(OUT)
print(f"Wrote {OUT}")
Formatting the values in Python rather than relying on str() is deliberate: Word has no number formats, so 98400.0 would print exactly like that. Right-aligning numerics costs one line and is what makes a generated table look typeset rather than dumped.
Step 3: Fix the Column Widths
Word autofits by default, and autofit ignores per-cell widths unless the table's layout is explicitly fixed. Both parts are required — setting widths alone does nothing.
# pip install "python-docx>=1.1,<2"
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
from docx.shared import Cm
def set_fixed_widths(table, widths_cm: list[float]) -> None:
"""Pin column widths by disabling autofit and writing the width on every cell."""
if len(widths_cm) != len(table.columns):
raise ValueError(f"{len(widths_cm)} width(s) for {len(table.columns)} column(s)")
table.autofit = False
# tblLayout type="fixed" is what makes Word honour the widths below
table_properties = table._tbl.tblPr
layout = OxmlElement("w:tblLayout")
layout.set(qn("w:type"), "fixed")
table_properties.append(layout)
for column_index, width in enumerate(widths_cm):
for row in table.rows:
# Width must be set on every cell, not once per column
row.cells[column_index].width = Cm(width)
if __name__ == "__main__":
set_fixed_widths(table, [4.5, 3.0, 4.0])
Setting the width on each cell rather than on the column object is the part that trips people up. table.columns[i].width exists and appears to work, but Word reads the per-cell w:tcW values, so a column-level assignment is ignored the moment the document is opened.
Step 4: Repeat the Header Across Page Breaks
A table longer than a page needs its header on every page. There is no python-docx property for it; the flag is one XML element on the header row.
# pip install "python-docx>=1.1,<2"
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def repeat_header(table) -> None:
"""Mark row 0 as a header row so Word repeats it after every page break."""
header_row = table.rows[0]
properties = header_row._tr.get_or_add_trPr()
repeat = OxmlElement("w:tblHeader")
repeat.set(qn("w:val"), "true")
properties.append(repeat)
def keep_rows_together(table) -> None:
"""Stop Word splitting an individual row across two pages."""
for row in table.rows:
properties = row._tr.get_or_add_trPr()
cant_split = OxmlElement("w:cantSplit")
properties.append(cant_split)
Both helpers are worth applying to every generated table longer than about fifteen rows. They cost nothing and remove the two most common complaints about machine-produced documents: an orphaned header and a row cut in half by a page break.
Edge Cases and Variants
Merging Cells
merge returns the combined cell and works in both directions. The merged cell keeps the text of the top-left participant, and any text in the others is discarded.
# pip install "python-docx>=1.1,<2"
def add_section_banner(table, row_index: int, text: str) -> None:
"""Merge a whole row into one banner cell spanning the table."""
first = table.cell(row_index, 0)
last = table.cell(row_index, len(table.columns) - 1)
merged = first.merge(last)
merged.text = text
for paragraph in merged.paragraphs:
for run in paragraph.runs:
run.bold = True
Vertical merges follow the same call with row indices instead of columns. The full treatment, including the layout traps that make a merged table render differently in LibreOffice, is in merge table cells with python-docx.
Editing an Existing Table
Updating a template's table in place is usually preferable to rebuilding it, because the template already carries the house style.
# pip install "python-docx>=1.1,<2" "pandas>=2.2,<3"
from docx import Document
import pandas as pd
def refresh_table(docx_path, frame: pd.DataFrame, table_index: int = 0, out_path=None):
"""Replace a table's data rows while keeping its header and styling."""
document = Document(str(docx_path))
table = document.tables[table_index]
# Remove every row below the header, back to front so indices stay valid
for row in list(table.rows)[1:]:
row._tr.getparent().remove(row._tr)
for record in frame.itertuples(index=False):
cells = table.add_row().cells # inherits the table style automatically
for position, value in enumerate(record):
cells[position].text = "" if pd.isna(value) else str(value)
document.save(str(out_path or docx_path))
return out_path or docx_path
Removing rows through row._tr.getparent().remove(row._tr) is the supported idiom — python-docx has no delete_row, and slicing table.rows only changes a Python list, not the document.
Cell Shading
Alternating row shading is an XML fill on the cell properties:
# pip install "python-docx>=1.1,<2"
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def shade_cell(cell, hex_colour: str = "EFF6FF") -> None:
"""Apply a solid background fill to one cell."""
shading = OxmlElement("w:shd")
shading.set(qn("w:val"), "clear")
shading.set(qn("w:color"), "auto")
shading.set(qn("w:fill"), hex_colour) # six hex digits, no leading hash
cell._tc.get_or_add_tcPr().append(shading)
Validation
A Word table is not validated by anything, so assert on the structure you expect after generating it.
# pip install "python-docx>=1.1,<2" "pandas>=2.2,<3"
from pathlib import Path
import pandas as pd
from docx import Document
def assert_table_matches(docx_path: Path, frame: pd.DataFrame, table_index: int = 0) -> None:
"""Reopen the document and check the table's shape and contents."""
document = Document(str(docx_path))
assert len(document.tables) > table_index, f"no table at index {table_index}"
table = document.tables[table_index]
assert len(table.columns) == len(frame.columns), (
f"{len(table.columns)} column(s) in the document, {len(frame.columns)} in the frame"
)
assert len(table.rows) == len(frame) + 1, (
f"{len(table.rows)} row(s) including header, expected {len(frame) + 1}"
)
header = [cell.text.strip() for cell in table.rows[0].cells]
assert header == [str(c) for c in frame.columns], f"header mismatch: {header}"
first_data = [cell.text.strip() for cell in table.rows[1].cells]
assert any(first_data), "first data row is empty — cells were never populated"
print(f"table verified: {len(table.rows) - 1} data row(s), {len(table.columns)} column(s)")
if __name__ == "__main__":
assert_table_matches(Path("out/report.docx"), df)
The empty-first-row assertion catches the merge trap: after a horizontal merge, row.cells returns the merged cell repeatedly, so a naive positional write can put every value into the same cell and leave the rest blank. Checking cell text rather than cell count is what exposes it.
Performance and Scale Notes
python-docx builds the document in memory and writes once, so cost scales with cell count rather than page count. Roughly 10,000 cells per second is typical; a 500-row, 8-column table takes well under a second, and a 50,000-row table takes about half a minute and produces a document Word struggles to open.
Two habits matter at scale. Create the table with its final row count rather than calling add_row per record — each call inserts an XML element into a growing tree, and the difference over thousands of rows is several-fold. And avoid touching cell.paragraphs[0].runs for every cell when the styling is uniform: set it on the table style instead, so the formatting lives in one style definition rather than in tens of thousands of run properties, which also keeps the file size down.
When the output is genuinely large, split it: one document per region or per month, assembled afterwards if a single deliverable is required. That also keeps the docx to PDF conversion step within its timeout.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
KeyError: "no style with name 'Light Grid Accent 1'" | Style not defined in this document | Use Table Grid, or start from a template that defines it |
| Column widths ignored | Autofit still on, or width set on the column | table.autofit = False, tblLayout fixed, width on every cell |
| Header does not repeat on page 2 | tblHeader flag not set | Append w:tblHeader to the header row's trPr |
| A row is split across two pages | Word may break inside rows | Append w:cantSplit to each row's trPr |
| Every value lands in one cell | Positional write after a horizontal merge | Address cells with table.cell(r, c), not row.cells[i] |
Numbers show as 98400.0 | Word has no number formats | Format to string in Python before writing |
| Table looks different in LibreOffice | Autofit plus percentage widths | Use fixed layout and absolute widths |
Complete Working Script
#!/usr/bin/env python3
"""Render a CSV or Excel file as a styled Word table.
pip install "python-docx>=1.1,<2" "pandas>=2.2,<3"
python table_report.py data/regions.csv out/report.docx --title "Regional summary"
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import pandas as pd
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt
def _flag(row, tag: str, value: str | None = None) -> None:
element = OxmlElement(tag)
if value is not None:
element.set(qn("w:val"), value)
row._tr.get_or_add_trPr().append(element)
def build(frame: pd.DataFrame, title: str, dest: Path, widths: list[float] | None) -> Path:
document = Document()
document.add_heading(title, level=1)
table = document.add_table(rows=len(frame) + 1, cols=len(frame.columns))
table.style = "Table Grid"
for position, name in enumerate(frame.columns):
cell = table.cell(0, position)
cell.text = str(name)
for run in cell.paragraphs[0].runs:
run.bold = True
run.font.size = Pt(10)
for row_index, record in enumerate(frame.itertuples(index=False), start=1):
for position, value in enumerate(record):
cell = table.cell(row_index, position)
if pd.isna(value):
cell.text = ""
elif isinstance(value, float):
cell.text = f"{value:,.2f}"
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
elif isinstance(value, int):
cell.text = f"{value:,}"
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
else:
cell.text = str(value)
_flag(table.rows[0], "w:tblHeader", "true")
for row in table.rows:
_flag(row, "w:cantSplit")
if widths:
table.autofit = False
layout = OxmlElement("w:tblLayout")
layout.set(qn("w:type"), "fixed")
table._tbl.tblPr.append(layout)
for position, width in enumerate(widths):
for row in table.rows:
row.cells[position].width = Cm(width)
dest.parent.mkdir(parents=True, exist_ok=True)
document.save(str(dest))
return dest
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="CSV or Excel to a Word table.")
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--title", default="Data")
parser.add_argument("--widths", type=float, nargs="*", help="column widths in cm")
args = parser.parse_args(argv)
if not args.source.exists():
print(f"error: no such file: {args.source}", file=sys.stderr)
return 1
reader = pd.read_excel if args.source.suffix.lower() in {".xlsx", ".xls"} else pd.read_csv
frame = reader(args.source)
if frame.empty:
print("error: source has no rows", file=sys.stderr)
return 1
if args.widths and len(args.widths) != len(frame.columns):
print(f"error: {len(args.widths)} width(s) for {len(frame.columns)} column(s)", file=sys.stderr)
return 1
dest = build(frame, args.title, args.output, args.widths)
print(f"wrote {dest} ({len(frame):,} data row(s))")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Combine it with the templating approach in dynamic mail merge with Python when the table is one section of a larger letter, and with inserting images into Word documents when a chart image sits beside it.
FAQ
Why do my column widths change when the document is opened? Autofit is still active. Word recalculates column widths to fit the content unless the table layout is explicitly fixed and each cell carries a width — both are needed, and setting one without the other has no visible effect.
Can I apply a corporate table style that Word ships but python-docx does not list?
Only if the style is defined in the document you started from. Build from a template .docx that already contains the style, or define it once in a template and reuse that file for every generated document.
How do I add a total row that stays at the bottom? Append it as an ordinary row after the data and style it — bold, shaded, with a top border. Word has no concept of a totals row, so there is nothing to keep it pinned; regenerate the table when the data changes.
Does python-docx support nested tables?
Yes: cell.add_table(rows, cols) creates a table inside a cell. Support in converters is uneven, so check the rendered PDF before relying on it for output that leaves your team.
Why is my merged cell's text repeated in the diagnostic?
Because row.cells returns one entry per grid position, and a merged cell occupies several. Iterate the grid with table.cell(r, c) and deduplicate on the underlying _tc element when you need distinct cells.
Tables That Survive Being Edited
A generated table is rarely the end of the story: somebody opens the document, adds a row, changes a figure and sends it on. Two habits make that survivable.
Avoid encoding meaning in position. If a downstream script re-reads the document, matching on the header text rather than on column index means an inserted column does not silently shift every value. The same applies to the totals row: find it by its label, not by assuming it is last.
And keep the table's structure regular. Merged cells, nested tables and manually split rows all make a document harder for both a human and a parser to edit safely. Where a layout genuinely needs them, put them in a presentation table and keep a plain, regular table elsewhere in the document — or better, keep the data in an attached spreadsheet and let the Word table be a rendering of it.
Related
- Fix python-docx Table Style Not Found — the
KeyErrorontable.style - Merge Table Cells with python-docx — horizontal and vertical merges in depth
- Automating Word Document Creation — paragraphs, headings and document structure
- Dynamic Mail Merge with Python — tables inside templated letters
- Converting DOCX to PDF with Python — turning the finished table into a deliverable