Guide

Flattening a filled PDF form with pypdf, and the method that doesn't exist

pypdf fills AcroForm fields without complaint: set a value, call write(), and a compliant viewer shows the string in the field. What ships in that output PDF is still a form. Every field a script just filled stays interactive, so a person can click into it and change what was just set, and a lot of downstream systems that scan a PDF for stray AcroForm fields will flag it as unfinished. Flattening is the step that removes that: bake each field's current value into the page content, delete the field and its widget, and the document behaves like a printed page instead of a form someone forgot to lock.

What pypdf calls flatten() is a different operation

Reach for the obvious name and pypdf's PdfWriter has no public flatten() method at all. It does carry a private _flatten(), and its own docstring warns against assuming what it does:

>>> from pypdf import PdfWriter
>>> [m for m in dir(PdfWriter()) if "flat" in m.lower()]
['_flatten', '_flatten_leaf_page', '_flatten_page_tree_node', 'flattened_pages']

"Flattening a PDF also means combining all the contents into one single layer and making the file less editable" is the exact note pypdf's maintainers left to distinguish their _flatten() from that other meaning. Their version flattens the page tree. It copies inherited attributes like /Resources and /MediaBox down from parent nodes onto each page object, which speeds up page lookups and has nothing to do with form fields.

Filling still works, baking doesn't

from pypdf import PdfReader, PdfWriter

reader = PdfReader("w9.pdf")
writer = PdfWriter()
writer.append(reader)

writer.update_page_form_field_values(
    writer.pages[0],
    {"full_name": "Jordan Rivera"},
    auto_regenerate=False,
)

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

That output has the right text sitting in the right box in every viewer tested. It also still carries an /AcroForm dictionary and an /Annots entry pointing at a live text field widget, both readable straight back out through PdfReader(...).get_fields(). pypdf has no further method to call that removes them.

pdftk's flatten does the job, when you can run it

pdftk has shipped a flatten operation since its earliest releases, and running it against the file above closes that gap:

pdftk filled.pdf output flattened.pdf flatten

Run that and PdfReader("flattened.pdf").get_fields() comes back empty, /Annots comes back None, and the name renders as plain page content in every viewer tested. For a script running on a machine where pdftk is already installed, that one line is the rest of the job pypdf leaves undone.

The part that breaks on Lambda, Vercel, or a Worker

pdftk is a Java program wrapped in a shell script, so using it means shipping a JRE alongside the code, not a small binary vendored into a zip. AWS Lambda carries no system Java by default and needs a custom layer with both the JRE and the pdftk jar; Vercel's serverless functions run into the same shape of problem, and Cloudflare Workers can't run it at all, because a V8 isolate has no subprocess to shell out to in the first place. The same constraint that forced the wkhtmltopdf-on-Lambda migration applies here: a tool that works fine on a laptop can be unusable on the runtime a PDF actually needs to run on.

One call instead

PDFops' /api/fill-form takes a flatten parameter directly, so the fill-and-bake step that needs pdftk locally becomes one HTTP call instead, with no JRE to package and no subprocess to shell out to:

curl -X POST https://pdfops.dev/api/fill-form \
  -H "X-API-Key: $PDFOPS_KEY" \
  -F "pdfFile=@w9.pdf" \
  -F 'fields={"full_name":"Jordan Rivera"}' \
  -F "flatten=true" \
  -o filled.pdf

The response is the flattened PDF itself, with no AcroForm or Annots left to query, built on a Helvetica appearance pass that runs the same way whether flatten is set or not. Flattening here is cosmetic: it stops casual re-editing in a viewer, not tamper evidence or a digital signature, so pair it with an actual signing step if a filled PDF needs to prove it wasn't altered afterward.

Try it

A free key from /docs/signup includes 250 fills a month with no card, enough to try flatten=true against a real template before wiring it into anything bigger.