PDF processing
Merging, splitting and compressing PDFs server-side without a Ghostscript headache
How to merge, split and shrink PDFs in Node and Python — what pdf-lib and pypdf do well, where they stop, and why reaching for Ghostscript turns a code problem into a deployment problem.
The RoutexAPI TeamUpdated August 11, 20267 min read
Almost every back office grows a PDF problem eventually. Invoices arrive as separate files and have to become one statement. A 40-page scan needs to be split so each department gets only its pages. A user uploads a 60 MB "PDF" that is really twenty phone photos, and your email provider rejects anything over 25 MB. None of this is exotic, and all of it is doable in pure Node or Python — right up until the word compress enters the conversation, at which point most tutorials quietly shell out to Ghostscript and stop explaining. This guide walks the three operations, shows working code, and is honest about where the easy libraries end and the deployment pain begins.
Merging PDFs
Merging is the friendliest of the three because it is structural: you are copying page objects from several documents into one and writing the result. No rasterising, no re-encoding.
Node, with pdf-lib
pdf-lib is pure JavaScript, has no native dependencies, and runs the same on your laptop, in a Lambda, and in a container. That property alone makes it the right default.
import { PDFDocument } from "pdf-lib";
import { readFile, writeFile } from "node:fs/promises";
async function mergePdfs(paths) {
const merged = await PDFDocument.create();
for (const path of paths) {
const bytes = await readFile(path);
const doc = await PDFDocument.load(bytes);
const pages = await merged.copyPages(doc, doc.getPageIndices());
for (const page of pages) merged.addPage(page);
}
return merged.save(); // Uint8Array
}
const bytes = await mergePdfs(["a.pdf", "b.pdf", "c.pdf"]);
await writeFile("merged.pdf", bytes);
The one detail people miss: copyPages must be called per source document, and you have to add every returned page. Copying page indices from one document into another without copyPages corrupts the cross-reference table, and the file opens blank in strict readers while looking fine in lenient ones — the worst kind of bug because it passes your test and fails at the client.
Python, with pypdf
from pypdf import PdfReader, PdfWriter
def merge_pdfs(paths, out_path):
writer = PdfWriter()
for path in paths:
reader = PdfReader(path)
for page in reader.pages:
writer.add_page(page)
with open(out_path, "wb") as fh:
writer.write(fh)
merge_pdfs(["a.pdf", "b.pdf", "c.pdf"], "merged.pdf")
pypdf (the maintained successor to PyPDF2) is likewise pure Python. For a plain concatenation it is a few lines, and PdfWriter also carries append() if you want to merge whole files with bookmarks preserved.
The things that bite
- Encrypted inputs. A password-protected source throws on load. Detect it and either decrypt with the known password (
reader.decrypt(pw)in pypdf) or reject the file with a clear message — do not let the exception bubble up as a 500. - Forms (AcroForm). Merging two documents that both contain form fields with the same field names silently merges the fields too, so filling one later fills the "same" field on pages from the other document. If your inputs have forms, flatten them first.
- Linearisation and size. A naive merge concatenates resources without deduplicating fonts, so three files that embed the same font ship it three times. This is where "merge" bleeds into "compress".
Splitting PDFs
Splitting is merging in reverse and just as structural. Extract a page range into a new document.
import { PDFDocument } from "pdf-lib";
async function extractRange(bytes, start, end) {
const src = await PDFDocument.load(bytes);
const out = await PDFDocument.create();
const indices = [];
for (let i = start; i <= end; i++) indices.push(i);
const pages = await out.copyPages(src, indices);
for (const page of pages) out.addPage(page);
return out.save();
}
In Python the shape is identical: build a PdfWriter, add_page the slice you want, write it out. The interesting decisions in splitting are never the code — they are the boundaries. Splitting "one file per page" is trivial; splitting "one file per invoice" means you need to know where invoices begin, which is a text-extraction or barcode-detection problem sitting on top of the split. Keep those two concerns separate: a function that splits by explicit page ranges, and a separate function that decides what the ranges are.
Compressing PDFs — where it stops being easy
Here is the honest part. pdf-lib and pypdf cannot meaningfully compress a PDF whose weight is images, and image weight is what almost every oversized PDF is made of. They can re-save with object streams and drop unused objects, which trims a structurally bloated file, but they will not re-encode a 12-megapixel scan down to a screen-resolution JPEG, because doing that means decoding the image, resampling it, and re-compressing it — a different class of work.
pdf-lib's save({ useObjectStreams: true }) and pypdf's writer.add_page with page.compress_content_streams() help with structural bloat:
from pypdf import PdfReader, PdfWriter
reader = PdfReader("in.pdf")
writer = PdfWriter()
for page in reader.pages:
page.compress_content_streams() # recompresses content streams (CPU-bound)
writer.add_page(page)
writer.add_metadata(reader.metadata or {})
with open("out.pdf", "wb") as fh:
writer.write(fh)
That will take a 5 MB file to maybe 4 MB. It will do essentially nothing to a 60 MB scan, because the bytes are in the images, and neither library resamples images.
The Ghostscript temptation
The universal Stack Overflow answer is:
gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 \
-dPDFSETTINGS=/ebook -dNOPAUSE -dBATCH -dQUIET \
-sOutputFile=out.pdf in.pdf
This genuinely works — /ebook targets ~150 dpi and will take that 60 MB scan under 10 MB — and it is where the real cost hides. Ghostscript is not an npm or PyPI package; it is a system binary you now have to install, pin, patch and license:
- Deployment. Your image needs
apt-get install ghostscript, which pulls a chain of dependencies, inflates the build, and means your "pure Node" service now has a native binary whose version you must track for CVEs (Ghostscript has a long history of them, because it is a full PostScript interpreter processing untrusted input). - Licensing. Ghostscript is AGPL. If you distribute your product or run it as a network service, AGPL's terms reach you, and "we just call the binary" does not exempt you. For many companies this alone is a blocker that legal finds after it ships.
- Untrusted input as a security surface. You are feeding user-uploaded files into a PostScript interpreter that has had sandbox-escape CVEs. That belongs behind isolation — a locked-down subprocess, resource limits, a timeout — not a bare
exec. - Operational cost. It is CPU- and memory-heavy, single-file-at-a-time, and slow enough that you should not run it inline on a request thread. Now you need a queue, a worker, and a timeout policy.
qpdf (Apache-2.0, so no licensing cliff) is the better tool for structural optimisation and linearisation — qpdf --linearize --object-streams=generate in.pdf out.pdf — but it deliberately does not resample images either. So the moment your requirement is "make the images smaller", you are back to Ghostscript or an imaging pipeline (render pages, resample, rebuild), with all of the above.
An honest decision table
| Operation | Pure Node/Python enough? | Reach for a system tool? |
|---|---|---|
| Merge | Yes (pdf-lib / pypdf) | No |
| Split by page range | Yes | No |
| Drop structural bloat | Yes (object streams) | qpdf for linearisation |
| Shrink image-heavy scans | No | Ghostscript (/ebook, /screen) |
| Flatten forms before merge | Yes (pdf-lib) | No |
Reading page counts and metadata cheaply
Before you merge or split, you often need to inspect: how many pages, is it encrypted, how big will the job be. Both libraries let you read that without processing content, and it is worth doing up front so you can reject a pathological file — a 4,000-page upload, or an encrypted one — before you spend CPU on it.
from pypdf import PdfReader
reader = PdfReader("in.pdf")
print(len(reader.pages)) # page count, no content decoding
print(reader.is_encrypted) # gate before attempting operations
print(reader.metadata) # title/author, if present
Two guardrails belong here, because this is the boundary where untrusted files enter your pipeline. Cap the page count and the byte size and reject anything past the limit with a clear message rather than letting a huge file OOM a worker. And treat a parse failure as a validation error, not a crash — malformed and truncated PDFs are common in real uploads, so a try/except around the load that returns "this file could not be read" is the difference between a handled 400 and a paged on-call engineer.
Streaming and memory
The examples above read whole files into memory, which is fine for a handful of small PDFs and a problem at scale. Merging fifty 20 MB files the naive way holds a gigabyte of buffers at once. If you process large or many documents, work from file paths and streams rather than in-memory byte arrays, release each source once its pages are copied, and — critically — do not run merge, split or compress inline on a request thread. These are CPU- and memory-bound operations; put them behind a queue with a per-job memory ceiling and a timeout, return a job id immediately, and deliver the result asynchronously. That shape also gives you a natural place to enforce the size caps above and to isolate the Ghostscript step discussed next.
Self-hosting the whole thing
You can absolutely own this end to end. Run pdf-lib/pypdf in-process for merge and split, add qpdf for linearisation, and stand up a separate, sandboxed worker for Ghostscript compression: a queue, a subprocess with a CPU/memory ceiling and a hard timeout, input validation before the file ever reaches gs, and a pinned Ghostscript you patch on its CVE cadence. Budget for the AGPL review. This is a real, maintainable setup — it is just meaningfully more than "npm install", and the ongoing cost is the binary, not the code.
If the compression tier is the only reason you would take on a native, AGPL, CVE-tracked dependency, that is exactly the trade a managed endpoint exists to remove: you keep merge and split in your own process where they are cheap, and hand the image-resampling job to something that already runs Ghostscript safely behind an isolation boundary.
Try it on RoutexAPI
Skip the setup
Would rather not run and maintain this yourself? These APIs on RoutexAPI do the same job behind one key.
- PDF ToolkitOne API for every PDF operation — organize, optimize, convert, secure, edit.@rangoboomapp
- DocGenCreate PDFs and images from HTML, Markdown, or reusable templates, merge and protect PDF files, fill PDF forms, add watermarks, and manage document templates through a single high-performance API.@rangoboomapp
