Guide
Filling a checkbox with pypdf, and the on-state string it never validates
pypdf's update_page_form_field_values() will set a
checkbox's value without checking whether the value you passed
means anything. Pass the wrong on-state string, one that
doesn't exist anywhere in that field's appearance dictionary,
and the call returns normally, the field looks filled if you
read it back with get_fields(), and the box still
renders unchecked in every viewer. No exception, no log line,
not even at DEBUG level. Four common inputs trigger it:
"Yes" without the leading slash, "true",
"1", and the Python boolean True.
Only the exact string the field's own appearance dictionary
defines actually checks the box.
What actually decides whether a box looks checked
An AcroForm checkbox carries two things that have to agree: the
field's value (/V) and the widget's appearance
state (/AS). A viewer renders whichever appearance
stream /AS points to, out of the ones listed in
the widget's /AP /N dictionary. The PDF spec never
mandates a name for the checked state. /Yes is the
common convention because Acrobat's own form wizard writes it,
but a PDF built in a different tool can use /On,
/1, or a name its author chose. There is no
universal on value for a checkbox, only whatever key sits in
that specific field's own appearance dictionary next to
/Off.
Reproducing the silent no-op
Take a one-field test PDF whose checkbox agree_to_terms
really uses /Yes as its on-state, confirmed by
reading _States_ off the field:
['/Off', '/Yes']. Running
update_page_form_field_values() with
auto_regenerate=False for the values
"Yes", "true", "1", and
True leaves /V and /AS
at /Off every time, on pypdf 6.18.0. Only
{"agree_to_terms": "/Yes"}, matching the field's
real on-state string exactly, sets both to /Yes.
The function returns None, so there is nothing in
the call to check for success, and nothing gets logged either
way.
from pypdf import PdfReader, PdfWriter
reader = PdfReader("consent-form.pdf")
writer = PdfWriter()
writer.append(reader)
# Every one of these looks reasonable. None of them checks the box.
for value in ["Yes", "true", "1", True]:
writer.update_page_form_field_values(
writer.pages[0], {"agree_to_terms": value}, auto_regenerate=False,
)
print(value, "->", writer.get_fields()["agree_to_terms"].get("/V"))
# /Off, every single time. No exception, no warning.
# Only the field's own on-state string checks it.
writer.update_page_form_field_values(
writer.pages[0], {"agree_to_terms": "/Yes"}, auto_regenerate=False,
)
print(writer.get_fields()["agree_to_terms"].get("/V")) # /Yes
Reading the real on-state before you fill
The fix is to stop guessing at the on-state name and read it
off the field instead. reader.get_fields()
exposes it as _States_, a list where one entry is
always /Off and the other is the string that
actually checks the box; the widget annotation's own
/AP /N keys carry the same string for anyone
working at that lower level. Either way, the value passed to
update_page_form_field_values has to come from the
PDF itself, never from a hardcoded /Yes or a
stringified boolean, because the on-state name belongs to that
specific template, not to the file format in general.
reader = PdfReader("consent-form.pdf")
fields = reader.get_fields()
on_state = next(
s for s in fields["agree_to_terms"]["/_States_"] if s != "/Off"
)
writer = PdfWriter()
writer.append(reader)
writer.update_page_form_field_values(
writer.pages[0], {"agree_to_terms": on_state}, auto_regenerate=False,
)
The default that trades one bug for a viewer-dependent one
update_page_form_field_values defaults
auto_regenerate to True, and that
default sets NeedAppearances on the whole
document's AcroForm dictionary rather than baking the
checkbox's new appearance into the file at write time.
NeedAppearances tells a conforming viewer to
regenerate every field's appearance itself when the document
opens, so rendering moves from decided once, at fill time, to
decided per viewer, on every open. Acrobat honors the flag. A
number of other renderers, including some used in headless
pipelines, do not regenerate anything and just show whatever
appearance stream was already baked in, which for a field
filled this way can mean no visible change at all.
What that breaks beyond checkboxes
That reaches past checkboxes for anyone
testing generated
PDFs by hashing them. A document with
NeedAppearances set can render two different ways
depending on which engine opens it, and that undercuts the
premise behind hashing a PDF and diffing it in CI: the bytes
match, but a screenshot or a downstream re-render is not
guaranteed to. auto_regenerate=False avoids
planting that flag, at the cost of handling any
appearance-stream work pypdf does not bake in on its own.
Try it
None of this is specific to a checkbox filled by hand. It is
why /api/fill-form
takes a plain "true" or "false"
string for any checkbox field, resolves the real on-state name
internally, and bakes a fresh appearance stream into the
response PDF instead of setting NeedAppearances
and leaving the outcome to whichever viewer opens the file
next. A free key from /docs/signup
covers 250 calls a month, and
/docs/inspect reads back a
template's real field names and types first, checkboxes
included, so there is no need to go spelunging through
_States_ by hand at all.