When you feed a stack of documents through an Automatic Document Feeder (ADF) with duplex scanning enabled, any single-sided sheet in the packet produces a full-page scan of its empty back side. Removing blank pages from a scanned PDF is harder than cleaning up a born-digital document because a scanned blank page is not structurally empty.

In the PDF specification (ISO 32000-1), a page from an office scanner contains a /Contents stream that paints a full-page raster /Image XObject. To a PDF parser, a blank reverse sheet looks identical in structure to a dense contract page.

Why Naive Blank-Page Checks Fail on Scans

To test how different heuristics behave on scan artifacts, we can generate a 6-page, 150 DPI US Letter (1275 x 1650 px) scanned packet (scanned_packet.pdf) with simulated defects:

  • Page 1: Full-page contract text
  • Page 2: Blank reverse side with off-white paper grain (246–255), 30 platen dust specks, and a 14 px dark scanner border along the top and left edges (every page carries this border)
  • Page 3: Dense invoice table
  • Page 4: Blank reverse side with faint ink bleed-through (232 gray bands) from the front side
  • Page 5: Sparse signature page containing only a short signature line near the bottom
  • Page 6: Blank reverse side with scanner edge shadow and dust specks

The generator below needs pymupdf, pillow, and numpy (see the virtual-environment setup in Method 1):

import io
import numpy as np
import pymupdf
from PIL import Image, ImageDraw

W, H = 1275, 1650  # US Letter at 150 DPI
rng = np.random.default_rng(7)

def sheet(kind):
    img = Image.fromarray(rng.integers(246, 256, (H, W), dtype=np.uint8))  # paper grain
    d = ImageDraw.Draw(img)
    if kind == "text":
        for y in range(150, 1450, 42):
            d.text((130, y), "Lorem ipsum dolor sit amet, consectetur adipiscing elit " * 2, fill=25, font_size=30)
    elif kind == "table":
        for y in range(200, 1400, 60):
            d.line([(120, y), (1150, y)], fill=30, width=3)
            d.text((140, y + 12), "INV-0042   Widget assembly   12   $1,240.00", fill=25, font_size=30)
    elif kind == "signature":
        d.line([(700, 1390), (1100, 1390)], fill=20, width=4)
        d.line([(720, 1370), (800, 1330), (880, 1380), (960, 1320), (1060, 1375)], fill=30, width=5)
    else:
        if kind == "bleed":
            for y in range(150, 1450, 42):
                d.rectangle([(160, y), (1100, y + 22)], fill=232)  # faint bleed-through
        for x, y in rng.integers(150, 1500, (30, 2)):  # platen dust
            d.ellipse([(x, y), (x + 3, y + 3)], fill=40)
    d.rectangle([(0, 0), (W, 13)], fill=15)   # ADF edge shadow, top
    d.rectangle([(0, 0), (13, H)], fill=15)   # ADF edge shadow, left
    return img

doc = pymupdf.open()
for kind in ["text", "blank", "table", "bleed", "signature", "blank"]:
    buf = io.BytesIO()
    sheet(kind).save(buf, "JPEG", quality=75, dpi=(150, 150))
    page = doc.new_page(width=612, height=792)
    page.insert_image(page.rect, stream=buf.getvalue())
doc.save("scanned_packet.pdf")

If you inspect this file with pypdf by checking extracted text length and compressed image stream byte size, both heuristics fail:

from pypdf import PdfReader

reader = PdfReader("scanned_packet.pdf")
for idx, page in enumerate(reader.pages, start=1):
    txt_len = len(page.extract_text().strip())
    img_bytes = sum(len(im.data) for im in page.images)
    print(f"Page {idx}: text_len={txt_len}, images={len(page.images)}, image_bytes={img_bytes}")

Output:

Page 1: text_len=0, images=1, image_bytes=421578
Page 2: text_len=0, images=1, image_bytes=110073
Page 3: text_len=0, images=1, image_bytes=271130
Page 4: text_len=0, images=1, image_bytes=113141
Page 5: text_len=0, images=1, image_bytes=113019
Page 6: text_len=0, images=1, image_bytes=109873

Notice two traps in the output above:

  1. text_len is 0 on all 6 pages: Before OCR, no text operators exist in the page stream. Deleting pages where extract_text() is empty deletes the entire document.
  2. Compressed byte size (image_bytes) misclassifies sparse pages: Page 4 (a blank back page with faint bleed-through) compresses to 113,141 bytes, slightly more than Page 5 (a real signature page) at 113,019 bytes, because paper grain dominates the JPEG size of both. Any byte-size cutoff that deletes Page 4 will also wipe out the signed Page 5.

The Fix: Margin-Trimmed Dark-Pixel Coverage

Even pixel-level checks can be tricked if you measure the entire page area. When a sheet feeds through an ADF at a slight angle, the scanner captures a thin black strip along the edge of the page. In our 6-page test file, that 14 px border (plus dust) accounts for roughly 2.09% of all pixels on each blank page—nearly identical to the 2.27% total dark pixels on Page 5 (the sparse signature page).

Cropping 5% off each outer edge before measuring dark pixels eliminates scanner edge shadows and three-hole punch marks while preserving headers, footers, and bottom-of-page signatures.

flowchart LR
    A["Scanned PDF Page<br/>(Raster XObject)"] --> B["Clip 5% Outer Margin<br/>(Strip edge shadows & punch holes)"]
    B --> C["Render 100 DPI Grayscale<br/>(0 = black, 255 = white)"]
    C --> D{"Pixels < 200<br/>below 0.10%?"}
    D -->|Yes| E["Mark Page as Blank<br/>& Purge XObject"]
    D -->|No| F["Keep Page<br/>(Text or Signature)"]

Here is how the uncropped vs. 5% margin-trimmed metrics compare across all six pages of scanned_packet.pdf (counting pixels darker than 200 on a 0–255 grayscale scale; the full-page column is the same measurement without clip=):

PageActual ContentCompressed Image SizeFull-Page Dark PixelsMargin-Trimmed Dark Pixels (5% Inset)Classification (< 0.10%)
Page 1Contract text421,578 B16.3194%16.4970%Keep
Page 2Blank back + border + dust110,073 B2.0948%0.0207%Blank (Delete)
Page 3Invoice table271,130 B11.1465%10.0529%Keep
Page 4Blank back + bleed-through113,141 B2.0994%0.0285%Blank (Delete)
Page 5Signature at bottom only113,019 B2.2742%0.2426%Keep
Page 6Blank back + border109,873 B2.0926%0.0215%Blank (Delete)

After trimming the 5% perimeter margin, the blank pages drop to 0.02%–0.03% dark pixels, whereas the sparse signature page stays at 0.2426%—more than 8 times higher than the noisiest blank sheet.

Method 1: Automated Detection and Deletion in Python (PyMuPDF)

For multi-hundred-page scans where blank pages appear at irregular intervals, PyMuPDF (pymupdf) can sample the inner 90% of each page at 100 DPI in grayscale, delete the blank pages, and garbage-collect their image streams in a single pass.

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

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

[!NOTE] Starting with PyMuPDF 1.24+, import the library as import pymupdf rather than the legacy import fitz alias, which now emits a deprecation warning in PyMuPDF 1.28+.

Save and run the following script (remove_blank_scans.py):

import pymupdf

pdf_path = "scanned_packet.pdf"
out_path = "cleaned_packet.pdf"

doc = pymupdf.open(pdf_path)
blank_pages = []  # 0-based page indices

for i, page in enumerate(doc):
    rect = page.rect
    # Trim 5% off each edge to ignore scanner border shadows and punch holes
    mx = rect.width * 0.05
    my = rect.height * 0.05
    clip = pymupdf.Rect(rect.x0 + mx, rect.y0 + my, rect.x1 - mx, rect.y1 - my)

    pix = page.get_pixmap(dpi=100, colorspace=pymupdf.csGRAY, clip=clip)
    samples = pix.samples
    dark_pixels = sum(1 for px in samples if px < 200)
    dark_ratio = dark_pixels / len(samples)

    is_blank = dark_ratio < 0.001  # 0.10% dark-pixel threshold
    print(f"Page {i + 1}: dark_ratio={dark_ratio:.4%} -> {'BLANK (delete)' if is_blank else 'KEEP'}")
    if is_blank:
        blank_pages.append(i)

if blank_pages:
    doc.delete_pages(blank_pages)

# Save once without garbage collection (for comparison), then with garbage=4,
# which removes unreferenced objects (including orphaned image XObjects)
doc.save("cleaned_no_gc.pdf")
doc.save(out_path, garbage=4, deflate=True)
doc.close()
print(f"Deleted {len(blank_pages)} pages")

Output:

Page 1: dark_ratio=16.4970% -> KEEP
Page 2: dark_ratio=0.0207% -> BLANK (delete)
Page 3: dark_ratio=10.0529% -> KEEP
Page 4: dark_ratio=0.0285% -> BLANK (delete)
Page 5: dark_ratio=0.2426% -> KEEP
Page 6: dark_ratio=0.0215% -> BLANK (delete)
Deleted 3 pages

Why Garbage Collection Matters for File Size

In the PDF object model, calling doc.delete_pages() only removes the /Page dictionary references from the /Pages tree. If you call doc.save() with default settings (garbage=0), the deleted pages’ raster /XObject streams stay inside the file body as unreferenced objects.

We can verify the byte difference using stat (use stat -f "%N: %z bytes" on macOS BSD stat, or stat -c "%n: %s bytes" on Linux GNU coreutils):

# macOS (BSD stat)
stat -f "%N: %z bytes" scanned_packet.pdf cleaned_no_gc.pdf cleaned_packet.pdf

# Linux (GNU coreutils)
# stat -c "%n: %s bytes" scanned_packet.pdf cleaned_no_gc.pdf cleaned_packet.pdf

Output:

scanned_packet.pdf: 1146272 bytes
cleaned_no_gc.pdf: 1146251 bytes
cleaned_packet.pdf: 810846 bytes

Without garbage=4, deleting three full-page scans shrank the file by just 21 bytes (1,146,272 to 1,146,251 bytes). Passing garbage=4, deflate=True purges the unreferenced JPEG streams and drops the file size by 29.3% to 810,846 bytes.

Method 2: Visual & Duplex Even/Odd Removal in the Browser

If you don’t want to set up a Python environment—or if you want to visually confirm that a nearly empty page does not contain a handwritten initial or notary stamp before deleting it—use the Remove PDF Pages tool. The tool runs in your browser, so the file is not uploaded.

Here is what the tool actually does under the hood:

  1. Thumbnail Grid Rendering: It renders every page as a thumbnail with pdf.js, displaying the page grid in your browser so you can spot blank sheets, separator pages, or upside-down scans at a glance.
  2. One-Click Duplex Selection (Select Even Pages / Select Odd Pages): If you scanned a single-sided packet through a duplex feeder so that every second page (2, 4, 6…) is blank, click Select Even Pages in the toolbar to mark all even pages at once. If a single sheet in the packet actually had content on the reverse side, click its thumbnail to unmark it before saving.
  3. Fresh Copy via pdf-lib: When you click Remove N Selected Pages, the tool creates a new PDF and copies only the remaining pages into it with copyPages(). Pages you removed are not carried over (unless a kept page links to them), so their image streams are left behind without a separate garbage-collection step.

Keep two constraints in mind when using the browser tool:

  • No automatic blank detection: You select pages visually, with the Select Even Pages / Select Odd Pages buttons, or by typing page numbers and ranges (such as 2, 4-6) into the toolbar’s range field.
  • Document-level metadata and outlines: Because copyPages() builds a fresh PDFDocument, document-level bookmarks (/Outlines) and original Info dictionary fields are not copied over. Also, you must keep at least one page in the document.

If you prefer a command-line equivalent for stripping every even page from a duplex scan of single-sided sheets, you can also run a short pypdf script:

import os
from pypdf import PdfReader, PdfWriter

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

# Slice [::2] keeps 0-based indices 0, 2, 4 (Pages 1, 3, 5)
for page in reader.pages[::2]:
    writer.add_page(page)

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

print("Saved:", len(writer.pages), "pages,", os.path.getsize("cleaned_pypdf.pdf"), "bytes")

Output:

Saved: 3 pages, 811245 bytes

Choosing the Right Blank-Page Workflow

ScenarioRecommended MethodHow Blank Pages Are IdentifiedPreserves Bookmarks (/Outlines)
Single-sided stack scanned on duplex ADFRemove PDF Pages (Select Even Pages) or pypdf pages[::2]Every even-numbered page (2, 4, 6...)No (pdf-lib / pypdf new writer)
1–50 page scan with scattered blank backsRemove PDF Pages thumbnail gridVisual inspection before clicking removeNo
100+ page scan with irregular blank pagesPython (pymupdf margin-trimmed pixel ratio)5% inset crop + < 0.10% pixels darker than 200Yes (in-place delete_pages + garbage=4)

If your scanned pages also kept thick black edges on the kept pages after removing the blank sheets, see our guide on splitting two-page book scans and trimming scanner borders or use the PDF Cropper to trim the outer viewport. If you need to reorder pages at the same time you delete blank sheets, use Organize PDF.