DOCX Authoring
Intro
Use python-docx to generate Word documents programmatically. The
library manipulates the underlying Office Open XML directly through a
Python API. Structure documents with paragraph styles, not ad-hoc
formatting — styles make documents consistent and editable in Word
after generation.
Overview
Setup
# /// script
# dependencies = ["python-docx"]
# ///
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
doc = Document() # blank document
# doc = Document("template.docx") # or start from a template
Install with uv add python-docx or pip install python-docx.
Headings and paragraphs
doc.add_heading("Document Title", level=0) # Title style
doc.add_heading("Section 1", level=1) # Heading 1
doc.add_heading("Subsection", level=2) # Heading 2
p = doc.add_paragraph("Body text here.")
p = doc.add_paragraph("Intro sentence. ", style="Body Text")
run = p.add_run("Bold phrase.")
run.bold = True
run.font.size = Pt(12)
Level 0 maps to the built-in "Title" style; levels 1-9 map to "Heading 1" through "Heading 9". Always use heading levels for structural hierarchy — never bold a paragraph to fake a heading.
Paragraph styles
Prefer named styles over inline formatting. Built-in style names
(case-sensitive): "Normal", "Body Text", "List Bullet",
"List Number", "Quote", "Intense Quote", "Caption".
p = doc.add_paragraph("Quoted passage.", style="Quote")
p = doc.add_paragraph("First bullet point.", style="List Bullet")
doc.add_paragraph("Second bullet point.", style="List Bullet")
To define a custom style, either embed it in a template DOCX or
create it programmatically via doc.styles.add_style().
Tables
table = doc.add_table(rows=1, cols=3, style="Table Grid")
hdr = table.rows[0].cells
hdr[0].text = "Name"
hdr[1].text = "Value"
hdr[2].text = "Unit"
row = table.add_row().cells
row[0].text = "Latency p99"
row[1].text = "42"
row[2].text = "ms"
# Merge cells
table.rows[0].cells[0].merge(table.rows[0].cells[1])
Use table styles ("Table Grid", "Light Shading", "Medium List 1") for
consistent visual treatment. Access cell paragraph properties via
cell.paragraphs[0].paragraph_format.
Images and sections
doc.add_picture("chart.png", width=Inches(5.0))
# Add a page break
doc.add_page_break()
# Section properties (portrait → landscape)
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
section = doc.sections[-1]
section.orientation = WD_ORIENT.LANDSCAPE
section.page_width, section.page_height = section.page_height, section.page_width
Headers, footers, and metadata
section = doc.sections[0]
header = section.header
header.paragraphs[0].text = "Company Confidential"
footer = section.footer
p = footer.paragraphs[0]
p.text = "Page "
p.add_run().add_field("PAGE") # auto-numbered page field via XML injection
doc.core_properties.author = "Generated by Agent"
doc.core_properties.title = "Quarterly Report"
Saving
doc.save("output.docx")
Generate to a BytesIO buffer to avoid writing to disk:
from io import BytesIO
buf = BytesIO()
doc.save(buf)
buf.seek(0)
Document structure checklist
Before saving, verify:
- Title and all heading levels are set via styles, not bold/font-size
- Tables have a named style (never
style=None) - Images have explicit widths (Word's default sizing is unreliable)
core_properties.titleandcore_properties.authorare set- The file opens correctly in Word and LibreOffice
Gotchas
Agent-specific failure modes — provider-neutral pause-and-self-check items:
- Applying bold/font-size directly instead of using paragraph styles. A paragraph made bold with
run.bold = Truelooks like a heading but is not — it breaks the document outline, navigation pane, and PDF export. Usedoc.add_heading(text, level=N)or a named paragraph style for every structural element. - Starting from a blank
Document()when the output must match a corporate template. A blank document uses Word's default styles, which rarely match a client's brand. When the target is a templated document (letterhead, report cover), always open the template withDocument("template.docx")and add content into it. - Not specifying image width.
doc.add_picture("chart.png")withoutwidth=Inches(...)inserts at the image's native resolution, which varies wildly. Always supply an explicitwidthorheightso the image fits predictably within the page margins. - Generating tables with
style=None. Unstyled tables render with no borders and no shading, making them hard to read. Always pass a named table style — at minimum"Table Grid"for a simple bordered table. - Changing page orientation without swapping width and height. Setting
section.orientation = WD_ORIENT.LANDSCAPEalone does not resize the page — it just marks the orientation flag. You must also swapsection.page_widthandsection.page_heightfor the layout to render correctly. - Saving to the same path the document was opened from without a copy.
doc.save("template.docx")overwrites the template used as the base, destroying it for the next run. Always save to a distinct output path, or load the template from a path separate from the output. - Not verifying the output opens in Word and LibreOffice.
python-docxcan produce XML that is technically valid but triggers repair prompts or rendering differences in specific Word versions. Always open the generated file in at least one consumer before shipping.
Full reference
Page margins and layout
from docx.shared import Inches
section = doc.sections[0]
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
Cell formatting
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def set_cell_background(cell, hex_color):
tc = cell._tc
tcPr = tc.get_or_add_tcPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), hex_color)
shd.set(qn("w:val"), "clear")
tcPr.append(shd)
set_cell_background(table.rows[0].cells[0], "4472C4")
Numbered lists with custom start
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
p = doc.add_paragraph("Step one.", style="List Number")
# To restart numbering or set start: manipulate numPr XML directly
Hyperlinks
python-docx does not have a native hyperlink API. Inject via XML:
from docx.opc.constants import RELATIONSHIP_TYPE as RT
def add_hyperlink(paragraph, text, url):
part = paragraph.part
r_id = part.relate_to(url, RT.HYPERLINK, is_external=True)
hyperlink = OxmlElement("w:hyperlink")
hyperlink.set(qn("r:id"), r_id)
run = OxmlElement("w:r")
rPr = OxmlElement("w:rPr")
rStyle = OxmlElement("w:rStyle")
rStyle.set(qn("w:val"), "Hyperlink")
rPr.append(rStyle)
run.append(rPr)
t = OxmlElement("w:t")
t.text = text
run.append(t)
hyperlink.append(run)
paragraph._p.append(hyperlink)
return hyperlink
Conversion to PDF
python-docx cannot produce PDF directly. Options:
libreoffice --headless --convert-to pdf output.docx(requires LibreOffice)docx2pdflibrary (requires Word on macOS/Windows)- Upload to Google Docs API and export as PDF