Fix python-docx PackageNotFoundError
Opening a document that exists and opens fine in Word fails:
docx.opc.exceptions.PackageNotFoundError: Package not found at 'in/contracts/2026-09-17 agreement.docx'
The message names a path, which sends everyone looking for a typo — but Path(...).exists() returns True, the file is 84 KB, and double-clicking it opens Word normally.
Root Cause
PackageNotFoundError means "this is not an Office Open XML package", not "this file is missing". python-docx expects a ZIP archive containing word/document.xml; anything else raises the same error regardless of the reason. Five causes account for nearly all occurrences. The file is a legacy .doc (an OLE compound file) with a .docx name or not; it is an RTF or HTML file that Word saved or a system exported with a .docx extension; it is a Word lock file (~$name.docx), which is a tiny OLE stub; it is a truncated or empty download, where the ZIP central directory is missing; or it is encrypted, which wraps the package in an OLE container. A sixth, rarer case: the path points at a directory, or at a file on a network share that is temporarily unavailable, which surfaces as the same error because the read fails before any parsing.
Minimal Diagnostic
Read the first bytes and the ZIP contents to identify what the file really is.
# pip install python-docx
import zipfile
from pathlib import Path
SOURCE = Path("in/contracts/2026-09-17 agreement.docx")
SIGNATURES = {
b"PK\x03\x04": "ZIP (Office Open XML or other zip)",
b"\xd0\xcf\x11\xe0": "OLE compound file (.doc, .xls, encrypted, or a ~$ lock file)",
b"{\\rtf": "RTF document",
b"<!DOC": "HTML",
b"<html": "HTML",
b"%PDF-": "PDF",
}
def identify(path: Path) -> None:
if not path.exists():
print(f"{path}: does not exist")
return
if path.is_dir():
print(f"{path}: is a directory")
return
size = path.stat().st_size
head = path.read_bytes()[:8]
kind = next((name for sig, name in SIGNATURES.items() if head.startswith(sig)), f"unknown ({head!r})")
print(f"{path.name}: {size:,} bytes, looks like {kind}")
if path.name.startswith("~$"):
print(" this is a Word lock file for an open document, not a document")
if size == 0:
print(" empty file: an interrupted download or a failed export")
if head.startswith(b"PK\x03\x04"):
try:
with zipfile.ZipFile(path) as zf:
names = set(zf.namelist())
print(f" zip parts: {len(names)}; has word/document.xml: {'word/document.xml' in names}")
if "xl/workbook.xml" in names:
print(" this is an .xlsx workbook, not a Word document")
if "ppt/presentation.xml" in names:
print(" this is a .pptx presentation")
except zipfile.BadZipFile as exc:
print(f" zip header present but the archive is broken: {exc} (truncated download?)")
if __name__ == "__main__":
identify(SOURCE)
2026-09-17 agreement.docx: 84,992 bytes, looks like OLE compound file (.doc, .xls, encrypted, or a ~$ lock file)
An OLE container with a .docx name: the sender exported a legacy .doc and renamed it, or the file is encrypted. Word opens both happily, which is why nobody noticed.
Fix: Identify, Convert, Then Open
Wrap opening in a function that classifies the failure and converts what can be converted, so a mixed intake folder processes without manual triage. Changed lines carry comments.
# pip install python-docx msoffcrypto-tool
# system: sudo apt-get install -y libreoffice-writer
import io
import shutil
import subprocess
import tempfile
import zipfile
from pathlib import Path
import msoffcrypto
from docx import Document
from docx.opc.exceptions import PackageNotFoundError
CONVERTIBLE = {b"\xd0\xcf\x11\xe0", b"{\\rtf", b"<!DOC", b"<html"}
def convert_to_docx(path: Path, timeout: int = 180) -> Path:
if shutil.which("soffice") is None:
raise RuntimeError("LibreOffice is not installed; cannot convert legacy formats")
out_dir = Path(tempfile.mkdtemp(prefix="docx-convert-"))
result = subprocess.run(
["soffice", "--headless", "--norestore", "--convert-to", "docx", "--outdir", str(out_dir), str(path)],
capture_output=True, text=True, timeout=timeout,
)
produced = out_dir / (path.stem + ".docx")
if result.returncode != 0 or not produced.exists():
raise RuntimeError(f"conversion failed for {path.name}: {result.stderr[:200]}")
return produced # changed: caller deletes the temp dir
def open_document(path: Path, password: str | None = None) -> Document:
if not path.is_file():
raise FileNotFoundError(f"{path} is not a file") # changed: distinct from a bad package
if path.name.startswith("~$"):
raise ValueError(f"{path.name} is a Word lock file, not a document") # changed: skip these
head = path.read_bytes()[:8]
if head.startswith(b"PK\x03\x04"):
with zipfile.ZipFile(path) as zf:
if "word/document.xml" not in zf.namelist():
raise ValueError(f"{path.name} is a zip but not a Word document")
return Document(path)
if head.startswith(b"\xd0\xcf\x11\xe0"):
with path.open("rb") as fh:
office_file = msoffcrypto.OfficeFile(fh)
if office_file.is_encrypted(): # changed: encrypted, not legacy
if password is None:
raise ValueError(f"{path.name} is encrypted; a password is required")
buffer = io.BytesIO()
office_file.load_key(password=password)
office_file.decrypt(buffer)
buffer.seek(0)
return Document(buffer)
if head[:5] in CONVERTIBLE or head[:4] in CONVERTIBLE:
converted = convert_to_docx(path) # changed: legacy or RTF/HTML
try:
return Document(converted)
finally:
shutil.rmtree(converted.parent, ignore_errors=True)
raise PackageNotFoundError(f"{path.name} is not a readable Word document (starts with {head!r})")
if __name__ == "__main__":
doc = open_document(Path("in/contracts/2026-09-17 agreement.docx"))
print(f"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} tables")
Reading the document from a BytesIO for encrypted files keeps a decrypted copy off disk. Converting through LibreOffice produces a genuine .docx, at the cost of some fidelity for complex legacy documents — acceptable for extraction, worth checking if the converted file will be sent on. The password path reuses the approach in read password-protected Excel files in Python, which applies to Word documents unchanged.
Variant Fix 1: Truncated and In-Progress Files
A file being written by another process — an email intake saving an attachment, a scanner writing over SMB — is a ZIP without its central directory until the last byte arrives, so zipfile.BadZipFile or PackageNotFoundError appears intermittently. Wait for the file to settle before opening:
# stdlib only
import time
import zipfile
from pathlib import Path
def wait_until_readable(path: Path, quiet: float = 2.0, timeout: float = 120.0) -> bool:
deadline, last, stable_since = time.monotonic() + timeout, None, None
while time.monotonic() < deadline:
try:
stat = path.stat()
except FileNotFoundError:
return False
current = (stat.st_size, stat.st_mtime_ns)
if current == last and stat.st_size > 0:
stable_since = stable_since or time.monotonic()
if time.monotonic() - stable_since >= quiet:
try:
with zipfile.ZipFile(path) as zf:
return "word/document.xml" in zf.namelist()
except zipfile.BadZipFile:
stable_since = None # still being written, or genuinely broken
else:
last, stable_since = current, None
time.sleep(0.5)
return False
This is the same completeness problem as fix watchdog event fires before file is written, with a format-specific final check: a .docx is complete when its ZIP directory lists word/document.xml.
Variant Fix 2: Batch Triage of a Folder
For an inbox of mixed files, classify everything first and report, rather than failing on the first surprise:
# pip install python-docx "pandas>=2.2"
from pathlib import Path
import pandas as pd
def triage(folder: Path, password: str | None = None) -> pd.DataFrame:
rows = []
for path in sorted(folder.rglob("*")):
if not path.is_file():
continue
row = {"file": str(path.relative_to(folder)), "bytes": path.stat().st_size, "status": "", "detail": ""}
try:
doc = open_document(path, password)
row["status"] = "ok"
row["detail"] = f"{len(doc.paragraphs)} paragraphs"
except Exception as exc:
row["status"] = type(exc).__name__
row["detail"] = str(exc)[:120]
rows.append(row)
report = pd.DataFrame(rows)
return report.sort_values(["status", "file"])
The resulting table is what goes back to whoever supplies the documents: "eleven of these are legacy .doc files, three are RTF with a .docx name, one is a spreadsheet". That is a concrete request rather than "the import failed", and it usually fixes the source of the problem for good.
Verification
Assert that the opener handles every known case, using fixtures you construct rather than files you happen to have.
# pip install python-docx
import tempfile
import zipfile
from pathlib import Path
import pytest
from docx import Document
from docx.opc.exceptions import PackageNotFoundError
def make_fixtures(folder: Path) -> dict[str, Path]:
folder.mkdir(parents=True, exist_ok=True)
good = folder / "good.docx"
Document().save(good)
files = {"good": good}
files["lock"] = folder / "~$good.docx"
files["lock"].write_bytes(b"\xd0\xcf\x11\xe0" + b"\x00" * 100)
files["rtf"] = folder / "export.docx"
files["rtf"].write_bytes(b"{\\rtf1\\ansi test}")
files["truncated"] = folder / "partial.docx"
files["truncated"].write_bytes(good.read_bytes()[: len(good.read_bytes()) // 2])
files["xlsx"] = folder / "book.docx"
with zipfile.ZipFile(files["xlsx"], "w") as zf:
zf.writestr("xl/workbook.xml", "<workbook/>")
return files
def test_open_document() -> None:
with tempfile.TemporaryDirectory() as tmp:
files = make_fixtures(Path(tmp))
assert open_document(files["good"]).paragraphs is not None
for key in ("lock", "truncated", "xlsx"):
with pytest.raises((ValueError, PackageNotFoundError, zipfile.BadZipFile)):
open_document(files[key])
print("opener handles valid, lock, truncated and wrong-type files")
Constructing the fixtures in code means the test runs anywhere and documents exactly which inputs the opener is expected to reject. Add a real legacy .doc to the repository only if LibreOffice is available in CI; otherwise assert that the conversion path raises a clear RuntimeError when soffice is missing.
FAQ
Why does Word open a file that python-docx rejects?
Word detects the real format and converts on the fly. python-docx supports only Office Open XML .docx.
Can python-docx read .doc files at all?
No. Convert with LibreOffice, Word automation on Windows, or ask the sender to save as .docx.
What creates ~$ files?
Word, while a document is open. They are lock stubs; skip any file whose name starts with ~$.
Why does the same file work on my machine and fail on the server? Usually the server reads it from a network share where the file is still being written, or the path resolves differently and points at a directory. Run the identification snippet on the server rather than comparing file names.
Can I repair a truncated .docx? Only if the missing part is small and the central directory survives, which is rarely the case. Re-fetch the file; a repaired archive that opens may still be missing content, which is worse than a clear failure.
Is .docm supported?
Yes for reading and writing content, but saving through python-docx may drop the macro project. Treat macro-enabled documents as read-only.
Related
- Automating Word Document Creation — building documents once they open
- Extracting Data from Word Documents with Python — reading content from valid packages
- Converting DOCX to PDF with Python — the LibreOffice conversion path in depth
- Fix watchdog Event Fires Before File Is Written — half-written files in intake folders
Part of Automating Word Document Creation.