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.