python-docx
python-docx reads, creates, and edits Microsoft Word .docx files from Python. It gives you an object model over the underlying Office Open XML: a Document holds paragraphs, tables, sections, and inline shapes, a Paragraph holds runs, and a run is the smallest chunk of text that can carry its own formatting. You import docx, open or create a document, add or change content, and call save(). Under the hood it is lxml manipulating the XML parts inside the zip archive that a .docx really is, which is also why its limits are the limits of that file format rather than of Word itself.
The default and effectively only serious way to write Word files from Python, and it does that job cleanly. Treat it as a document-content library rather than a document-layout one: the moment your requirements mention pages, PDFs, or footnotes you are combining it with something else.
Use it if
- You generate Word documents that a human will open and edit afterwards: reports, contracts, letters, meeting minutes where .docx is the required handoff format
- You need to read structured content out of existing Word files, such as pulling every table out of a folder of submitted forms
- You want to start from a company template so that heading styles, fonts, and page setup come from the .docx rather than from code
- You need to modify an existing document in place, changing text in specific runs while leaving the rest of the formatting untouched
- You need a PDF: python-docx has no rendering engine and cannot convert anything, so you are shelling out to LibreOffice, Word, or pypandoc for that step anyway
- You care about pages: Word decides pagination at render time, so there is no page count, no page numbers, and no way to know which page a paragraph will land on; the API only exposes page breaks Word already wrote into the file
- The input is a legacy .doc, .rtf, or .odt file: only the 2007-and-later .docx format is supported, and there is no converter in the box
- You need footnotes, endnotes, charts, or track-changes revisions: these are long-standing gaps, and the workaround is dropping to lxml against raw w: elements yourself
- You expect frequent releases: 1.2.0 landed in June 2025 and the previous feature release was 1.1.0 in November 2023, against 371 open issues, so a bug that blocks you may be yours to patch
Setup reality
pip install python-docx pulls lxml, which means a C extension; wheels exist for mainstream platforms so most people never notice, but on an unusual architecture or a slim container without build tools this is the install that fails. Note the import name is docx, not python_docx, and the abandoned docx package on PyPI is a different project that will shadow it if someone installs both. Beyond that, most of the real setup cost is conceptual: text lives in runs, not paragraphs, so replacing a phrase that Word split across three runs takes work; styles must already exist in the document you opened, so a template .docx is usually the practical starting point rather than a blank Document(); and document.paragraphs walks only the top-level body, skipping anything inside tables, headers, and footers.
Patterns
Create a document and save itcreate-document
from docx import Document
doc = Document()
doc.add_heading("Quarterly Report", level=1)
doc.add_paragraph("Revenue grew in every region.")
doc.add_paragraph("North America", style="List Bullet")
doc.add_paragraph("EMEA", style="List Bullet")
doc.save("report.docx")The import name is docx even though the package is python-docx. Style names like List Bullet must exist in the document's style set, which the default template provides.
Read text and tables out of an existing fileread-document
from docx import Document
doc = Document("submitted.docx")
for p in doc.paragraphs:
if p.text.strip():
print(p.style.name, "|", p.text)
for table in doc.tables:
for row in table.rows:
print([cell.text for cell in row.cells])doc.paragraphs covers only the top-level body. Paragraphs inside table cells, headers, and footers are not in that list and have to be reached through the table or section objects.
Mix formatting inside one paragraphformat-runs
from docx.shared import Pt, RGBColor
p = doc.add_paragraph("Status: ")
run = p.add_run("OVERDUE")
run.bold = True
run.font.size = Pt(14)
run.font.color.rgb = RGBColor(0xC0, 0x00, 0x00)
p.add_run(" (invoice 1042)")Formatting lives on runs, not paragraphs. This is also why find-and-replace on existing documents is awkward: Word often splits a single visible phrase across several runs.
Start from a corporate templateuse-template
doc = Document("templates/corporate.docx")
# reuse styles defined in that file
doc.add_paragraph("Executive Summary", style="Heading 1")
doc.add_paragraph("Signed off by Legal.", style="Intense Quote")
for style in doc.styles:
print(style.type, style.name)Opening a template keeps its styles, fonts, headers, and page setup. Passing an unknown style name raises KeyError, so print the style list once when adopting a new template.
Write a table with a header rowbuild-table
rows = [("EMEA", 120_400), ("APAC", 98_100)]
table = doc.add_table(rows=1, cols=2)
table.style = "Table Grid"
hdr = table.rows[0].cells
hdr[0].text = "Region"
hdr[1].text = "Revenue"
for region, revenue in rows:
cells = table.add_row().cells
cells[0].text = region
cells[1].text = f"{revenue:,}"cell.text = value replaces the cell contents with a single unformatted run; use cell.paragraphs[0].add_run() when you need bold or color inside a cell.
Insert a picture at a fixed widthinsert-image
from docx.shared import Inches
doc.add_picture("chart.png", width=Inches(6))
# centre it
from docx.enum.text import WD_ALIGN_PARAGRAPH
doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTERGive width or height, not both, and the aspect ratio is preserved. With neither, the image is placed at its native pixel size, which is usually far too large on a letter page.
Switch a section to landscapepage-setup
from docx.enum.section import WD_ORIENT
from docx.shared import Inches
section = doc.sections[0]
section.orientation = WD_ORIENT.LANDSCAPE
section.page_width, section.page_height = section.page_height, section.page_width
section.left_margin = Inches(0.75)
section.right_margin = Inches(0.75)Setting orientation alone does nothing visible; Word reads the actual page_width and page_height, so you have to swap them yourself. This trips up nearly everyone once.
Set a header and footerheader-footer
section = doc.sections[0]
section.header.paragraphs[0].text = "Acme Ltd. Confidential"
section.footer.paragraphs[0].text = "Generated automatically"
section.different_first_page_header_footer = True
section.first_page_header.paragraphs[0].text = ""There is no API for automatic page numbers in the footer, because that is a Word field the library does not build; you either inject the field XML by hand or accept static footer text.
Return a document from a web handlerin-memory-output
from io import BytesIO
buf = BytesIO()
doc.save(buf)
buf.seek(0)
return Response(
buf.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": 'attachment; filename="report.docx"'},
)save() accepts any binary file-like object, so nothing needs to touch disk. Remember the seek(0) or you will serve a zero-byte file.
Remove a paragraph from a documentdelete-paragraph
def delete_paragraph(paragraph):
element = paragraph._element
element.getparent().remove(element)
paragraph._p = paragraph._element = None
for p in list(doc.paragraphs):
if p.text.startswith("DRAFT"):
delete_paragraph(p)There is no public delete API, so this reaches into the lxml element behind the object. Iterate over a copy of the list, since removing elements while walking doc.paragraphs skips entries.
Walk paragraphs and tables in document orderiterate-in-order
from docx.table import Table
from docx.text.paragraph import Paragraph
for block in doc.iter_inner_content():
if isinstance(block, Paragraph):
print("P:", block.text)
elif isinstance(block, Table):
print("T:", len(block.rows), "rows")doc.paragraphs and doc.tables are two separate lists with no ordering between them; iter_inner_content is the only way to know that a table sat between two specific paragraphs.
Anchor a review comment to textadd-comment
para = doc.add_paragraph("The deadline is ")
run = para.add_run("31 December")
doc.add_comment(
run,
text="Confirm this against the signed SOW.",
author="Legal Review",
initials="LR",
)Comments arrived in 1.2.0 and anchor to runs, not to arbitrary character offsets, so you may need to split text into its own run first to get the highlight range you want.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| docxtpl | PyPI | You are filling a designer-made Word template with data and Jinja placeholders beat rebuilding the layout in Python. |
| mammoth | PyPI | You want content out of .docx as clean HTML or Markdown rather than to author documents. |
| pypandoc | PyPI | You need real format conversion, including .docx to PDF or Markdown to .docx. |