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.

How a checkbox and a radio group are stored A checkbox field holds a value V and a single widget whose appearance dictionary has two states, Off and an export value such as On, with AS selecting which one is drawn. A radio group is one parent field with value V and several kid widgets, each with Off and its own export value such as Choice1, Choice2 and Choice3. Selecting an option sets the parent V to that export value, the chosen kid's AS to it, and every other kid's AS to Off. Checkbox field agree_terms V = /On when ticked value must equal an appearance state name Its widget AP /N states: /Off, /On — AS picks one what viewers actually draw Radio parent contract_type V = /Choice2 when Fixed-term chosen one value for the whole group Kid widgets 1, 2, 3 /Choice1 /Choice2 /Choice3, others AS = /Off exactly one kid shows its state

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.

Values that fail versus values that tick The left panel lists values that do not match any appearance state: Python True for a checkbox, the word Yes for a box whose export value is Ja, and the label Permanent for a radio option whose export value is Choice1. The right panel lists the matching values read from the file: slash On, slash Ja and slash Choice1, which set both the field value and the widget appearance state. Ignored by viewers agree_terms = True newsletter = 'Yes' contract = 'Permanent' no appearance state matches Rendered everywhere agree_terms = '/On' newsletter = '/Ja' contract = '/Choice1' V and AS both set

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'}
Matching radio buttons to their labels The Contract type group has three radio buttons stacked vertically. For each button the code looks for words whose vertical centre is within one button height of the button's centre and which start within 160 points to its right. The first button matches Permanent, the second Fixed-term contract and the third Freelance, producing a mapping from label to export value Choice1, Choice2 and Choice3. Contract type group, page 1 1 Choice1 label: Permanent 2 Choice2 label: Fixed-term contract 3 Choice3 label: Freelance 4 4 Search window same line, up to 160 pt right

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.

Part of Filling PDF Forms with Python.

/html>