Convert PDF to DOCX and DOCX to PDF Using Python

For PDF → DOCX, use the pdf2docx library (Converter(pdf).convert(docx)); it works anywhere Python runs but only on PDFs that contain real text. For DOCX → PDF, use docx2pdf if Microsoft Word is installed (Windows or macOS), or call LibreOffice headless (soffice --headless --convert-to pdf) through subprocess, which is the reliable choice on Linux servers and in containers.
Which tool for which direction
| Direction | Tool | Platforms | Requires | Notes |
|---|---|---|---|---|
| PDF → DOCX | pdf2docx | Windows, macOS, Linux | pip only (pulls in PyMuPDF) | Reconstructs layout heuristically; no OCR |
| DOCX → PDF | docx2pdf | Windows, macOS | Microsoft Word installed | Best fidelity, since Word itself renders the file |
| DOCX → PDF | LibreOffice headless | Windows, macOS, Linux | LibreOffice installed | Server-friendly; minor layout differences possible |
The two directions are fundamentally different problems. Producing a PDF from a Word file is rendering: a word processor lays out the document and prints it. Producing a Word file from a PDF is reverse engineering: a PDF is a set of positioned glyphs, lines and images with no notion of paragraphs or tables, so the converter has to guess the structure.
PDF to DOCX with pdf2docx
Install it with pip install pdf2docx. The Converter class opens the PDF, analyzes each page and writes a DOCX built with python-docx:
from pdf2docx import Converter
cv = Converter("report.pdf")
try:
cv.convert("report.docx") # all pages
# cv.convert("part.docx", start=0, end=3) # pages 0-2 (end is exclusive)
# cv.convert("some.docx", pages=[0, 4, 9]) # specific pages
finally:
cv.close()
Converter also accepts password= for encrypted files. For one-off calls there is a functional shortcut, from pdf2docx import parse; parse("report.pdf", "report.docx"). The package also installs a pdf2docx command-line tool (pdf2docx convert report.pdf report.docx). Page indexes are zero-based. The examples here were tested with pdf2docx 0.5.13; see the pdf2docx documentation for the full option list.
Conversion progress is printed through the logging module at INFO level (pdf2docx configures the root logger when imported). Call logging.getLogger().setLevel(logging.WARNING) after the import if you want quiet batch runs.
What pdf2docx can and cannot do
pdf2docx detects text blocks, fonts, images and tables (including tables drawn only with lines or background shading) and maps them to Word paragraphs and tables. It works well on PDFs exported from word processors. Expect problems with:
- Scanned PDFs. A scan is just an image of each page. pdf2docx puts the image in the DOCX, but there is no editable text. You need OCR first, for example
ocrmypdf input.pdf searchable.pdf(Tesseract-based), and then convert the searchable PDF. - Complex layouts. Multi-column magazines, text over images, rotated text, forms and footnotes often come out as floating frames or nested tables that are awkward to edit.
- Lists. Bullets in a PDF are just glyphs. In our round-trip test (a python-docx file with a heading, a 3×2 table and a two-item bulleted list, rendered to PDF by LibreOffice and converted back), all text and the data table survived, but pdf2docx wrapped the table and the bullet list inside an outer layout table and kept the bullets as literal symbol characters rather than a Word list.
- Fonts. If the original font is not installed on the converting machine, Word substitutes another one, changing line breaks.
Licensing note: pdf2docx is MIT-licensed, but it depends on PyMuPDF, which is dual-licensed under the AGPL and a commercial license from Artifex. Check that this is acceptable before shipping it inside a closed-source product or SaaS.
Detecting scanned PDFs before converting
A cheap check saves users from receiving an "empty" Word file. If no page has a meaningful text layer, route the file to OCR instead:
import pdfplumber
def looks_scanned(path, min_chars=20):
"""True if no page has a usable text layer (likely an image-only scan)."""
with pdfplumber.open(path) as pdf:
return all(len((page.extract_text() or "").strip()) < min_chars
for page in pdf.pages)
print(looks_scanned("report.pdf"))
False
DOCX to PDF with docx2pdf (Word required)
docx2pdf drives Microsoft Word: through COM automation on Windows and through AppleScript/JXA on macOS. Because Word does the rendering, the PDF looks exactly as it would if you used "Save as PDF" manually.
from docx2pdf import convert
convert("report.docx") # writes report.pdf next to it
convert("report.docx", "out/report.pdf") # explicit output file
convert("in_folder/", "out_folder/") # every .docx in a folder (output folder must exist)
On Linux, convert() raises NotImplementedError because Word is not available there. It is also a poor fit for unattended servers even on Windows: Microsoft does not support Office automation from services, and a stuck Word dialog can hang the process. Use it for desktop scripts; use LibreOffice for anything that runs in the background.
DOCX to PDF with LibreOffice headless
LibreOffice can convert documents from the command line without opening a window. Install it with your package manager (for example apt install libreoffice-writer on Debian/Ubuntu), then:
soffice --headless --convert-to pdf --outdir out report.docx
From Python, wrap that command with subprocess.run, pass arguments as a list (never a shell string built from file names), set a timeout and verify that the output exists. LibreOffice can exit with status 0 even when it fails to convert a file, so checking for the PDF is essential:
import shutil
import subprocess
from pathlib import Path
def find_soffice():
for name in ("soffice", "libreoffice"):
path = shutil.which(name)
if path:
return path
raise FileNotFoundError("LibreOffice not found; install it or add soffice to PATH")
def docx_to_pdf(src, outdir=None, timeout=120):
src = Path(src).resolve()
outdir = Path(outdir or src.parent).resolve()
outdir.mkdir(parents=True, exist_ok=True)
cmd = [find_soffice(), "--headless", "--convert-to", "pdf",
"--outdir", str(outdir), str(src)]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
pdf = outdir / (src.stem + ".pdf")
if result.returncode != 0 or not pdf.exists():
raise RuntimeError(f"conversion failed: {result.stderr or result.stdout}")
return pdf
print(docx_to_pdf("report.docx", "out"))
Tested with LibreOffice 24.2 on Linux, converting a one-page DOCX took about two seconds including startup. On Windows the executable is usually C:\Program Files\LibreOffice\program\soffice.exe, which is not on PATH by default; pass the full path in that case. On macOS it lives at /Applications/LibreOffice.app/Contents/MacOS/soffice. The same command converts .doc, .odt, .rtf, .pptx and .xlsx files too. Options are described in the LibreOffice command-line parameters guide.
Batch conversion
Starting LibreOffice is the expensive part, so convert many files in one invocation rather than one process per file. A throwaway user profile (-env:UserInstallation) prevents the conversion from silently doing nothing when another LibreOffice instance is already running under the same user, and lets parallel workers run without stepping on each other:
import shutil
import subprocess
import tempfile
from pathlib import Path
def batch_docx_to_pdf(indir, outdir, timeout=600):
files = sorted(str(p.resolve()) for p in Path(indir).glob("*.docx"))
if not files:
return []
outdir = Path(outdir).resolve()
outdir.mkdir(parents=True, exist_ok=True)
soffice = shutil.which("soffice") or shutil.which("libreoffice")
with tempfile.TemporaryDirectory() as profile:
cmd = [soffice, f"-env:UserInstallation=file://{profile}",
"--headless", "--convert-to", "pdf", "--outdir", str(outdir), *files]
subprocess.run(cmd, check=True, capture_output=True, timeout=timeout)
missing = [f for f in files if not (outdir / (Path(f).stem + ".pdf")).exists()]
if missing:
raise RuntimeError(f"not converted: {missing}")
return sorted(outdir.glob("*.pdf"))
for pdf in batch_docx_to_pdf("in", "batch_out"):
print(pdf.name, pdf.stat().st_size > 0)
doc1.pdf True
doc2.pdf True
doc3.pdf True
On Windows, build the profile URL with Path(profile).as_uri() instead of the f-string, since Windows paths need a different file-URL form. For the opposite direction, loop over PDFs and collect failures instead of letting one malformed file stop the batch:
from pathlib import Path
from pdf2docx import Converter
def batch_pdf_to_docx(indir, outdir):
outdir = Path(outdir)
outdir.mkdir(parents=True, exist_ok=True)
failed = []
for pdf in sorted(Path(indir).glob("*.pdf")):
cv = Converter(str(pdf))
try:
cv.convert(str(outdir / (pdf.stem + ".docx")))
except Exception as exc: # malformed or unusual PDFs can fail
failed.append((pdf.name, exc))
finally:
cv.close()
return failed
Verifying the output
An existing file is not proof of a correct conversion. Two quick automated checks catch most problems: confirm the page count and that the expected text is present.
import pdfplumber
from docx import Document
with pdfplumber.open("out/report.pdf") as pdf:
print("pages:", len(pdf.pages))
print(pdf.pages[0].extract_text())
doc = Document("report.docx")
print([p.text for p in doc.paragraphs if p.text.strip()])
print(len(doc.tables), "top-level tables")
pages: 1
Quarterly report
This document was generated with python-docx for a conversion test.
Region Revenue
North 1,200
South 980
Item one
Item two
['Quarterly report', 'This document was generated with python-docx for a conversion test.', 'Item one', 'Item two']
1 top-level tables
Note that python-docx's doc.paragraphs excludes text inside tables, and doc.tables lists only top-level tables, so a converted DOCX whose content was wrapped in layout tables can look nearly empty to a naive check. Search doc.tables recursively (each cell has its own .paragraphs and .tables) when validating pdf2docx output. For visual regressions, render the first page of each PDF to an image and compare it with a reference.
Troubleshooting
- LibreOffice exits immediately and produces nothing: another instance is holding the profile lock. Use a dedicated
-env:UserInstallationdirectory as shown above. - Wrong fonts or shifted line breaks in the PDF: install the fonts used by the documents (on Debian/Ubuntu,
fonts-crosextra-carlitoandfonts-crosextra-caladeaare metric-compatible with Calibri and Cambria). - Hangs in containers: always set a
timeout, and make sureHOMEpoints to a writable directory. - Empty Word output from pdf2docx: the PDF is probably a scan; run OCR first.
- docx2pdf fails on Linux: expected. It needs Microsoft Word; switch to LibreOffice.
Summary
Use pdf2docx to turn text-based PDFs into editable Word files, accept that layout reconstruction is approximate, and run OCR first for scans. For DOCX → PDF, docx2pdf gives Word-perfect output on desktops with Word installed, while LibreOffice headless via subprocess works on every platform and is the right choice for servers, containers and batch jobs. Whichever tool you use, verify page counts and text afterwards instead of trusting the exit status.
ToolsInstall packages in PyCharm
Install Python packages in PyCharm via the Packages tool window, interpreter settings or pip; venv vs conda, requirements.txt and fixing import errors.
ToolsCheck a package version with pip
Check an installed Python package version with pip show, pip list and pip freeze, or in code with importlib.metadata, and compare versions safely.
ToolsPython version in Jupyter
Check the Python version a Jupyter kernel really runs with sys.version and sys.executable, avoid the !python --version trap and register a venv as a kernel.