Before sending a contract packet, board deck, or vendor proposal to an external recipient, teams frequently need to strip out internal cost worksheets, privileged legal notes, or executive compensation tables. To remove confidential pages from a PDF safely, you need more than a lower page count in your PDF viewer.

Under the PDF specification (ISO 32000-1), a PDF is an indexed database of indirect objects rather than a flat stream of pages. Depending on how a tool deletes a page, the confidential text and figures from that removed page can remain inside the file across four distinct structural layers.

Four Ways “Deleted” Confidential Pages Survive Inside a PDF

To test how different PDF libraries and workflows behave when removing confidential pages, we built a 6-page commercial proposal (proposal_internal.pdf) containing two internal-only pages—Page 3 (an internal margin worksheet) and Page 5 (privileged legal approval notes)—along with document bookmarks (/Outlines) and an embedded CSV pricing model (/EmbeddedFiles):

import shutil
import pymupdf
from pypdf import PdfReader, PdfWriter

doc = pymupdf.open()
pages_spec = [
    ("Page 1: Executive Summary", "Proposal for Globex Corp — Enterprise Platform Deployment"),
    ("Page 2: Architecture & SLA", "Deliverables: Multi-region failover, 99.95% uptime SLA, SSO."),
    ("Page 3: INTERNAL MARGIN WORKSHEET (CONFIDENTIAL)", "SECRET_COGS_FLOOR: 118,000 USD | Walk-Away Price: 195,000 USD | Rep Split: 14%"),
    ("Page 4: Implementation Schedule", "Phase 1: Discovery (Weeks 1-2) | Phase 2: Cutover (Weeks 3-6)"),
    ("Page 5: PRIVILEGED LEGAL APPROVAL NOTES", "SECRET_LEGAL_CAP: Liability cap waiver pre-approved up to 500,000 USD by General Counsel."),
    ("Page 6: Commercial Order Form & Signature", "Annual Subscription Fee: 320,000 USD | Authorized Signature: ____________________"),
]

for idx, (heading, body) in enumerate(pages_spec, start=1):
    page = doc.new_page(width=612, height=792)
    page.insert_text((72, 72), heading, fontsize=14)
    page.insert_text((72, 110), body, fontsize=11)
    page.insert_text((72, 740), f"Page {idx} of 6", fontsize=9)

doc.set_toc([
    [1, "Executive Summary", 1],
    [1, "Architecture & SLA", 2],
    [1, "Internal Margin Worksheet (195k Walk-Away Floor)", 3],
    [1, "Implementation Schedule", 4],
    [1, "Privileged Legal Notes (500k Cap Waiver)", 5],
    [1, "Commercial Order Form", 6],
])

doc.embfile_add(
    "margin_calc.csv",
    b"metric,amount_usd\nsecret_cogs_floor,118000\nwalk_away_price,195000\n",
    filename="margin_calc.csv",
)

doc.save("proposal_internal.pdf", garbage=4, deflate=True)
doc.close()

When you delete Page 3 and Page 5 (0-based indices 2 and 4), every viewer reports a 4-page PDF. However, the underlying file structure behaves very differently depending on how the save operation is performed:

flowchart TD
    A["6-Page Proposal PDF<br/>(Pages 3 & 5 Confidential + Bookmarks + CSV Attachment)"] --> B{"How Are Pages 3 & 5 Removed?"}
    B -->|"1. Incremental Save<br/>(saveIncr / Fast Save)"| C["2 %%EOF Trailers<br/>Truncating at 1st %%EOF restores full 6-page PDF"]
    B -->|"2. In-Place Delete Without GC<br/>(garbage=0)"| D["1 %%EOF, all objects kept<br/>Pages 3 & 5 /Contents streams stay in xref table"]
    B -->|"3. In-Place Delete + garbage=4 Only"| E["1 %%EOF, fewer objects<br/>Page streams purged, but /Outlines & /EmbeddedFiles leak"]
    B -->|"4. Fresh Document Copy<br/>(pdf-lib copyPages / pypdf PdfWriter)"| F["1 %%EOF, no old revisions, TOC, or catalog attachments<br/>Objects referenced by kept pages (incl. linked pages) still copied"]

The three in-place variants used below come from this script:

import shutil
import pymupdf

# 1. Incremental save: delete Pages 3 and 5 and append the change to a copy of the file
shutil.copy("proposal_internal.pdf", "removed_incremental.pdf")
d = pymupdf.open("removed_incremental.pdf")
d.delete_pages([2, 4])
d.saveIncr()
d.close()

# 2. and 3. Full rewrite without and with garbage collection
for out_name, gc in [("removed_no_gc.pdf", 0), ("removed_gc4_only.pdf", 4)]:
    d = pymupdf.open("proposal_internal.pdf")
    d.delete_pages([2, 4])
    d.save(out_name, garbage=gc, deflate=True)
    d.close()

1. Incremental Saves That Can Be Rolled Back

Under ISO 32000-1 Section 7.5.6, PDF supports incremental updates, where modifications are appended to the end of the file along with a new cross-reference section and an additional %%EOF marker. If a tool or script deletes Pages 3 and 5 and saves incrementally (d.saveIncr()), the file size actually increases from 6,376 bytes to 7,237 bytes.

Unless the original was linearized, any recipient can roll back an incremental save in four lines of Python by slicing the binary up to the first %%EOF marker:

import pymupdf

raw = open("removed_incremental.pdf", "rb").read()
first_eof = raw.find(b"%%EOF") + len(b"%%EOF")
open("recovered_revision_1.pdf", "wb").write(raw[:first_eof])

doc = pymupdf.open("recovered_revision_1.pdf")
print(f"Recovered revision 1: {len(doc)} pages ({first_eof} bytes)")
for pno in (3, 5):
    print(f"--- Recovered Page {pno} ---")
    print(doc[pno - 1].get_text().strip())
doc.close()

Output:

Recovered revision 1: 6 pages (6375 bytes)
--- Recovered Page 3 ---
Page 3: INTERNAL MARGIN WORKSHEET (CONFIDENTIAL)
SECRET_COGS_FLOOR: 118,000 USD | Walk-Away Price: 195,000 USD | Rep Split: 14%
Page 3 of 6
--- Recovered Page 5 ---
Page 5: PRIVILEGED LEGAL APPROVAL NOTES
SECRET_LEGAL_CAP: Liability cap waiver pre-approved up to 500,000 USD by General Counsel.
Page 5 of 6

2. Orphaned Page Content Left in the File

Even when a PDF library writes a single-revision file (%%EOF count of 1), removing a /Page dictionary from the /Pages/Kids array does not automatically delete the indirect /Contents stream or page /Resources that the deleted page pointed to. Saving with garbage=0 (removed_no_gc.pdf) shrinks the file by just 62 bytes (6,376 to 6,314 bytes) and leaves all 36 indirect objects—including SECRET_COGS_FLOOR: 118,000 USD and SECRET_LEGAL_CAP—readable inside the cross-reference table.

3. Dangling Bookmarks and Embedded Attachments

Passing garbage=4 during an in-place save (removed_gc4_only.pdf) purges unreferenced objects, which removes the deleted pages’ /Contents streams. However, garbage=4 only collects unreferenced objects. Two high-risk structures remain referenced by the root /Catalog (/Root) even after their corresponding pages are gone:

  • Document Bookmarks (/Outlines): Bookmark nodes live in a linked tree under /Root -> /Outlines. When Pages 3 and 5 are deleted in-place, their bookmark titles (Internal Margin Worksheet (195k Walk-Away Floor) and Privileged Legal Notes (500k Cap Waiver)) remain in the sidebar pointing at pages that no longer exist (PyMuPDF’s get_toc() reports their target page as -1).
  • Embedded File Attachments (/Names -> /EmbeddedFiles): Source spreadsheets or CSV attachments (margin_calc.csv containing 118000) are attached to the document catalog rather than to Page 3, so page deletion leaves them intact.

4. Visible “Page X of Y” Gaps on the Kept Pages

Deleting Pages 3 and 5 re-indexes the PDF viewer toolbar from 1..6 to 1..4, but it does not alter text already painted inside the remaining pages’ /Contents streams. On the kept pages, the static footers still read Page 1 of 6, Page 2 of 6, Page 4 of 6, and Page 6 of 6—immediately signaling to the recipient that Pages 3 and 5 were withheld.

Auditing All Removal Methods Against Confidential Leaks

We tested five removal workflows on proposal_internal.pdf (removing Pages 3 and 5) and scanned every indirect object and stream in the resulting files for seven confidential markers (SECRET_COGS_FLOOR, SECRET_LEGAL_CAP, 195,000, 500,000, 195k, 500k, 118000):

import pymupdf

files = [
    "proposal_internal.pdf",
    "removed_incremental.pdf",
    "removed_no_gc.pdf",
    "removed_gc4_only.pdf",
    "removed_pymupdf_sanitized.pdf",
    "removed_pypdf.pdf",
]

secrets = ["SECRET_COGS_FLOOR", "SECRET_LEGAL_CAP", "195,000", "500,000", "195k", "500k", "118000"]

for f in files:
    raw = open(f, "rb").read()
    eof_count = raw.count(b"%%EOF")
    doc = pymupdf.open(f)
    stream_hits = []
    for xref in range(1, doc.xref_length()):
        obj_str = doc.xref_object(xref)
        stream_str = doc.xref_stream(xref).decode("latin1", errors="ignore") if doc.xref_is_stream(xref) else ""
        combined = obj_str + "\n" + stream_str
        for s in secrets:
            if (s in combined or s.encode().hex() in combined.lower()) and s not in stream_hits:
                stream_hits.append(s)
    print(f"{f:29s} | pages={len(doc)} | bytes={len(raw):4d} | EOF={eof_count} | xrefs={doc.xref_length()-1:2d} | toc={len(doc.get_toc())} | emb={doc.embfile_names()} | hits={stream_hits}")
    doc.close()

Output:

proposal_internal.pdf         | pages=6 | bytes=6376 | EOF=1 | xrefs=36 | toc=6 | emb=['margin_calc.csv'] | hits=['SECRET_COGS_FLOOR', '195,000', 'SECRET_LEGAL_CAP', '500,000', '195k', '500k', '118000']
removed_incremental.pdf       | pages=4 | bytes=7237 | EOF=2 | xrefs=36 | toc=6 | emb=['margin_calc.csv'] | hits=['SECRET_COGS_FLOOR', '195,000', 'SECRET_LEGAL_CAP', '500,000', '195k', '500k', '118000']
removed_no_gc.pdf             | pages=4 | bytes=6314 | EOF=1 | xrefs=36 | toc=6 | emb=['margin_calc.csv'] | hits=['SECRET_COGS_FLOOR', '195,000', 'SECRET_LEGAL_CAP', '500,000', '195k', '500k', '118000']
removed_gc4_only.pdf          | pages=4 | bytes=4818 | EOF=1 | xrefs=28 | toc=6 | emb=['margin_calc.csv'] | hits=['195k', '500k', '118000']
removed_pymupdf_sanitized.pdf | pages=4 | bytes=4133 | EOF=1 | xrefs=25 | toc=4 | emb=[] | hits=[]
removed_pypdf.pdf             | pages=4 | bytes=3472 | EOF=1 | xrefs=21 | toc=0 | emb=[] | hits=[]

Here is how the methods compare side by side:

Output FileRemoval MethodVisible PagesFile Size%%EOF CountIndirect xrefsBookmarks (toc)Attachments (emb)Leaked Confidential Markers
removed_incremental.pdfIn-place delete_pages + saveIncr()47,237 B2366 (2 dead)margin_calc.csvAll 7 leaked (full rollback possible)
removed_no_gc.pdfIn-place delete_pages + garbage=046,314 B1366 (2 dead)margin_calc.csvAll 7 leaked in orphaned streams
removed_gc4_only.pdfIn-place delete_pages + garbage=4 only44,818 B1286 (2 dead)margin_calc.csv195k, 500k (TOC), 118000 (CSV)
removed_pymupdf_sanitized.pdfPyMuPDF delete_pages + TOC/emb cleanup + garbage=444,133 B1254 (clean)None ([])None ([])
removed_pypdf.pdfpypdf fresh PdfWriter.add_page()43,472 B1210None ([])None ([])

The clean pypdf fresh-copy result holds for this test file because none of its kept pages link to Pages 3 or 5. A kept page with such a link would pull the removed page into the copy, and the same applies to the browser tool (see the browser-tool caveats below).

Method 1: Removing Confidential Pages in the Browser

When you want to visually inspect each page before deleting confidential sheets, open the file in the Remove PDF Pages tool. The tool runs in your browser, so the file is not uploaded.

Here is what happens when you load a PDF and remove pages:

  1. Thumbnail Preview via pdf.js: The tool renders every page as a thumbnail in a responsive grid. Clicking a page card marks it for removal with a red border, trash badge, grayscaled thumbnail, and strikethrough page label, while updating the {selected} of {total} pages selected for removal counter. (A range field such as 2, 4-6 and toolbar buttons for Select Even Pages, Select Odd Pages, and Clear Selection are also available.)
  2. Fresh PDFDocument Construction via copyPages(): When you click Remove N Selected Pages, the tool does not edit the source PDF in place. Instead, it creates an empty PDFDocument and copies every page not marked for removal into it with copyPages().
  3. What Is and Is Not Carried Over: Because the output is a brand-new PDFDocument, prior %%EOF revisions, the old Info dictionary, document-level /Outlines bookmarks, and catalog /EmbeddedFiles attachments are not carried over. However, copyPages() copies every object a kept page references, not just its own content. If a kept page carries a link annotation that points to a removed page—such as a table-of-contents entry—that removed page, including its /Contents stream, is copied into the output too. Kept pages also bring along their own annotations and comments.

Keep four constraints of the browser tool in mind:

  • You cannot delete every page: Selecting all pages triggers "You cannot remove all pages from the PDF document." At least one page must remain.
  • Encrypted PDFs must be unlocked first: The widget loads files with PDFDocument.load(buf, { ignoreEncryption: true }), so an owner-password-locked PDF does not raise an error. The removal appears to succeed, but the kept pages can come out garbled or blank because their streams are copied without being decrypted. Unlock the file first with the PDF Unlock tool, then remove pages.
  • Verify the output before sending: Run the audit script above on the downloaded file. If it reports confidential markers, delete the links on the kept pages (or flatten the file) and remove the pages again, or use the PyMuPDF script below (in-place delete plus garbage=4) and re-run the audit. When a kept page’s link points at a removed page, pdf-lib copyPages() and pypdf PdfWriter.add_page() both carry the linked page into the output, while PyMuPDF delete_pages() saved with garbage=4 drops the dangling link and the page with it.
  • Legitimate bookmarks are also dropped: Because copyPages() does not copy the root /Outlines tree, bookmarks for the kept chapters are stripped alongside the confidential ones. If you need to preserve bookmarks for the kept pages, use the sanitized PyMuPDF script below.

Method 2: Command-Line and Python Workflows (pypdf vs. PyMuPDF)

On both macOS (Homebrew Python) and Ubuntu/Debian Linux, PEP 668 blocks system-wide pip installs by default, so create a virtual environment first:

python3 -m venv .venv
.venv/bin/pip install pypdf pymupdf

Option A: Fresh Document Rebuild with pypdf

Like pdf-lib in the browser, pypdf’s PdfWriter builds a new PDF structure from the pages you explicitly add with writer.add_page(). Document-level /Outlines bookmarks and /EmbeddedFiles attachments are not carried over, but objects the kept pages reference come along with them, so run the audit script on the result:

from pypdf import PdfReader, PdfWriter

reader = PdfReader("proposal_internal.pdf")
writer = PdfWriter()

# 1-based page numbers to remove (Page 3 and Page 5)
remove_pages = {3, 5}

for idx, page in enumerate(reader.pages, start=1):
    if idx not in remove_pages:
        writer.add_page(page)

with open("removed_pypdf.pdf", "wb") as f:
    writer.write(f)

Option B: Preserving Valid Bookmarks While Purging Confidential Data (PyMuPDF)

If you want to keep the valid /Outlines bookmarks for the remaining pages (toc=4), PyMuPDF (import pymupdf) lets you delete the confidential pages in-place, filter out any bookmark entries whose target page became -1, delete catalog attachments, clear document metadata, and compact the cross-reference table with garbage=4:

import pymupdf

doc = pymupdf.open("proposal_internal.pdf")

# Delete 0-based page indices 2 and 4 (Pages 3 and 5)
doc.delete_pages([2, 4])

# Prune dead bookmarks pointing to deleted pages (target page == -1)
clean_toc = [entry for entry in doc.get_toc() if entry[2] != -1]
doc.set_toc(clean_toc)

# Delete any catalog-level embedded files (/Names -> /EmbeddedFiles)
for emb_name in doc.embfile_names():
    doc.embfile_del(emb_name)

# Strip Document Info dictionary and XMP metadata stream
doc.set_metadata({})
doc.del_xml_metadata()

# Save with garbage=4 to purge unreferenced streams and compact xrefs
doc.save("removed_pymupdf_sanitized.pdf", garbage=4, deflate=True)
doc.close()

Verifying the Cleaned PDF from the Terminal

Before emailing the sanitized PDF, run two terminal checks to verify that no incremental revision trailers remain and that the file size shrank proportionally.

First, count %%EOF markers using grep -ac (the -a flag treats binary files as text on both macOS BSD grep and Linux GNU grep):

grep -ac "%%EOF" proposal_internal.pdf removed_incremental.pdf removed_pymupdf_sanitized.pdf removed_pypdf.pdf

Output:

proposal_internal.pdf:1
removed_incremental.pdf:2
removed_pymupdf_sanitized.pdf:1
removed_pypdf.pdf:1

A count of 2 or more means the file has more than one revision section. Linearized (“Fast Web View”) PDFs also carry a second %%EOF by design, so the count alone does not prove hidden history, but a full rewrite (such as the PyMuPDF garbage=4 save above) removes the doubt either way.

Second, inspect the byte sizes with stat (macOS uses BSD stat -f "%N: %z bytes", whereas Linux uses GNU stat -c "%n: %s bytes"):

# macOS (BSD stat)
stat -f "%N: %z bytes" proposal_internal.pdf removed_incremental.pdf removed_no_gc.pdf removed_gc4_only.pdf removed_pymupdf_sanitized.pdf removed_pypdf.pdf

# Linux (GNU coreutils)
# stat -c "%n: %s bytes" proposal_internal.pdf removed_incremental.pdf removed_no_gc.pdf removed_gc4_only.pdf removed_pymupdf_sanitized.pdf removed_pypdf.pdf

Output:

proposal_internal.pdf: 6376 bytes
removed_incremental.pdf: 7237 bytes
removed_no_gc.pdf: 6314 bytes
removed_gc4_only.pdf: 4818 bytes
removed_pymupdf_sanitized.pdf: 4133 bytes
removed_pypdf.pdf: 3472 bytes

Pre-Send Checklist for Removing Confidential Pages

  1. Confirm %%EOF Count Is 1: Never send a PDF whose file size grew after deleting pages. If grep -ac "%%EOF" returns more than 1, re-save it with a full rewrite before sending (linearized files legitimately have two markers, but a rewrite removes any doubt).
  2. Audit Bookmarks and Attachments: If you deleted pages in-place rather than copying kept pages into a new document, inspect the bookmark sidebar for dead section headings (page = -1) and check the attachment panel for embedded CSV/XLSX files.
  3. Check Kept Pages for "Page X of Y" Gaps or Confidential Snippets: Removing Page 3 and Page 5 leaves static footers on Page 4 (Page 4 of 6) unchanged. You can trim old footer bands with the PDF Cropper and stamp continuous numbering with PDF Page Numbers (see our guide on PDF page number placement and cover pages). If a kept page still mentions a confidential figure in a summary paragraph, scrub that line with the Redact PDF tool (and see how to redact sensitive PDF data in your browser).
  4. Clear Document Properties and XMP Metadata: If you edited an existing file in place, strip author names and working titles with the PDF Metadata Remover or review our breakdown on why clearing PDF properties can leave XMP metadata behind.
  5. Search the Final File for Confidential Terms: Run the audit script above on the exact file you will send. Even a freshly rebuilt PDF can contain a removed page if a kept page links to it.