Fill PDF Checkboxes and Radio Buttons with Python
Text fields on the application form fill correctly, but every checkbox stays empty and the radio group for "Contract type" shows nothing selected — or shows two options selected at once in one viewer and none in another:
writer.update_page_form_field_values(writer.pages[0], {
"agree_terms": True,
"newsletter": "Yes",
"contract_type": "Permanent",
})
No exception is raised. Opening the output in Acrobat, the boxes are blank; in a browser viewer, one box appears ticked; reading the field back shows /agree_terms holding True, a value no PDF viewer understands.
Root Cause
PDF button fields do not store booleans or labels. A checkbox has exactly two appearance states: /Off and an export value chosen by the form author — often /Yes, but just as often /On, /1, /Checked or a localised word like /Ja. To tick a box, the field's value (/V) and the widget's appearance state (/AS) must both be set to that exact name. Radio buttons are one field with several widget "kids", each with its own export value such as /Permanent or /Choice2; selecting one means setting the parent's /V to that option's name and setting /AS on every kid — the chosen one to its export value, all others to /Off. Passing True or a display label writes a value that matches no appearance state, so viewers either draw nothing or guess inconsistently. Nothing about the export value is visible in the form itself; it has to be read from the file.
Minimal Diagnostic
List every button field with its type and the appearance states each widget supports. The non-/Off state is the value you must use.
# pip install pypdf
from pathlib import Path
from pypdf import PdfReader
from pypdf.generic import NameObject
SOURCE = Path("in/application-form.pdf")
def button_states(pdf_path: Path) -> None:
try:
reader = PdfReader(pdf_path)
except Exception as exc:
raise SystemExit(f"cannot read {pdf_path}: {exc}")
for page_no, page in enumerate(reader.pages, start=1):
for annot_ref in page.get("/Annots", []) or []:
annot = annot_ref.get_object()
if annot.get("/Subtype") != "/Widget":
continue
parent = annot.get("/Parent")
field = parent.get_object() if parent is not None and "/T" not in annot else annot
if field.get("/FT") != "/Btn":
continue
flags = int(field.get("/Ff", 0))
kind = "radio" if flags & (1 << 15) else "pushbutton" if flags & (1 << 16) else "checkbox"
states = [str(k) for k in (annot.get("/AP", {}).get("/N", {}) or {}).keys()]
print(f"p{page_no} {kind:<9} field={field.get('/T')!s:<18} states={states} "
f"V={field.get('/V')} AS={annot.get('/AS')}")
if __name__ == "__main__":
button_states(SOURCE)
p1 checkbox field=agree_terms states=['/Off', '/On'] V=None AS=/Off
p1 checkbox field=newsletter states=['/Off', '/Ja'] V=None AS=/Off
p1 radio field=contract_type states=['/Off', '/Choice1'] V=None AS=/Off
p1 radio field=contract_type states=['/Off', '/Choice2'] V=None AS=/Off
p1 radio field=contract_type states=['/Off', '/Choice3'] V=None AS=/Off
agree_terms ticks with /On, newsletter with /Ja, and the three contract options are /Choice1–/Choice3 — not "Permanent". Which choice means which label has to be matched from the page layout, as shown below.
Fix: Set the Export Value on Value and Appearance
With PyMuPDF, widgets expose on_state() — the export value — so code can tick boxes without knowing the names in advance. Changed lines carry comments.
# pip install pymupdf
from pathlib import Path
import pymupdf
SOURCE = Path("in/application-form.pdf")
DEST = Path("out/application-filled.pdf")
CHECKBOXES = {"agree_terms": True, "newsletter": False}
RADIOS = {"contract_type": 2} # 1-based position of the option, in page order
def fill_buttons(src: Path, dest: Path) -> None:
with pymupdf.open(src) as doc:
radio_kids: dict[str, list] = {}
for page in doc:
for widget in page.widgets():
name = widget.field_name
if widget.field_type == pymupdf.PDF_WIDGET_TYPE_CHECKBOX and name in CHECKBOXES:
on = widget.on_state() # changed: real export value
widget.field_value = on if CHECKBOXES[name] else "Off" # changed: state name, not True
widget.update() # changed: writes V and AS
elif widget.field_type == pymupdf.PDF_WIDGET_TYPE_RADIOBUTTON and name in RADIOS:
radio_kids.setdefault(name, []).append((page.number, widget.rect.y0, widget.rect.x0, widget.xref))
for name, kids in radio_kids.items():
kids.sort() # changed: order by page, top, left
chosen = RADIOS[name] - 1
for i, (page_no, _y, _x, xref) in enumerate(kids):
page = doc[page_no]
widget = next(w for w in page.widgets() if w.xref == xref)
widget.field_value = widget.on_state() if i == chosen else "Off" # changed: one on, rest off
widget.update()
dest.parent.mkdir(parents=True, exist_ok=True)
doc.save(dest, garbage=3, deflate=True)
if __name__ == "__main__":
try:
fill_buttons(SOURCE, DEST)
print(f"wrote {DEST}")
except Exception as exc:
raise SystemExit(f"filling failed: {exc}")
Selecting radio options by position rather than by export value makes the mapping readable ("the second option under Contract type") and survives forms whose export values are meaningless ChoiceN names. Sorting kids by page, then vertical, then horizontal position reproduces reading order for both vertical and horizontal option lists. Set every kid explicitly: leaving previously selected options untouched is how two radio buttons end up selected at once.
Variant Fix 1: The Same with pypdf
pypdf needs the export value names from the diagnostic. Pass them as NameObject-style strings starting with a slash, and let pypdf update both the field value and the widget appearance state:
# pip install "pypdf>=4.0"
from pathlib import Path
from pypdf import PdfReader, PdfWriter
SOURCE = Path("in/application-form.pdf")
DEST = Path("out/application-filled-pypdf.pdf")
def checkbox_on_state(reader: PdfReader, field_name: str) -> str:
field = reader.get_fields()[field_name]
states = [s for s in field.get("/_States_", []) if s != "/Off"]
if not states:
raise ValueError(f"{field_name} has no on-state")
return states[0]
def fill_with_pypdf(src: Path, dest: Path) -> None:
reader = PdfReader(src)
writer = PdfWriter(clone_from=reader)
values = {
"agree_terms": checkbox_on_state(reader, "agree_terms"), # '/On'
"newsletter": "/Off",
"contract_type": "/Choice2", # radio: the chosen kid's export value
}
for page in writer.pages:
writer.update_page_form_field_values(page, values, auto_regenerate=False)
writer.set_need_appearances_writer(False) # appearances already exist for buttons
dest.parent.mkdir(parents=True, exist_ok=True)
with dest.open("wb") as fh:
writer.write(fh)
pypdf exposes a checkbox's possible states in the /_States_ entry it computes for get_fields(). For radio groups, recent pypdf versions set /AS on each kid when the parent value is updated; if your version leaves stale selections, fall back to the PyMuPDF approach or set each kid's /AS explicitly. Text fields in the same form are covered in Filling PDF Forms with Python.
Variant Fix 2: Map Labels to Radio Options Automatically
When forms change, hard-coded positions or ChoiceN names drift. Match each radio kid to the nearest text label on the page so the mapping can be written in business terms:
# pip install pymupdf
import pymupdf
def radio_options_by_label(page: pymupdf.Page, field_name: str) -> dict[str, str]:
words = page.get_text("words")
options = {}
for widget in page.widgets():
if widget.field_name != field_name or widget.field_type != pymupdf.PDF_WIDGET_TYPE_RADIOBUTTON:
continue
r = widget.rect
# words on the same line, starting just to the right of the button
label_words = [w for w in words if abs((w[1] + w[3]) / 2 - (r.y0 + r.y1) / 2) < r.height
and r.x1 <= w[0] <= r.x1 + 160]
label = " ".join(w[4] for w in sorted(label_words, key=lambda w: w[0])[:3])
options[label] = widget.on_state()
return options
with pymupdf.open("in/application-form.pdf") as doc:
print(radio_options_by_label(doc[0], "contract_type"))
# {'Permanent': 'Choice1', 'Fixed-term contract': 'Choice2', 'Freelance': 'Choice3'}
The 160-point search window and three-word label cap suit typical forms with labels to the right of buttons; forms with labels above or left of each button need the window adjusted. Print the mapping once per form version and store it with the job's configuration rather than recomputing it on every run — then a layout change shows up as a changed mapping in review, not as wrong answers in filled forms.
Verification
Read the saved file back, check both the value and the appearance state, and render the widgets to confirm viewers will draw what the data says.
# pip install pymupdf
from pathlib import Path
import pymupdf
def verify_buttons(pdf_path: Path, expect_checked: dict[str, bool], expect_radio: dict[str, int]) -> None:
with pymupdf.open(pdf_path) as doc:
kids: dict[str, list] = {}
for page in doc:
for w in page.widgets():
if w.field_type == pymupdf.PDF_WIDGET_TYPE_CHECKBOX and w.field_name in expect_checked:
ticked = w.field_value not in (False, "Off", "", None)
assert ticked == expect_checked[w.field_name], f"{w.field_name}: value {w.field_value!r}"
elif w.field_type == pymupdf.PDF_WIDGET_TYPE_RADIOBUTTON and w.field_name in expect_radio:
kids.setdefault(w.field_name, []).append((page.number, w.rect.y0, w.rect.x0,
w.field_value not in (False, "Off", "", None)))
for name, entries in kids.items():
entries.sort()
selected = [i for i, (*_, on) in enumerate(entries, start=1) if on]
assert selected == [expect_radio[name]], f"{name}: selected options {selected}"
print(f"{pdf_path.name}: checkboxes and radio groups verified")
if __name__ == "__main__":
verify_buttons(Path("out/application-filled.pdf"), {"agree_terms": True, "newsletter": False},
{"contract_type": 2})
The radio assertion checks that exactly one option is on, which catches the double-selection bug as well as nothing selected. For the final check across viewers, flatten a copy with flatten PDF form fields with Python and render it to an image; a flattened tick is exactly what a recipient's viewer will show.
FAQ
Why does the box look ticked in the browser but not in Acrobat?
The browser viewer draws from the value, Acrobat from the appearance state (or vice versa). Setting only one of /V or /AS produces this split; set both.
Can a checkbox have more than one on-state? Only in unusual forms where several widgets share one field name and act like a radio group. Treat those as radio groups by position.
What value unticks a box?/Off for both the value and the appearance state. Deleting the value is not enough if /AS still says on.
Do I need NeedAppearances for buttons?
No — button appearances are predefined in the form. NeedAppearances helps text fields; for buttons, set the correct state names instead.
Related
- Filling PDF Forms with Python — text fields, dropdowns and batch filling
- List PDF Form Field Names with Python — building the field map in the first place
- Fix PDF Form Fields Not Visible After Filling — the appearance problem for text fields
- Generate One DOCX per Row from Excel — the Word-based alternative when you control the template
Part of Filling PDF Forms with Python.