PDFOven is live on DevHunt this week — check it out →
← All guides

How to split a PDF and extract pages programmatically (2026)

Splitting a PDF comes in three flavours: pull out a few pages into a new file, cut the document into named ranges (chapters, statements, one invoice per customer), or break it into fixed-size chunks such as one file per page. This guide shows each with pypdf and pdf-lib, then the same three with a single PDFOven request.

Option 1: pypdf in Python

pip install "pypdf[crypto]"

Extract some pages into one file

from pypdf import PdfReader, PdfWriter

reader = PdfReader("statement.pdf")
writer = PdfWriter()
for index in [0, 2, 3]:  # pages 1, 3 and 4
    writer.add_page(reader.pages[index])
writer.write("extract.pdf")

reader.pages is zero-based and supports slicing, so reader.pages[4:10] is pages 5 to 10 and reader.pages[-1] is the last page.

One file per range

ranges = {"summary": (0, 2), "transactions": (2, 9), "terms": (9, len(reader.pages))}

for name, (start, stop) in ranges.items():
    writer = PdfWriter()
    writer.append(reader, pages=(start, stop))
    writer.write(f"statement-{name}.pdf")

Every N pages

size = 1
for start in range(0, len(reader.pages), size):
    writer = PdfWriter()
    writer.append(reader, pages=(start, min(start + size, len(reader.pages))))
    writer.write(f"page-{start + 1}.pdf")

If the file is encrypted, call reader.decrypt("password") before reading pages.

Option 2: pdf-lib in Node.js

pdf-lib has no split function; you create a new document and copy pages into it.

import { readFile, writeFile } from "node:fs/promises";
import { PDFDocument } from "pdf-lib";

const source = await PDFDocument.load(await readFile("statement.pdf"));

async function extract(indices, outPath) {
  const part = await PDFDocument.create();
  const pages = await part.copyPages(source, indices);
  for (const page of pages) part.addPage(page);
  await writeFile(outPath, await part.save());
}

await extract([0, 2, 3], "extract.pdf");

const size = 5;
const total = source.getPageCount();
for (let start = 0; start < total; start += size) {
  const indices = Array.from({ length: Math.min(size, total - start) }, (_, i) => start + i);
  await extract(indices, `part-${start / size + 1}.pdf`);
}

Things to know before you split

The parts can add up to more than the original. Pages share fonts and images. A font used on every page is stored once in the original, but each part gets its own copy, so splitting a 2 MB document into 100 single pages can easily produce 20 MB of files.

Off-by-one errors. Users and most tools talk about pages starting at 1; both libraries index from 0. Convert at the boundary and validate that ranges fall inside the page count before you start writing files.

Links and bookmarks break. Internal links that point to a page that ended up in another part no longer work, and outlines are not carried over by page copying.

Encryption. As with merging, pdf-lib can't decrypt. pypdf can if you have the password.

The PDFOven way

POST /v1/pdf/split takes one PDF and exactly one of three options:

Option Result
pages One PDF with just these pages, e.g. "1,3-4"
ranges One PDF per range, e.g. ["1-2", "3-9", "10-last"], returned as a zip
every Parts of this many pages each, returned as a zip

Page numbers start at 1. last is the final page, "8-" runs to the end, and a backwards range such as "last-1" reverses the order.

Extract pages

curl https://pdfoven.com/v1/pdf/split \
  -H "Authorization: Bearer $PDFOVEN_KEY" \
  -F file=@statement.pdf \
  -F pages=1,3-4 \
  --output extract.pdf

Split by ranges, as JSON

curl https://pdfoven.com/v1/pdf/split \
  -H "Authorization: Bearer $PDFOVEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file": { "url": "https://example.com/statement.pdf" },
    "ranges": ["1-2", "3-9", "10-last"],
    "filename": "statement"
  }' \
  --output statement.zip

The zip contains statement-pages-1-2.pdf and so on; single pages are named like statement-page-1.pdf. filename sets the prefix and defaults to split. The X-File-Count header says how many PDFs are inside.

One detail worth handling: if ranges or every produces a single part, for example every larger than the page count, you get that PDF directly rather than a one-file zip. Check the Content-Type header.

One file per page, unzipped in Python

import io
import os
import zipfile
import requests

with open("statement.pdf", "rb") as f:
    resp = requests.post(
        "https://pdfoven.com/v1/pdf/split",
        headers={"Authorization": f"Bearer {os.environ['PDFOVEN_KEY']}"},
        files={"file": ("statement.pdf", f, "application/pdf")},
        data={"every": "1"},
        timeout=120,
    )
resp.raise_for_status()

os.makedirs("pages", exist_ok=True)
if resp.headers["Content-Type"].startswith("application/zip"):
    with zipfile.ZipFile(io.BytesIO(resp.content)) as archive:
        archive.extractall("pages")
else:
    with open("pages/statement.pdf", "wb") as out:
        out.write(resp.content)

Unzipped in Node.js

Node has no built-in zip reader, so use a small library such as fflate:

import { openAsBlob } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { unzipSync } from "fflate";

const form = new FormData();
form.append("file", await openAsBlob("statement.pdf"), "statement.pdf");
form.append("ranges", "1-2");
form.append("ranges", "3-last");

const res = await fetch("https://pdfoven.com/v1/pdf/split", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.PDFOVEN_KEY}` },
  body: form,
});
if (!res.ok) throw new Error((await res.json()).error.message);

const bytes = new Uint8Array(await res.arrayBuffer());
await mkdir("parts", { recursive: true });
if (res.headers.get("Content-Type")?.startsWith("application/zip")) {
  for (const [name, data] of Object.entries(unzipSync(bytes))) {
    await writeFile(`parts/${name}`, data);
  }
} else {
  await writeFile("parts/split.pdf", bytes);
}

Repeat the ranges field once per range in a multipart form, or send the whole request as JSON.

If you'd rather not deal with a zip at all, send "response": "base64". You get JSON with the file as data, its content_type, and for zips a files list with each part's name and page_count.

A split counts as one render, however many parts it produces. Inputs can be up to 50 MB and 5,000 pages, and password-protected files take password (a form field in multipart, or inside the file object in JSON). Agents can call the same operation as the split_pdf MCP tool, and the free split PDF tool handles one-off jobs in the browser.

When to use which

  • pypdf if you're in Python and splitting is a small part of a larger pipeline that already reads PDFs.
  • pdf-lib for JavaScript, including splitting in the browser so the file never leaves the user's machine.
  • PDFOven when you want page-range parsing, encrypted inputs and zip packaging handled for you, or you're calling from a language with weak PDF libraries. Start with 100 free renders a month; paid plans are $9, $39 and $99 for 2,000, 15,000 and 60,000.

Skip the browser plumbing

PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.

Get a free API key