Fix PdfFileReader Deprecation Error in pypdf

A script that ran last year now stops at the first line that touches a PDF:

DeprecationError: PdfFileReader is deprecated and was removed in pypdf 3.0.0. Use PdfReader instead.
AttributeError: 'PdfReader' object has no attribute 'getNumPages'
ImportError: cannot import name 'PdfFileMerger' from 'pypdf'

Nothing is wrong with the PDFs. The library renamed its entire public surface: PyPDF2 3.0 deprecated the camelCase API, and pypdf — the maintained continuation of the project — removed it. The fix is mechanical, but it is not a blind find-and-replace: three of the renames also changed semantics.

Root Cause

PyPDF2 grew a Java-flavoured API (getNumPages, getPage, PdfFileReader) that predates PEP 8 by a decade. The 2022 revival renamed everything to snake_case and properties, kept the old names as deprecation shims for one major version, then deleted them. Version 3.0.0 removed the shims; the project also renamed itself from PyPDF2 to pypdf, so an environment can end up with both packages installed and imports resolving to whichever was last written.

# The two-package trap: PyPDF2 is the frozen fork, pypdf is maintained
pip list | grep -i pypdf
# PyPDF2   3.0.1
# pypdf    5.1.0
How the camelCase API disappeared A timeline in four stages. Stage one is PyPDF2 version 1 and 2, where PdfFileReader and getNumPages are the only API. Stage two is PyPDF2 version 3, where snake_case names arrive and the old names still work but emit deprecation warnings. Stage three is the removal, where the old names raise DeprecationError. Stage four is pypdf version 4 and 5, the maintained package, which ships only the snake_case API. A note records that having both packages installed makes imports ambiguous. PyPDF2 1.x-2.x PdfFileReader getNumPages() PyPDF2 3.0 both names work warnings emitted Shims removed DeprecationError scripts stop here pypdf 4.x-5.x PdfReader only len(reader.pages) Migrate to the right-hand end and pin it

Minimal Diagnostic

Find every legacy call site before changing anything, so the migration is a list rather than a hunt.

# No dependencies — standard library only
from pathlib import Path
import re

LEGACY = re.compile(
    r"\b(PdfFileReader|PdfFileWriter|PdfFileMerger|getNumPages|getPage|"
    r"addPage|appendPagesFromReader|getFields|encrypt\(|writeToStream|"
    r"numPages|extractText|mergePage|getDocumentInfo)\b"
)

def scan(root: Path) -> list[tuple[Path, int, str]]:
    """List every legacy PyPDF2 call site under a source tree."""
    hits = []
    for path in sorted(root.rglob("*.py")):
        try:
            lines = path.read_text(encoding="utf-8").splitlines()
        except (OSError, UnicodeDecodeError):
            continue
        for number, line in enumerate(lines, start=1):
            if LEGACY.search(line):
                hits.append((path, number, line.strip()))
    return hits

if __name__ == "__main__":
    for path, number, line in scan(Path("src")):
        print(f"{path}:{number}: {line}")

Also check which package the import actually resolves to, because a stale PyPDF2 can mask the problem on one machine and not another:

# pip install "pypdf>=4.2,<6"
import pypdf
print(pypdf.__version__, pypdf.__file__)

Fix: The Rename Map

Apply these one file at a time. The first block is a pure rename; the second block changes shape as well as name.

# pip install "pypdf>=4.2,<6"
# --- pure renames -------------------------------------------------------
# PyPDF2                          pypdf
# PdfFileReader(f)             -> PdfReader(f)
# PdfFileWriter()              -> PdfWriter()
# PdfFileMerger()              -> PdfWriter()          (merger is gone; writer.append covers it)
# reader.getPage(0)            -> reader.pages[0]
# writer.addPage(page)         -> writer.add_page(page)
# page.extractText()           -> page.extract_text()
# reader.getDocumentInfo()     -> reader.metadata
# writer.encrypt(pwd)          -> writer.encrypt(pwd)  (same name, new arguments)

# --- shape changes ------------------------------------------------------
# reader.getNumPages()         -> len(reader.pages)        method -> len() of a sequence
# reader.numPages              -> len(reader.pages)        property -> len() of a sequence
# page.mergePage(other)        -> page.merge_page(other)   in-place, same semantics
# writer.write(open(p, "wb"))  -> with open(p, "wb") as fh: writer.write(fh)

A before-and-after on the most common script in this space — merging a directory of PDFs:

# BEFORE (PyPDF2, raises DeprecationError on pypdf 3+)
from PyPDF2 import PdfFileMerger

merger = PdfFileMerger()
for name in ["a.pdf", "b.pdf"]:
    merger.append(PdfFileReader(open(name, "rb")))
merger.write("out.pdf")
merger.close()
# AFTER (pypdf 4/5)
# pip install "pypdf>=4.2,<6"
from pathlib import Path
from pypdf import PdfWriter

def merge(paths: list[Path], dest: Path) -> Path:
    """Concatenate PDFs with the modern writer API."""
    missing = [p for p in paths if not p.exists()]
    if missing:
        raise FileNotFoundError(f"Missing input(s): {', '.join(map(str, missing))}")

    writer = PdfWriter()
    for path in paths:
        try:
            writer.append(str(path))      # accepts a path, a stream or a PdfReader
        except Exception as exc:
            raise RuntimeError(f"Could not append {path}: {exc}") from exc

    dest.parent.mkdir(parents=True, exist_ok=True)
    with dest.open("wb") as fh:            # context manager, not a bare open()
        writer.write(fh)
    writer.close()
    return dest

if __name__ == "__main__":
    print(merge([Path("data/a.pdf"), Path("data/b.pdf")], Path("out/merged.pdf")))

PdfWriter.append replaces the whole PdfFileMerger class and additionally carries over outlines and named destinations, which the old merger dropped in some versions. The full merge workflow, including page ranges and bookmark handling, is in batch merge PDFs with a Python script.

Variant Fix 1: Both Packages Installed

If import PyPDF2 still works somewhere in the tree, the two libraries can be loaded into the same process with different object graphs, and passing a PyPDF2 page to a pypdf writer fails with a confusing type error.

# Remove the frozen fork once the migration is complete
pip uninstall -y PyPDF2

# Pin the survivor so a future major cannot repeat this
python - <<'PY'
from pathlib import Path
req = Path("requirements.txt")
lines = [ln for ln in req.read_text().splitlines() if not ln.lower().startswith("pypdf2")]
if not any(ln.startswith("pypdf==") for ln in lines):
    lines.append("pypdf==5.1.0")
req.write_text("\n".join(lines) + "\n")
PY

Variant Fix 2: Deprecation Warnings Instead of Errors

On pypdf 3.x the old names raise DeprecationError, but a few call sites only warn — and warnings are invisible in a scheduled job. Turn them into failures while migrating so nothing is missed:

# pip install "pypdf>=4.2,<6"
import warnings

# Fail the run on any pypdf deprecation, so CI catches the last stragglers
warnings.filterwarnings("error", category=DeprecationWarning, module=r"pypdf.*")

Keep that filter in the test suite rather than in production code, so a library change surfaces in CI rather than at 3 a.m. in the job described in scheduling and logging automation jobs.

Variant Fix 3: Encryption Arguments Changed

writer.encrypt() kept its name but replaced positional passwords with keyword arguments and an algorithm selector:

# pip install "pypdf>=4.2,<6"
# BEFORE: writer.encrypt("userpw", "ownerpw", use_128bit=True)
writer.encrypt(
    user_password="userpw",
    owner_password="ownerpw",
    algorithm="AES-256",      # RC4 defaults are gone; pick explicitly
)

Files encrypted with the old RC4 defaults still open, but new output should use AES — the compatibility matrix is covered in add password protection to PDF files.

Order of operations for the migration Four ordered steps. Step one inventories every legacy call site with a regular expression scan. Step two applies the rename map file by file. Step three uninstalls PyPDF2 so imports are unambiguous. Step four pins pypdf in requirements and turns deprecation warnings into errors in the test suite. Doing step three before step two is called out as the common mistake, because it breaks the code before it has been migrated. 1. Inventory regex the tree list, don't guess 2. Rename file by file watch len() cases 3. Uninstall drop PyPDF2 one library only 4. Pin requirements warnings as errors Migrate in this order Uninstalling before renaming breaks the code you are still editing Keep both installed until step 2 is complete, then remove the old one

Verification

Migration is done when the legacy scan is empty and the output files are byte-comparable in structure to the pre-migration ones.

# pip install "pypdf>=4.2,<6"
from pathlib import Path
from pypdf import PdfReader

def assert_equivalent(before: Path, after: Path) -> None:
    """Confirm a migrated script still produces the same document shape."""
    old, new = PdfReader(str(before)), PdfReader(str(after))
    assert len(old.pages) == len(new.pages), (
        f"page count changed: {len(old.pages)} -> {len(new.pages)}"
    )
    for index, (a, b) in enumerate(zip(old.pages, new.pages), start=1):
        assert a.mediabox == b.mediabox, f"page {index}: media box changed"
        old_text = (a.extract_text() or "").split()
        new_text = (b.extract_text() or "").split()
        assert old_text == new_text, f"page {index}: text content changed"
    print(f"{len(new.pages)} page(s) identical after migration")

if __name__ == "__main__":
    assert_equivalent(Path("baseline/merged.pdf"), Path("out/merged.pdf"))

Keep one baseline output per script in the repository. It turns "the migration looked fine" into a test, and it catches the subtle regressions — a lost outline, a dropped form dictionary, a page rotated during append — that a page count alone will not.

Tracking a rename migration to zero A stacked bar per module shows how many legacy PyPDF2 call sites have been renamed and how many remain. The ingest module is fully migrated, reports has four remaining, archive has eleven, and a shared utils module has two. The advice is to drive every bar to zero before uninstalling PyPDF2, because the package must stay installed while the code still calls it. Call sites renamed against call sites remaining ingest 18 / 18 done reports 4 left archive 11 left utils 2 left Keep PyPDF2 installed until every bar is green, then uninstall and pin pypdf

FAQ

Should I use pypdf or PyPDF2 for new code?pypdf. PyPDF2 is the frozen name; all maintenance, security fixes and new features land in pypdf. The import is from pypdf import PdfReader.

Is there an automatic migration tool?pypdf ships no codemod, but the rename map is small enough for sed plus a review pass. Do the two len() cases by hand — a blind replacement of getNumPages() with len(reader.pages) breaks when the call was chained or stored.

Can I pin PyPDF2 2.x and skip the migration? Only briefly. That version receives no security updates, and PDF parsers are a common target for malformed-input bugs. Treat pinning as a scheduling decision, not an alternative.

Why do I still get DeprecationError after renaming everything? Usually a transitive dependency still calls the old API. Run the process with -W error::DeprecationWarning and read the traceback: the frame below yours names the package to upgrade or replace.

Does PdfWriter.append handle bookmarks and links? Yes — it merges outlines and named destinations from each source, which the removed PdfFileMerger did inconsistently. Verify with the baseline comparison above if outlines matter to your output.

Pinning After the Migration

The migration is worth finishing with a version pin, because the same class of breakage will happen again. Libraries in this space rename their public surface roughly once per major version, and a pipeline that floats to the newest release will eventually pick one up unattended.

Pin the exact version in requirements.txt, and record the upgrade as a deliberate task rather than a side effect of a rebuild. When you do upgrade, run the baseline comparison from the verification section first: it is a two-minute check that answers whether the new version produces the same documents, which no changelog can tell you about your own files.

It is also worth keeping the legacy scan in the test suite rather than deleting it after the migration. It costs milliseconds, and it fails loudly the first time somebody copies an old snippet from an internal wiki into new code — which, in practice, is how the old API comes back.

One last habit worth adopting: when the migration is complete, delete the compatibility shims rather than leaving them behind a feature flag. A half-migrated codebase carrying both spellings is harder to reason about than either version alone, and the shim inevitably becomes the path new code copies.

Part of Merging and Splitting PDF Documents.