Choosing a document library, from Python

docx4j from Python

python-docx and docxtpl are good libraries — and for many jobs you should simply use them. This page is about the jobs where they run out, and how cheaply a Python system can borrow docx4j's capabilities across the JVM boundary.

The decision rule

If the requirement is fill a template, simple formatting, extract text — stay native. The moment it includes faithful PDF output without Office, structured data binding, document surgery beyond a curated API, or high-volume generation, bridging to docx4j is less total effort than fighting the format in Python: you're buying twenty years of edge-case handling for the cost of a process boundary.

What bridging buys you

These are the capabilities with no real Python-native equivalent:

  • Headless docx → PDF, no Word, no LibreOffice. docx4j converts via XSL-FO and Apache FOP, including Tagged PDF, PDF/A and PDF/UA output. The Python options are docx2pdf (requires Word), LibreOffice headless (fidelity and operational pain), or paid conversion APIs.
  • Structured data binding (OpenDoPE). Content-control binding with repeats, conditions, position conditions and template composition — and a reverter for round-tripping edited documents. docxtpl's Jinja-tags-in-runs approach is string templating inside a format that fights it; there is no Python library in this territory.
  • The whole file format, addressable. docx4j's object model is generated from the OpenXML schemas — content controls, numbering, fields, comments, tracked changes, DrawingML — where python-docx exposes a curated subset and hands you raw lxml for the rest.
  • The surrounding machinery. docx → HTML, XHTML import, diffing, anonymization, TOC generation and field updating, merging — plus pptx and xlsx in the same object model.

The routes across the boundary

JPype — in-process JVM
A JVM inside the Python process; Java classes look like Python objects. The usual choice for scripts, notebooks and batch jobs, and what the example below uses. One JVM per process (it can't be restarted), and its memory sits alongside Python's.
A small service or CLI wrapper
The production answer: wrap the docx4j operations you need behind a tiny HTTP service or CLI and call it like any other service — clean process boundary, warm JVM, independent scaling.
Py4J
Socket gateway to a separately-running JVM (PySpark's mechanism); the JVM long-lived but out of process, without designing a REST API.
GraalPy
Python on GraalVM with direct Java interop; check your C-extension needs before committing. (Jython is Python 2 only — avoid.)

A taste: OpenDoPE data binding in twelve lines

Tested with Python 3.14, JPype 1.7.1 and Java 21, against docx4j's single self-contained bundle jar:

import jpype, jpype.imports
jpype.startJVM(classpath=["docx4j-bundle-17.0.4-shaded.jar"])

from java.io import File
from org.docx4j.openpackaging.packages import WordprocessingMLPackage
from org.docx4j.model.datastorage import OpenDoPEHandler, BindingHandler

pkg = WordprocessingMLPackage.load(File("invoice-template.docx"))
odh = OpenDoPEHandler(pkg); pkg = odh.preprocess()   # repeats + conditions
bh = BindingHandler(pkg)                             # data binding
bh.setStartingIdForNewBookmarks(odh.getNextBookmarkId())
bh.applyBindings(pkg.getMainDocumentPart())
pkg.save(File("invoice-out.docx"))

That's a repeating-row invoice template, expanded and populated from its XML data — the kind of job that in docxtpl means hand-managing Jinja tags inside table rows.

Full setup — classpath assembly, the PDF configuration, logging, practical notes — is maintained with the code: docs/Docx4j_from_Python.md in the docx4j repository.

Frequently asked questions

Is there a single jar I can point JPype at?
Yes — the docx4j-bundle shaded jar is self-contained for loading, manipulating, binding and saving docx, pptx and xlsx (it includes the JAXB runtime from docx4j 17.0.4 on). PDF output additionally needs docx4j-export-fo and its dependencies.
What does the JVM cost me operationally?
Startup of a second runtime (start it once per process), its heap alongside Python's, and Java exceptions to translate at the boundary. For production loads, the service-wrapper route moves all of that out of your Python processes entirely.
Why not just run LibreOffice headless for PDF?
It works until it doesn't: conversion fidelity varies with the LibreOffice version, and a crashed soffice process is a familiar operational headache. FOP-based conversion is a library call — deterministic, headless, and configurable down to PDF/A and PDF/UA.
Where does the "twenty years of edge-case handling" claim come from?
docx4j has been developed continuously since 2007, in the open — the repository, its history and its issue record are all inspectable. See why that openness matters more than ever.

See also