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
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.
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.
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.
Related
- Merging and Splitting PDF Documents — the current-API workflow
- Batch Merge PDFs with a Python Script — directory-scale merging
- Split PDF by Page Ranges with Python — the same rename map applied to splitting
- Add Password Protection to PDF Files — the changed
encrypt()signature - Filling PDF Forms with Python — form APIs that were renamed at the same time
Part of Merging and Splitting PDF Documents.