Independent guide. Not affiliated with the pdfplumber project or its maintainers.Official repository: jsvine/pdfplumber ↗

Reference

Form fields

A filled-in PDF form keeps its values in form fields, not in the page text, so extract_text() does not see them. pdfplumber can still reach them. We tested the official README recipe, a label-matching version, and the case most guides skip: forms that were flattened before they reached you.

Tested with pdfplumber 0.11.10 · Python 3.13Updated 2026-11-04Source of truth: official README ↗

Short answer

The recipe in pdfplumber’s README, run on our form sample without changes:

form_values.py
# The recipe from pdfplumber's README ("Extracting form values"), unchanged except the file name
import pdfplumber
from pdfplumber.utils.pdfinternals import resolve_and_decode, resolve

pdf = pdfplumber.open("samples/form.pdf")

def parse_field_helper(form_data, field, prefix=None):
    """ appends any PDF AcroForm field/value pairs in `field` to provided `form_data` list

        if `field` has child fields, those will be parsed recursively.
    """
    resolved_field = field.resolve()
    field_name = '.'.join(filter(lambda x: x, [prefix, resolve_and_decode(resolved_field.get("T"))]))
    if "Kids" in resolved_field:
        for kid_field in resolved_field["Kids"]:
            parse_field_helper(form_data, kid_field, prefix=field_name)
    if "T" in resolved_field or "TU" in resolved_field:
        # "T" is a field-name, but it's sometimes absent.
        # "TU" is the "alternate field name" and is often more human-readable
        # your PDF may have one, the other, or both.
        alternate_field_name  = resolve_and_decode(resolved_field.get("TU")) if resolved_field.get("TU") else None
        field_value = resolve_and_decode(resolved_field["V"]) if 'V' in resolved_field else None
        form_data.append([field_name, alternate_field_name, field_value])

form_data = []
fields = resolve(resolve(pdf.doc.catalog["AcroForm"])["Fields"])
for field in fields:
    parse_field_helper(form_data, field)

for row in form_data:
    print(row)
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
['full_name', None, 'Mira Okafor']
['department', None, 'Field services']
['email', None, 'm.okafor@hollowpine.example']
['needed_by', None, '2026-12-01']
['item_laptop', None, 'Yes']
['item_monitor', None, 'Off']
['item_dock', None, 'Yes']
['priority', None, 'Normal']
['approved', None, 'yes']

Every value comes back: text fields as text, checkboxes as Yes or Off, the choice field as the selected option, and the radio group as its chosen value.

Values are not in the page text

not_in_text.py
import pdfplumber

with pdfplumber.open("samples/form.pdf") as pdf:
    page = pdf.pages[0]
    print("in extract_text():", "Mira Okafor" in page.extract_text())
    widgets = [a for a in page.annots if a["data"].get("Subtype").name == "Widget"]
    print("form widgets on the page:", len(widgets), "- first:", widgets[0]["data"]["T"], widgets[0]["data"]["V"])
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
in extract_text(): False
form widgets on the page: 10 - first: b'full_name' b'Mira Okafor'

The text you type into a form field is stored in the field, together with an appearance: a small drawing of how the field should look. The page’s own content contains only the labels and boxes. That is why extract_text() returns the labels but none of the answers, and why the same is true of words, search() and table extraction.

Rendering behaves the same way. to_image() draws page content without form fields, so a filled-in form looks empty in pdfplumber’s renders, while the flattened copy shows its values:

Two renders side by side: the live form with empty fields and no checkboxes, and the flattened form showing the typed values, ticked boxes and the selected radio button
The same form rendered by pdfplumber before and after flattening. Generated by lab/make_figures.py.

Match fields to their labels

Field names are chosen by whoever built the form, and are often useless (Text1, Check Box7). The labels people read are printed on the page next to each field. page.annots gives every widget’s position, so the label can be read from the page beside it:

fields_by_label.py
import pdfplumber
from pdfplumber.utils.pdfinternals import resolve, resolve_and_decode

def field_value(data):
    """Text for text and choice fields; True/False for checkboxes and for each radio option."""
    kind = data.get("FT") or resolve(data["Parent"]).get("FT")
    value = data.get("V", resolve(data["Parent"]).get("V") if "Parent" in data else None)
    value = resolve_and_decode(value) if value is not None else None
    if kind.name == "Btn" and "Parent" not in data:          # checkbox
        return value not in (None, "Off")
    if kind.name == "Btn":                                    # one option of a radio group
        return data["AS"].name != "Off"
    return value

with pdfplumber.open("samples/form.pdf") as pdf:
    page = pdf.pages[0]
    form = {}
    for annot in page.annots:
        data = annot["data"]
        if data.get("Subtype").name != "Widget":
            continue
        # The printed label: words on the same line, left of the field (or right, for tick boxes)
        left = page.crop((40, annot["top"], annot["x0"] - 1, annot["bottom"])).extract_text()
        right = page.crop((annot["x1"] + 1, annot["top"], annot["x1"] + 110, annot["bottom"])).extract_words()
        label = left if left and not right else right[0]["text"] if right else left
        name = resolve_and_decode(data.get("T") or resolve(data["Parent"]).get("T"))
        form.setdefault(name, []).append((label, field_value(data)))

for name, entries in form.items():
    print(f"{name:12} {entries}")
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
full_name    [('Full name', 'Mira Okafor')]
department   [('Department', 'Field services')]
email        [('Email', 'm.okafor@hollowpine.example')]
needed_by    [('Needed by', '2026-12-01')]
item_laptop  [('Laptop', True)]
item_monitor [('Monitor', False)]
item_dock    [('Docking', True)]
priority     [('Priority', 'Normal')]
approved     [('Yes', True), ('No', False)]

The function also turns values into types: True/False for checkboxes, and for radio buttons, which of the group’s options is selected (each option is its own widget, with the group’s name on its parent). Labels sit to the left of text fields and to the right of tick boxes on this form; adjust the two crops to your layout.

Flattened forms

Forms often arrive flattened: printed to PDF, or processed by a workflow that “locks” them. The fields are gone and the values have become ordinary page content. Our flattened copy was made from the same form with pikepdf. Text values are now normal text, so reading them works like any label–value layout. Checkboxes and radio buttons have become drawings, so their state has to be read from the graphics:

flattened_form.py
import pdfplumber

def right_of(page, label):
    hit = page.search(label, regex=False)[0]
    return page.crop((hit["x1"] + 1, hit["top"] - 2, page.width, hit["bottom"] + 2)).extract_text()

def ticked(page, box):
    """A box counts as ticked if something smaller is drawn inside it."""
    x0, top, x1, bottom = box
    inside = [o for o in page.curves + page.rects + page.chars
              if o["x0"] >= x0 and o["x1"] <= x1 and o["top"] >= top and o["bottom"] <= bottom
              and o["width"] < (x1 - x0) - 1]
    return bool(inside)

with pdfplumber.open("samples/form-flattened.pdf") as pdf:
    page = pdf.pages[0]
    print("fields left:", len(page.annots))
    print({label: right_of(page, label) for label in ["Full name", "Department", "Needed by", "Priority"]})

    # Small squares and circles are tick boxes and radio buttons; the word to the right is the label
    boxes = {(round(o["x0"]), round(o["top"]), round(o["x1"]), round(o["bottom"]))
             for o in page.rects + page.curves if 6 < o["width"] < 15 and abs(o["width"] - o["height"]) < 1}
    inside_another = {b for b in boxes for o in boxes
                      if o != b and o[0] <= b[0] and o[1] <= b[1] and b[2] <= o[2] and b[3] <= o[3]}
    for box in sorted(boxes - inside_another, key=lambda b: (b[1], b[0])):   # the tick marks are squares too
        label = page.crop((box[2] + 1, box[1] - 2, box[2] + 120, box[3] + 2)).extract_words()[0]["text"]
        print(f"{label:8} {'[x]' if ticked(page, box) else '[ ]'}")
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
fields left: 0
{'Full name': 'Mira Okafor', 'Department': 'Field services', 'Needed by': '2026-12-01', 'Priority': 'Normal'}
Laptop   [x]
Monitor  [ ]
Docking  [x]
Yes      [x]
No       [ ]

The rule is simple: a box is ticked when something smaller is drawn inside it, such as a tick, a cross or a dot. The tick marks are small squares themselves, which is why boxes inside other boxes are dropped first. The result matches the live form exactly: Laptop and Docking station ticked, Monitor not, Approved: Yes.

Signature fields, dates and XFA

  • Signature fields are widgets too. pdfplumber reports their position, but not whether a signature is valid; use a PDF signing library for that.
  • Dates are stored as typed, as text; parse them yourself.
  • XFA forms (dynamic forms from Adobe LiveCycle) keep their data in XML inside the PDF, not in AcroForm fields, and many have no page content at all until a viewer renders them. pdfplumber cannot read them; pdf.doc.catalog["AcroForm"] may contain an XFA entry, which is how to recognise them.