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.

What python-docx exposes, and what needs XML A Document owns a list of Tables. Each Table exposes rows, columns and a cell grid, plus a style name. Directly supported operations are adding rows, writing cell text, setting a style and merging cells. Operations that require reaching into the underlying XML element include repeating the header row across pages, fixing the table layout so Word does not autofit, setting cell shading and setting cell borders. Document document.tables Table rows, columns, style Cell grid table.cell(r, c) Direct API add_row, cell.text table.style cell_a.merge(cell_b) Needs raw XML repeat header row fixed layout, widths shading and borders .docx on disk word/document.xml Everything on the right is a few lines of OxmlElement — not a reason to abandon python-docx

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)
The four properties that make a long table readable Two page fragments side by side. Page one shows a table with a bold header row, a merged banner row spanning all columns, alternating row shading and fixed column widths. Page two shows the continuation of the same table, where the header row has been repeated at the top because the header row flag was set, and no row has been split across the break. Page 1 Page 2 Region Units Value EMEA totals UK 412 31,004 DE 388 29,880 page break Region Units Value header repeated automatically FR 301 24,110 ES 277 19,455 merged banner + shading tblHeader + cantSplit Fixed widths keep both pages identical — without them Word re-fits each page independently

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.

Pre-sizing the table against calling add_row per record Build time for tables of increasing size. Creating the table at its final row count scales close to linearly, reaching about half a second at five thousand rows. Calling add_row once per record scales worse because each call inserts into a growing XML tree, reaching about two point four seconds for the same table. Build time by construction method pre-sized: 0.5 s add_row loop: 2.4 s 500 rows 5,000 rows Each add_row inserts into a growing tree; creating the grid once avoids the repeated cost

Troubleshooting

SymptomRoot causeFix
KeyError: "no style with name 'Light Grid Accent 1'"Style not defined in this documentUse Table Grid, or start from a template that defines it
Column widths ignoredAutofit still on, or width set on the columntable.autofit = False, tblLayout fixed, width on every cell
Header does not repeat on page 2tblHeader flag not setAppend w:tblHeader to the header row's trPr
A row is split across two pagesWord may break inside rowsAppend w:cantSplit to each row's trPr
Every value lands in one cellPositional write after a horizontal mergeAddress cells with table.cell(r, c), not row.cells[i]
Numbers show as 98400.0Word has no number formatsFormat to string in Python before writing
Table looks different in LibreOfficeAutofit plus percentage widthsUse 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.

Part of Word Document Templating & Batch Processing.

Explore next