How to merge PDF files in Node.js and Python (2026)
Merging PDFs sounds like concatenation, but a PDF is a graph of objects, not a stream of pages, so "merge" really means copying page objects and everything they reference into a new document. Both major open-source libraries do that well. This guide shows pdf-lib for Node.js and pypdf for Python, the things that go wrong in production, and the one-request alternative with PDFOven.
Option 1: pdf-lib in Node.js
pdf-lib is pure JavaScript with no native dependencies, so it runs in Node, Deno, Bun, edge runtimes and the browser.
npm install pdf-lib
import { readFile, writeFile } from "node:fs/promises";
import { PDFDocument } from "pdf-lib";
async function mergePdfs(paths) {
const merged = await PDFDocument.create();
for (const path of paths) {
const source = await PDFDocument.load(await readFile(path));
const pages = await merged.copyPages(source, source.getPageIndices());
for (const page of pages) merged.addPage(page);
}
return merged.save();
}
await writeFile("merged.pdf", await mergePdfs(["cover.pdf", "report.pdf", "appendix.pdf"]));
copyPages copies each page and the fonts, images and other resources it uses into the destination document. addPage then places it at the end; insertPage(index, page) puts it anywhere else.
Picking and reordering pages
copyPages takes zero-based indices in whatever order you want them:
const report = await PDFDocument.load(await readFile("report.pdf"));
const pages = await merged.copyPages(report, [2, 0, 1]);
for (const page of pages) merged.addPage(page);
That copies the third page first, then the first and second. To drop a page, leave its index out.
Option 2: pypdf in Python
pypdf is also pure Python. Install it with the crypto extra if you might meet encrypted files:
pip install "pypdf[crypto]"
from pypdf import PdfWriter
writer = PdfWriter()
for path in ["cover.pdf", "report.pdf", "appendix.pdf"]:
writer.append(path)
writer.write("merged.pdf")
writer.close()
append takes a path, a file object or a PdfReader, and a pages argument for selecting pages. A tuple works like range, and a list picks pages in order:
writer.append("report.pdf", pages=(0, 3)) # pages 1 to 3
writer.append("report.pdf", pages=[4, 2, 3]) # pages 5, 3 and 4, in that order
Unlike pdf-lib's copyPages, append also imports the source's outline (bookmarks) by default; pass import_outline=False if you don't want it.
Pitfalls you will hit in production
Encrypted inputs. Many PDFs from banks, payroll systems and scanners are encrypted, sometimes with an empty user password. pdf-lib can't decrypt: PDFDocument.load throws an EncryptedPDFError, and { ignoreEncryption: true } only lets you load the file, while the copied content stays encrypted and usually renders as garbage. pypdf can decrypt:
from pypdf import PdfReader
reader = PdfReader("statement.pdf")
if reader.is_encrypted:
reader.decrypt("the-password")
writer.append(reader)
Memory. Both libraries parse the whole file into memory, and a merged document holds every input at once. A few hundred megabytes of scanned PDFs can use several times that in RAM. If you merge large files in a web process, put a size limit on inputs and do the work in a background job.
Bookmarks. Copying pages copies pages, not the document-level structures around them. With pdf-lib the outline is gone after a merge, and so are named destinations and the page labels that make a viewer show "iii" instead of "3". pypdf brings outlines across but not everything else.
Form fields. Form fields live in the document's AcroForm dictionary, not in the pages. Page copying brings the widgets along, but the fields can lose their link to the form, and two inputs with a field named name collide. If the inputs are filled forms, flatten them first so the values become ordinary page content, then merge.
Duplicate resources. Merging ten invoices that all embed the same font gives you ten copies of that font. That's harmless but makes the output bigger than you might expect. See the compression guide if size matters.
The PDFOven way: one request
PDFOven's /v1/pdf/merge joins 2 to 50 PDFs in the order given and returns the merged file. Each file can carry its own pages selection, and password-protected inputs take a password.
JSON with URLs
curl https://pdfoven.com/v1/pdf/merge \
-H "Authorization: Bearer $PDFOVEN_KEY" \
-H "Content-Type: application/json" \
-d '{
"files": [
{ "url": "https://example.com/cover.pdf" },
{ "url": "https://example.com/report.pdf", "pages": "2-last" },
{ "url": "https://example.com/statement.pdf", "password": "s3cret" }
],
"filename": "q3-report"
}' \
--output q3-report.pdf
Page selections use 1-based numbers: "1-3,5,8-", last for the final page, and backwards ranges such as "last-1" to reverse. Instead of a url, any file can be { "base64": "..." }.
Multipart upload with cURL
curl https://pdfoven.com/v1/pdf/merge \
-H "Authorization: Bearer $PDFOVEN_KEY" \
-F files=@cover.pdf \
-F files=@report.pdf \
-F files=@appendix.pdf \
--output merged.pdf
Per-file options go in a JSON payload field, matched to the uploads by position: -F 'payload={"files":[{},{"pages":"2-last"},{}]}'.
Node.js with fetch and FormData
import { openAsBlob } from "node:fs";
import { writeFile } from "node:fs/promises";
const form = new FormData();
for (const path of ["cover.pdf", "report.pdf", "appendix.pdf"]) {
form.append("files", await openAsBlob(path), path);
}
const res = await fetch("https://pdfoven.com/v1/pdf/merge", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PDFOVEN_KEY}` },
body: form,
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
await writeFile("merged.pdf", Buffer.from(await res.arrayBuffer()));
console.log("pages:", res.headers.get("X-Page-Count"));
openAsBlob needs Node 20 or later. Don't set Content-Type yourself; fetch adds the multipart boundary.
Python with requests
import os
import requests
paths = ["cover.pdf", "report.pdf", "appendix.pdf"]
files = [("files", (p, open(p, "rb"), "application/pdf")) for p in paths]
resp = requests.post(
"https://pdfoven.com/v1/pdf/merge",
headers={"Authorization": f"Bearer {os.environ['PDFOVEN_KEY']}"},
files=files,
timeout=120,
)
for _, (_, handle, _) in files:
handle.close()
if not resp.ok:
err = resp.json()["error"]
raise RuntimeError(f"{err['code']}: {err['message']}")
with open("merged.pdf", "wb") as f:
f.write(resp.content)
A merge counts as one render, whatever the number of files or pages. Files can be up to 50 MB each and 60 MB per request, with up to 5,000 pages each. An encrypted file without a password returns encrypted_pdf, and a bad page selection returns invalid_pages naming the file. Nothing is stored.
If an AI agent is doing the merging, the MCP server exposes the same operation as the merge_pdfs tool. For one-off merges there is a free merge PDF tool that needs no account.
When to use which
- pdf-lib when you work in JavaScript, the inputs are unencrypted, and you're happy to own memory limits and edge cases. It runs anywhere, including the browser.
- pypdf when you're in Python or need to decrypt inputs and keep bookmarks.
- PDFOven when you'd rather not handle encrypted files, big uploads and memory spikes in your own process, or you already use it to generate the PDFs you're merging. The free tier includes 100 renders a month, and 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