mrkeyoor.com_
Thu 06 Aug 07:39 UTC
PyPIDataupdated 06 Aug 2026

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.

Verdict

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.

API stability5/5The Document, Paragraph, Run, and Table objects have looked the same for a decade; 1.0.0 in 2023 only dropped Python 2, and 1.1 and 1.2 added methods without changing existing ones.
Docs4/5Read the Docs carries a task-oriented user guide plus a full API reference and an unusually honest feature-support list, though newer additions such as comments reach the changelog before the guide.
Maintenance3/5Commits landed in August 2026, but the last release was 1.2.0 in June 2025 and the gap before that ran from 2021 to 2023; 371 open issues (504 counting PRs) sit against a project driven by one maintainer.
Ecosystem4/5docxtpl, docx2txt, and docx2pdf all build on or around it, and it is the library that document-generation and LLM tooling reaches for by default; there is no plugin system of its own.

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
Skip it if

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.CENTER

Give 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

PackageRegistryPick it when
docxtplPyPIYou are filling a designer-made Word template with data and Jinja placeholders beat rebuilding the layout in Python.
mammothPyPIYou want content out of .docx as clean HTML or Markdown rather than to author documents.
pypandocPyPIYou need real format conversion, including .docx to PDF or Markdown to .docx.