Build a Word Report with Tables from Excel Data
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
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
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.
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:
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.
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:
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:
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.
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:
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
autofitoff. - Header not repeating. Add the
w:tblHeaderflag to the first row. - Landscape section that is still portrait-sized. Swap
page_widthandpage_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.
Related
- Up one level: Generating Excel Reports from Templates — reports from templates and data.
- Generate Word Documents from Excel Rows with Python — one document per row.
- Convert Word Document Tables to Excel with Python — the reverse direction.
- Create a PDF Report from Excel Data with ReportLab — when the output should be a PDF.
More on report layouts & templates
- GuideBuilding Finance and Operations Reports in Excel with Python
- How-toAllocate Shared Costs Across Departments with Python
- How-toBuild a Bank Reconciliation Report with Python and Excel
- How-toBuild a Bill of Materials Cost Rollup
- How-toBuild a Break-Even Analysis in Excel with Python
- ComparisonBuild a Budget vs Actual Variance Report in Excel with Python