How-to
Automating Reporting WorkflowsDeep dive

Build a Word Report with Tables from Excel Data

Turn Excel data into a Word report with python-docx: headings and generated commentary, DataFrames written as properly styled Word tables with right-aligned numbers, fixed column widths and a header row that repeats on every page, a landscape section for a wide table, a chart image with a caption, and checks of the finished document.

Plenty of reporting still ends in Word: a board paper, a monthly management report, a client review — documents with prose around the numbers, which people want to edit, comment on and track changes in before they go out. Copying tables from Excel into Word by hand every month is slow, error-prone and produces tables that break across pages badly. python-docx can write the whole document from your data instead. This page reads an Excel workbook with pandas, writes a report with headings and commentary generated from the figures, converts DataFrames into Word tables that look deliberate — a table style, right-aligned numbers, fixed column widths, a header row repeated on every page — puts a wide table in its own landscape section, inserts a chart with a caption, and checks the result. For one document per row — letters and statements — see Generate Word Documents from Excel Rows with Python; this page is about one report built from a whole dataset. It belongs to Generating Excel Reports from Templates.

Prerequisites

Bash
pip install python-docx pandas openpyxl matplotlib

The input is a workbook with one row per region and month — revenue, cost and order counts for six months. LibreOffice is used at the end only to render the document to PDF for a page-level check.

Step 1 — Summarise the data

Python
import pandas as pd

monthly = pd.read_excel("regional_h1.xlsx", sheet_name="Monthly")
monthly["Margin"] = monthly["Revenue"] - monthly["Cost"]
summary = (monthly.groupby("Region", as_index=False)[["Revenue", "Cost", "Margin", "Orders"]].sum()
           .sort_values("Revenue", ascending=False))
summary["Margin %"] = summary["Margin"] / summary["Revenue"]
wide = monthly.pivot_table(index="Region", columns=monthly["Month"].dt.strftime("%b"), values="Revenue", sort=False)
total = summary["Revenue"].sum()
print(summary.round(3).to_string(index=False))

Central and South are almost level on revenue, but South's margin is ten points higher — the kind of contrast the commentary should draw out. The summary table and the month-by-region pivot are ordinary DataFrames. Everything the document says will come from them — including the sentences — so the document can never contradict its own tables.

Word, PDF or the workbook itself If readers will edit, comment on or track changes in the report before it goes out, produce a Word document. If it is final and must look identical everywhere, produce a PDF. If readers need to work with the numbers, send the workbook, possibly alongside either. What will readers do with it? edit and comment Word document python-docx read only PDF fixed layout analyse the workbook with or alongside

Step 2 — A helper that writes a DataFrame as a Word table

python-docx works at the level of rows and cells, so a small helper does the conversion once, with every formatting decision in one place:

Python
from docx import Document
from docx.enum.table import WD_TABLE_ALIGNMENT
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 repeat_header(row):
    tr_pr = row._tr.get_or_add_trPr()
    flag = OxmlElement("w:tblHeader")
    flag.set(qn("w:val"), "true")
    tr_pr.append(flag)

def df_to_table(doc, df, formats, widths_cm, style="Light Grid Accent 1"):
    table = doc.add_table(rows=1, cols=len(df.columns))
    table.style, table.alignment, table.autofit = style, WD_TABLE_ALIGNMENT.CENTER, False
    for cell, name in zip(table.rows[0].cells, df.columns):
        cell.text = str(name)
    repeat_header(table.rows[0])
    for values in df.itertuples(index=False):
        cells = table.add_row().cells
        for cell, name, value in zip(cells, df.columns, values):
            fmt = formats.get(name)
            cell.text = fmt.format(value) if fmt else str(value)
            if fmt:
                cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
    for row in table.rows:
        for cell, width in zip(row.cells, widths_cm):
            cell.width = Cm(width)
            for run in cell.paragraphs[0].runs:
                run.font.size = Pt(9)
    for cell, name in zip(table.rows[0].cells, df.columns):
        if name in formats:
            cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
    return table

A few details make the difference between a table that looks pasted and one that looks designed. A built-in table style (Light Grid Accent 1 is one of those in python-docx's default template) gives consistent borders and a shaded header. Numbers are formatted to strings with a format per column and right-aligned, header included, so the column's figures line up. Widths are set on every cell — Word takes column widths from its cells, and setting only the column object is not reliably honoured — with autofit off so Word does not resize them. And the w:tblHeader flag on the first row, which python-docx has no property for, makes Word repeat that row at the top of every page the table runs onto.

From DataFrame to Word table The summary DataFrame holds raw numbers. The Word table shows them as text formatted per column, right-aligned, with a styled header row that repeats on each page and fixed column widths. A B C 1 Region Revenue Margin % 2 Central 793568.62 0.3539 3 South 790641.19 0.447 A B C 1 Region Revenue Margin % 2 Central 793,569 35.4% 3 South 790,641 44.7% before after df_to_table

Step 3 — Write the report

Headings structure the document (and its navigation pane and table of contents); paragraphs carry commentary generated from the summary; the tables come from the helper:

Python
from docx.enum.section import WD_ORIENT, WD_SECTION

doc = Document()
doc.core_properties.title, doc.core_properties.author = "Regional performance, April–September 2026", "Finance"
doc.add_heading("Regional performance, April–September 2026", level=0)
best, worst = summary.iloc[0], summary.sort_values("Margin %").iloc[0]
doc.add_paragraph(
    f"Revenue for the half-year was {total:,.0f} across {len(summary)} regions. {best.Region} led with "
    f"{best.Revenue:,.0f} ({best.Revenue / total:.0%} of the total); {worst.Region} had the lowest margin at {worst['Margin %']:.1%}.")
doc.add_heading("Summary by region", level=1)
df_to_table(doc, summary, {"Revenue": "{:,.0f}", "Cost": "{:,.0f}", "Margin": "{:,.0f}", "Orders": "{:,}", "Margin %": "{:.1%}"},
            [3.0, 2.6, 2.6, 2.6, 2.0, 2.2])

landscape = doc.add_section(WD_SECTION.NEW_PAGE)
landscape.orientation = WD_ORIENT.LANDSCAPE
landscape.page_width, landscape.page_height = landscape.page_height, landscape.page_width
doc.add_heading("Monthly revenue by region", level=1)
month_table = wide.reset_index()
df_to_table(doc, month_table, {m: "{:,.0f}" for m in wide.columns}, [3.2] + [2.8] * len(wide.columns))
print(len(doc.tables), "tables;", len(doc.sections), "sections")

The month-by-region table is wider than a portrait page can hold comfortably, so it goes in a new section with landscape orientation. Changing orientation alone is not enough: python-docx does not swap the page dimensions, so the width and height are exchanged explicitly. Sections also carry their own headers, footers and margins, which is how a report mixes portrait narrative with landscape appendices.

Step 4 — A chart with a caption

Charts are inserted as images. Render a matplotlib figure to memory at a resolution that prints well and size it to the page:

Python
from io import BytesIO
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(8, 3.2))
for region, row in wide.iterrows():
    ax.plot(list(wide.columns), row.values / 1000, marker="o", label=region)
ax.set_ylabel("Revenue (thousands)")
ax.legend(ncol=5, fontsize=8, loc="upper center", bbox_to_anchor=(0.5, -0.12), frameon=False)
fig.tight_layout()
png = BytesIO()
fig.savefig(png, format="png", dpi=200)
plt.close(fig)

doc.add_picture(png, width=Cm(22))
caption = doc.add_paragraph("Figure 1. Monthly revenue by region, April–September 2026.", style="Caption")
doc.save("regional_report.docx")
print(f"chart image: {png.getbuffer().nbytes / 1024:.0f} KB")

The chart image is about 120 KB. At 200 dpi the chart stays sharp when printed and the image remains small. The Caption style is Word's own, so captions look consistent and can be collected by Word into a table of figures. Keep chart images out of table cells: they make row heights unpredictable and are awkward to edit. If readers would rather have charts they can restyle in Word, a native chart needs a library beyond python-docx; an image is the dependable default, and the same figure can go into the workbook as shown in Embed a matplotlib Chart in an Excel Report.

The structure of the generated report Section one is portrait with a title, generated commentary and the summary table. Section two is landscape with the monthly revenue table and the chart with its caption. Headings give the document its navigation structure. Section 1 (portrait) title, commentary, summary table Section 2 (landscape) monthly revenue table Chart 200 dpi PNG, Caption style Properties title and author

Step 5 — Check the document

Open the saved file again and check what matters, then render it to PDF to see how the tables fall on pages:

Python
import subprocess
import pdfplumber

check = Document("regional_report.docx")
t1, t2 = check.tables
print("summary rows:", len(t1.rows) - 1, "| header repeats:", t1.rows[0]._tr.trPr.find(qn("w:tblHeader")) is not None)
print("numbers right-aligned:", all(c.paragraphs[0].alignment == WD_ALIGN_PARAGRAPH.RIGHT for c in t1.columns[1].cells))
print("section 2:", check.sections[1].orientation, f"{check.sections[1].page_width.cm:.1f} x {check.sections[1].page_height.cm:.1f} cm")
subprocess.run(["soffice", "--headless", "--convert-to", "pdf", "regional_report.docx"], check=True, capture_output=True, timeout=180)
with pdfplumber.open("regional_report.pdf") as pdf:
    print("PDF pages:", len(pdf.pages), "| page 2 is landscape:", pdf.pages[1].width > pdf.pages[1].height)

The summary has one row per region with the header flagged to repeat; every cell in the Revenue column is right-aligned; the second section is landscape, 27.9 by 21.6 cm — US Letter turned sideways, the size of python-docx's default template; set the page size explicitly for A4. The PDF rendering has the landscape page where expected. The whole document fits on two pages: the portrait summary and the landscape appendix with the monthly table and chart. Checks like these run in the job, and the PDF rendering can be attached for readers who do not need to edit the document.

Common pitfalls

  • Column widths ignored. Set the width on every cell and turn autofit off.
  • Header not repeating. Add the w:tblHeader flag to the first row.
  • Landscape section that is still portrait-sized. Swap page_width and page_height.
  • Very large tables. python-docx slows down with thousands of rows; summarise or attach the workbook.
  • Default page size. python-docx's template is US Letter; set A4 if that is your standard.

Conclusion

To build a Word report from Excel data, summarise the workbook with pandas, generate commentary from the same figures, and write each DataFrame through one helper that applies a table style, formats and right-aligns numbers, fixes column widths on every cell and flags the header row to repeat. Put wide tables in landscape sections with swapped page dimensions, insert charts as 200 dpi images with captions, and reopen the document — and its PDF rendering — to check the result.

Frequently asked questions

How do I write a pandas DataFrame to a Word table? Create a table with python-docx, fill the header from the column names, then add a row per DataFrame row, formatting each value as text; a helper function keeps the formatting consistent.

How do I repeat a table header row on every page in python-docx? Add a w:tblHeader element with value true to the first row's properties; python-docx has no direct property for it.

Why are my python-docx column widths ignored? Word reads widths from the cells, so set cell.width on every cell in the column and set table.autofit to False.

How do I make one page landscape in a Word document with Python? Add a new section, set its orientation to landscape and swap its page_width and page_height.

More on report layouts & templates

More on report layouts & templates →