Bookkeepers, mortgage underwriters, and tax auditors regularly ask for a single month’s statement—or require a 12-month consolidated year-end export broken into YYYY-MM.pdf files for accounting ingestion. Splitting a bank statement PDF by month reliably is harder than splitting standardized forms because bank statements combine three structural traps: variable page lengths per cycle, silent owner-password encryption locks, and unreferenced PDF stream leakage when pages are removed improperly.

Three Structural Traps in Consolidated Bank Statements

To test these failure modes on concrete data, we can generate a synthetic 10-page Q1 consolidated statement (q1_2026_statements.pdf) and an owner-locked copy (q1_2026_statements_locked.pdf, AES-256 with an empty user password and print-only permissions):

  • January 2026 (Pages 1–3): Page 1 of 3 through Page 3 of 3, with payments to Acme Corp.
  • February 2026 (Pages 4–5): A low-volume month, Page 1 of 2 and Page 2 of 2.
  • March 2026 (Pages 6–10): A high-volume quarter-end cycle, Page 1 of 5 through Page 5 of 5, with payments to Initech and a cleared Check #1042 on Page 4 of 5.

The generator needs pymupdf (see the virtual-environment setup in Method 1):

import pymupdf

# (month, period end, pages, payee) for a synthetic Q1 consolidated statement
cycles = [("2026-01", "31", 3, "Acme Corp"), ("2026-02", "28", 2, "Globex LLC"), ("2026-03", "31", 5, "Initech")]
doc = pymupdf.open()
for month, last_day, n, payee in cycles:
    for p in range(1, n + 1):
        page = doc.new_page(width=612, height=792)
        page.insert_text((72, 72), f"Statement Period: {month}-01 to {month}-{last_day}", fontsize=12)
        page.insert_text((72, 96), f"Page {p} of {n}", fontsize=10)
        page.insert_text((72, 140), f"{month}-15  Payment to {payee}  -1,250.00", fontsize=10)
        if month == "2026-03" and p == 4:
            page.insert_text((72, 164), "Cleared Check #1042  -980.00", fontsize=10)
doc.save("q1_2026_statements.pdf", garbage=4, deflate=True)
# Owner-locked copy: AES-256, empty user password, print-only permissions
doc.save("q1_2026_statements_locked.pdf", garbage=4, deflate=True, encryption=pymupdf.PDF_ENCRYPT_AES_256,
         owner_pw="bank-owner-secret", user_pw="", permissions=pymupdf.PDF_PERM_PRINT)

1. Variable Cycle Lengths Break Fixed-Chunk Splitting

If you split a 10-page consolidated statement using a fixed 3-page interval, only January (1–3) lands cleanly. The second chunk (4–6) merges all of February (4–5) with Page 1 of March (6), exposing March’s opening balance and first deposits while leaving March’s remaining pages (7–10) without an account header.

2. Silent Owner-Password Encryption (Empty User Password)

Some banks apply AES encryption with an empty user password ("") and a restricted owner password (for example, print-only permissions). Because PDF viewers automatically try an empty user password on open, the file opens without a password prompt in Chrome, macOS Preview, or Adobe Acrobat Reader.

Utiliome’s in-browser PDF Splitting Tool loads files with pdf-lib’s PDFDocument.load(buf, { ignoreEncryption: true }), which skips decryption instead of throwing an error. The split appears to succeed, but the copied page streams are still encrypted while the new files have no /Encrypt dictionary, so the monthly PDFs can open with garbled or blank pages.

Even when your range syntax (1-3, 4-5, 6-10) is valid, unlock an owner-locked bank statement first—with the PDF Unlock tool or the pypdf script below—and then split it.

3. Orphaned Page Content When Deleting Pages In Place

In the PDF specification (ISO 32000-1), a page’s visible ledger text lives inside indirect /Contents stream objects referenced by a /Page dictionary. If you isolate February by deleting Pages 1–3 and 6–10 from the /Pages tree without garbage-collecting unreferenced objects, a PDF viewer displays only 2 pages, but the raw binary file still contains January and March transactions.

flowchart LR
    A["Consolidated Q1 PDF<br/>(10 pages: Jan 1-3, Feb 4-5, Mar 6-10)"] --> B{"Splitting Strategy"}
    B -->|"In-Place Page Tree Delete<br/>(garbage=0)"| C["2 Visible Pages, 31 Xref Objects<br/>(Jan & Mar payees remain in streams)"]
    B -->|"Fresh Document Copy<br/>(pdf-lib copyPages / pypdf PdfWriter)"| D["3 Monthly PDFs<br/>(No orphaned streams; linked pages still copied)"]

Method 1: Automated Boundary Detection & Decryption in Python (pypdf)

A short Python script solves both the silent bank encryption lock and variable page lengths in one pass. It authenticates against the empty user password (""), scans each page for the Page 1 of N and Statement Period: headers, writes standalone statement_YYYY-MM.pdf files, and prints the exact comma-separated range string for browser tools.

On both macOS (Homebrew Python) and Ubuntu/Debian Linux, PEP 668 blocks global pip installs by default, so create a virtual environment first. Be sure to install cryptography alongside pypdf—without it, opening an AES-encrypted bank PDF raises pypdf.errors.DependencyError: cryptography>=3.1 is required for AES algorithm:

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

Save and run split_by_month.py:

import re
import os
from pypdf import PdfReader, PdfWriter

pdf_path = "q1_2026_statements_locked.pdf"
reader = PdfReader(pdf_path)

print(f"File: {pdf_path} | Encrypted: {reader.is_encrypted}")
if reader.is_encrypted:
    # Authenticate with empty user password ("") to bypass bank owner lock
    status = reader.decrypt("")
    print(f"Decrypted with empty user password (status={status.name}), total pages={len(reader.pages)}")

# Match 'Page 1 of N' and extract the YYYY-MM statement period
period_re = re.compile(r"Statement Period:\s*(\d{4}-\d{2})-\d{2}\s+to\s+\d{4}-\d{2}-\d{2}")
page_one_re = re.compile(r"Page\s+1\s+of\s+(\d+)")

starts = []
for idx, page in enumerate(reader.pages):
    text = page.extract_text()
    if page_one_re.search(text):
        m = period_re.search(text)
        month_label = m.group(1) if m else f"part_{len(starts) + 1:02d}"
        starts.append((idx, month_label))

ranges_for_ui = []
for i, (start_idx, month_label) in enumerate(starts):
    end_idx = starts[i + 1][0] - 1 if i + 1 < len(starts) else len(reader.pages) - 1
    ranges_for_ui.append(f"{start_idx + 1}-{end_idx + 1}")

    writer = PdfWriter()
    for p in range(start_idx, end_idx + 1):
        writer.add_page(reader.pages[p])

    out_name = f"statement_{month_label}.pdf"
    with open(out_name, "wb") as f:
        writer.write(f)
    print(f"{out_name}: pages {start_idx + 1}-{end_idx + 1} ({end_idx - start_idx + 1} pages, {os.path.getsize(out_name)} bytes)")

print("Browser range string:", ", ".join(ranges_for_ui))

Output:

File: q1_2026_statements_locked.pdf | Encrypted: True
Decrypted with empty user password (status=USER_PASSWORD), total pages=10
statement_2026-01.pdf: pages 1-3 (3 pages, 2698 bytes)
statement_2026-02.pdf: pages 4-5 (2 pages, 1967 bytes)
statement_2026-03.pdf: pages 6-10 (5 pages, 4370 bytes)
Browser range string: 1-3, 4-5, 6-10

Each output file is written via a fresh PdfWriter() without an /Encrypt dictionary, automatically stripping the bank’s owner-password lock while keeping only that month’s pages.

Method 2: Splitting by Custom Page Ranges in the Browser

When your bank statement PDF is not encrypted (or after you strip an owner lock with the PDF Unlock tool), you can split all 12 months—or isolate a single month—using the PDF Splitting Tool. The tool runs in your browser, so the file is not uploaded.

Here is how the tool processes your statement:

  1. Thumbnail Grid and Preview: The tool renders a thumbnail of every page, and you can open any page in a larger preview. Use it to find where each month’s Page 1 of N summary starts before you type ranges.
  2. Two Split Modes (Split All Pages vs. Custom Page Ranges): Split All Pages saves every page as its own 1-page PDF (<filename>-page-1.pdf, <filename>-page-2.pdf, etc.). For multi-page monthly statements, switch to Custom Page Ranges and enter each month’s 1-indexed page span separated by commas:
    1-3, 4-5, 6-10
    Each range becomes its own new PDF (<filename>-part-1.pdf, <filename>-part-2.pdf, etc.), built by copying only those pages with pdf-lib’s copyPages(). Range numbers refer to the current order of the thumbnail grid, so reset any reordering before you split.
  3. Single PDF vs. ZIP: When the split produces two or more files, the tool packages them into <filename>-split-<count>-files.zip. If you only need February for a loan application and enter just 4-5, the tool skips the ZIP and downloads <filename>-part-1.pdf directly.

Keep two implementation details in mind when using the browser splitter:

  • Out-of-Range Pages Are Clamped or Skipped: A range that runs past the last page is cut off at the last page (6-12 on a 10-page file becomes 6-10). Open-ended tokens (6-), reversed ranges (5-4), and ranges or single pages that start beyond the last page are skipped without a warning. If no valid range remains, the tool shows Failed to split PDF. Please verify your page ranges and try again. Always check the number of PDFs inside the downloaded ZIP.
  • Reset Metadata and Bookmarks: Because copyPages() builds a fresh PDFDocument, document-level /Outlines bookmarks and original /Info metadata are not carried over. (For details on hidden XMP packets in PDFs, see our guide on PDF XMP metadata left behind.)

Verifying Cross-Month Transaction Leakage

When sending a single monthly statement (February 2026, pages 4–5) to a lender or opposing counsel, you must be certain that payees and wire amounts from January (Acme Corp) or March (Initech, Check #1042) are not sitting in unreferenced indirect objects inside the file.

To see the difference between in-place page deletion (garbage=0) and fresh object copying (pypdf PdfWriter), we ran the following verification script using PyMuPDF:

import os
import pymupdf

# Create a naive in-place deletion of Jan (0..2) and Mar (5..9) without garbage collection
doc = pymupdf.open("q1_2026_statements.pdf")
doc.delete_pages([0, 1, 2, 5, 6, 7, 8, 9])
doc.save("feb_naive_no_gc.pdf", garbage=0, deflate=True)
doc.close()

# Scan all indirect PDF streams (both literal ASCII and hex-encoded [<...>]TJ strings)
terms = ["Acme Corp", "Initech", "Check #1042"]
files = [
    "feb_naive_no_gc.pdf",
    "statement_2026-02.pdf",
]

for fname in files:
    d = pymupdf.open(fname)
    leaked = []
    for xref in range(1, d.xref_length()):
        if d.xref_is_stream(xref):
            raw = d.xref_stream(xref).decode("latin1", errors="ignore").lower()
            for t in terms:
                if (t.lower() in raw or t.encode().hex() in raw) and t not in leaked:
                    leaked.append(t)
    print(f"{fname}: pages={len(d)}, xrefs={d.xref_length()-1}, size={os.path.getsize(fname)} B, leaked={leaked}")
    d.close()

Output:

feb_naive_no_gc.pdf: pages=2, xrefs=31, size=5003 B, leaked=['Acme Corp', 'Initech', 'Check #1042']
statement_2026-02.pdf: pages=2, xrefs=13, size=1967 B, leaked=[]

Look closely at the cross-reference (xref) counts and file sizes:

Output FileSplitting MethodVisible PagesIndirect Objects (xrefs)File Size (Bytes)Other Months’ Payees in Streams
feb_naive_no_gc.pdfIn-place delete_pages() (garbage=0)2315,003 BLeaked (Acme Corp, Initech, Check #1042)
statement_2026-02.pdfpypdf PdfWriter.add_page()2131,967 BNone ([])

The clean fresh-copy result holds for this test file because its February pages contain no links to January or March pages. Both copyPages() and PdfWriter copy every object a kept page references, so a kept page that links to another month’s page (for example, a summary page linking to a check-image appendix) pulls that page into the output. Run the script above on your own split files before sending them. If it finds other months’ payees, remove the links on the kept pages and split again, or build each month with PyMuPDF (doc.select(month_pages) and doc.save(..., garbage=4)), which drops links to pages that are no longer in the document.

You can also confirm the byte reduction from your terminal using stat (note that macOS uses BSD stat -f, whereas Linux uses GNU stat -c):

# macOS (BSD stat)
stat -f "%N: %z bytes" q1_2026_statements.pdf feb_naive_no_gc.pdf statement_2026-02.pdf

# Linux (GNU coreutils)
# stat -c "%n: %s bytes" q1_2026_statements.pdf feb_naive_no_gc.pdf statement_2026-02.pdf

Output:

q1_2026_statements.pdf: 5058 bytes
feb_naive_no_gc.pdf: 5003 bytes
statement_2026-02.pdf: 1967 bytes

4-Point Pre-Flight Checklist Before Sharing a Split Statement

Before uploading a split monthly bank statement to a lender portal or sending it to an external bookkeeper, verify these four items:

  1. Check for Silent Bank Encryption First: The browser splitter does not report encryption errors; an owner-locked file splits into garbled or blank pages. Check reader.is_encrypted in pypdf before splitting, and if it is True, unlock the file with the PDF Unlock tool or reader.decrypt("") first.
  2. Verify First and Last Pages of Every Range: Confirm each split file begins with Page 1 of N and ends with Page N of N (including trailing check-image appendices or fee notices) so no transaction page rolls into the adjacent month.
  3. Search Each Split File for Other Months’ Payees: Run the verification script above on the exact file you will send. A file size close to the multi-month source is a warning sign, but a smaller file is not proof: a linked page from another month can still be inside it.
  4. Redact Account Numbers or Unrelated Line Items When Needed: Splitting isolates a single month, but every transaction on those kept pages remains readable. If you need to mask an account number or specific counterparty on the kept pages, use the Redact PDF tool, or see our guide on splitting large PDFs by page range if you are dealing with multi-hundred-megabyte archival scans.