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.

What the first bytes tell you A ZIP header with word/document.xml present means the file is fine and the problem is elsewhere, such as a path or permissions issue. A ZIP header without word/document.xml means it is another Office format such as xlsx or pptx. A ZIP header with a broken archive means a truncated download. An OLE header means a legacy doc file, an encrypted document, or a Word lock file. An RTF or HTML header means a mislabelled export. What do the first bytes show? read 8 bytes and list the zip PK, no document.xml Wrong Office type xlsx or pptx renamed PK, broken archive Truncated file re-download or re-export OLE header .doc, encrypted or ~$ convert or decrypt RTF or HTML Mislabelled export convert to docx LibreOffice convert or msoffcrypto

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.

Opening a mixed intake folder Each file is classified by its first bytes. Word lock files starting with tilde dollar are skipped. Valid Office Open XML packages are opened directly. OLE files that msoffcrypto reports as encrypted are decrypted in memory with a configured password. Legacy doc, RTF and HTML files are converted with LibreOffice to a temporary docx. Anything else is moved to a quarantine folder with the detected type recorded. Classify first 8 bytes Skip lock files ~$ prefix Open package python-docx Decrypt or convert msoffcrypto / LibreOffice Quarantine rest record the type

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.

File types behind the same error A legacy doc file starts with the OLE signature and is converted with LibreOffice. An encrypted document also starts with OLE and is decrypted with msoffcrypto after checking is_encrypted. A Word lock file starts with tilde dollar in its name and is skipped. An RTF export starts with a backslash rtf and is converted. A truncated download has a zip header but a broken archive and must be re-fetched. An xlsx workbook has a zip header without word slash document dot xml and belongs in a different pipeline. Actual type Recognise by Action Legacy .doc OLE signature convert with LibreOffice Encrypted docx OLE + is_encrypted decrypt with a password Word lock file $ name prefix skip RTF or HTML {\rtf or <html convert Truncated download zip header, broken archive re-fetch xlsx / pptx zip, other parts different pipeline

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.

Part of Automating Word Document Creation.