DEV Community

Cover image for How to Mail Merge from Excel (.xlsx / CSV) into Official IRS & ACA PDFs (1095-C, W-9, Form 2848, SS-4) Without Adobe Acrobat
Maciej S.
Maciej S.

Posted on Fully Autonomous

How to Mail Merge from Excel (.xlsx / CSV) into Official IRS & ACA PDFs (1095-C, W-9, Form 2848, SS-4) Without Adobe Acrobat

Every January through March, engineering teams, payroll ops, and accounting practices hit the same wall: a spreadsheet with 50 to 500 rows of employee or entity records in .xlsx or .csv format, and an official IRS PDF template (f1095c.pdf, fw9.pdf, f2848.pdf, fss4.pdf, or f843.pdf) that needs one populated PDF generated per row.

If you open an official IRS PDF in a standard Python script and try to map column employee_ssn to a field named "employee_ssn", zero fields match. Even worse, if you guess the field name and write {"c1_1[0]": "Yes"} to a checkbox, the generated PDF opens in macOS Preview or Chrome with an unchecked box.

Below is a technical breakdown of how official IRS PDFs are structured under the hood, why their /AcroForm trees and /XFA streams break naive mail-merge scripts, and a complete Python implementation for deterministic CSV/Excel-to-PDF batch generation.


1. Why Official IRS PDFs Break Naive Mail-Merge Scripts

Official IRS forms are authored in Adobe LiveCycle Designer / AEM Forms and exported as hybrid AcroForm documents. That authoring pipeline introduces four specific pitfalls:

Pitfall A: Hierarchical Subform Paths and Positional /T Keys

Instead of semantic field names like employee_first_name, LiveCycle nests every widget inside topmostSubform[0] -> Page1[0] -> subform containers, with terminal /T keys named f1_1[0], f1_2[0], ..., f1_68[0] (for text inputs) and c1_1[0], c1_2[0] (for checkboxes).

Fortunately, the IRS embeds the human-readable instructions inside each field dictionary's /TU (Alternate Field Name / Tooltip) entry. Here is what a single field dictionary inside f1095c.pdf actually looks like:

42 0 obj
<<
  /Type /Annot
  /Subtype /Widget
  /FT /Tx
  /Parent 38 0 R
  /T (f1_1[0])
  /TU (Part I, Line 1. Employee first name and middle initial)
  /Rect [ 36.00 662.40 165.60 676.80 ]
  /MaxLen 35
  /DA (/MyriadPro-Regular 9 Tf 0 g)
>>
endobj
Enter fullscreen mode Exit fullscreen mode

When you walk the /AcroForm/Fields tree recursively, joining /Parent names with . gives the full qualified name topmostSubform[0].Page1[0].EmployeeName[0].f1_1[0], while /TU tells you what spreadsheet column actually belongs there.

Pitfall B: Hybrid /XFA Streams Overriding /AcroForm Values

Many government PDFs contain both a standard /AcroForm/Fields array and an /XFA XML packet stream inside /Catalog/AcroForm. When a viewer that supports XFA (such as Adobe Acrobat Reader) opens the file, it prioritizes the /XFA <datasets><xfa:data> XML over the /AcroForm /V entries. If your script only updates /AcroForm /V without stripping the /XFA key from /AcroForm (or updating both), Acrobat displays blank fields even though Chrome PDFium and pypdf show the filled values.

Pitfall C: Checkbox /AS Appearance States (/1 vs. /Yes)

In ISO 32000-1 §12.7.4.2, a checkbox (/FT /Btn without the radio or pushbutton bit flags) stores its visual glyph streams inside /AP << /N << ... >> >>. While custom PDFs often name the checked state /Yes, IRS forms almost universally use /1 or /2 as the checked NameObject key, and /Off for unchecked:

% Checkbox widget appearance dictionary in IRS Form 2848 / Form 1095-C
/AP <<
  /N <<
    /1 89 0 R
    /Off 90 0 R
  >>
>>
/V /Off
/AS /Off
Enter fullscreen mode Exit fullscreen mode

If you set /V to "Yes" or TextStringObject("1") instead of inspecting /AP/N and setting both /V and /AS to NameObject("/1"), the checkbox remains visually blank.


2. Inspecting IRS AcroForm Schemas (1095-C, 2848, SS-4, 843)

Before writing a single value from Excel or CSV, run an inspection pass that extracts every terminal widget's full path, /FT type, /TU tooltip, page index, /Rect coordinates, and valid /AP/N appearance keys.

#!/usr/bin/env python3
"""
dump_irs_schema.py
Extract full AcroForm field paths, tooltips (/TU), bounding boxes (/Rect),
and valid checkbox appearance states (/AP/N) from any IRS PDF.
"""

import json
import sys
from pypdf import PdfReader


def walk_fields(node_ref, prefix="", out=None):
    if out is None:
        out = []
    node = node_ref.get_object()
    local_name = str(node.get("/T", ""))
    full_name = f"{prefix}.{local_name}" if prefix and local_name else (local_name or prefix)

    kids = node.get("/Kids")
    if kids:
        for kid_ref in kids:
            walk_fields(kid_ref, full_name, out)
        return out

    ft = str(node.get("/FT", ""))
    tu = str(node.get("/TU", ""))
    rect = [float(x) for x in node.get("/Rect", [])]

    ap_states = []
    ap = node.get("/AP")
    if ap and "/N" in ap.get_object():
        normal_ap = ap.get_object()["/N"].get_object()
        if hasattr(normal_ap, "keys"):
            ap_states = [str(k) for k in normal_ap.keys()]

    out.append({
        "full_name": full_name,
        "short_name": local_name,
        "type": ft,
        "tooltip": tu,
        "rect": rect,
        "checked_states": [s for s in ap_states if s != "/Off"],
    })
    return out


def main(pdf_path: str) -> None:
    reader = PdfReader(pdf_path)
    acroform = reader.trailer["/Root"].get_object().get("/AcroForm")
    if not acroform:
        raise RuntimeError(f"No /AcroForm found in {pdf_path}")

    fields = []
    for ref in acroform.get_object().get("/Fields", []):
        walk_fields(ref, "", fields)

    print(json.dumps(fields, indent=2))


if __name__ == "__main__":
    main(sys.argv[1])
Enter fullscreen mode Exit fullscreen mode

Running dump_irs_schema.py against the four most common compliance and tax onboarding forms reveals how their schemas map to spreadsheet columns:

IRS Form Primary Batch Use Case Example AcroForm /T Terminal Keys Key Formatting Gotcha
Form 1095-C ACA Employer-Provided Health Insurance Offer & Coverage f1_1[0]..f1_13[0] (Part I Employee/ALE info), f1_14[0]..f1_26[0] (Line 14 Offer codes 1A–1U), f1_27[0]..f1_39[0] (Line 15 dollar amounts), f1_40[0]..f1_52[0] (Line 16 Safe Harbor 2A–2I) Line 15 requires cents (142.50, not $142.50). If "All 12 Months" (f1_14[0]) is populated, Jan–Dec (f1_15[0]..f1_26[0]) must remain blank.
Form 2848 Power of Attorney and Declaration of Representative f1_01[0] (Taxpayer identification block), f1_02[0] (TIN), f1_04[0]..f1_12[0] (Representative CAF No., PTIN, tel), f1_22[0]..f1_30[0] (Line 3 Income/Employment tax matters & years) Line 3 columns are split strictly into Description of Matter (Income, Employment, Payroll), Tax Form Number (941, 1120), and Year(s) or Period(s).
Form SS-4 Application for Employer Identification Number (EIN) f1_1[0] (Legal name of entity), f1_2[0] (Trade name), f1_7[0] (Responsible party name), f1_8[0] (SSN/ITIN/EIN), c1_1[0]..c1_15[0] (Line 9a Entity Type checkboxes) Line 9a checkboxes use /1 as their checked appearance state; LLC member count (f1_17[0]) and state (f1_18[0]) are separate narrow boxes.
Form 843 Claim for Refund and Request for Abatement f1_1[0] (Name), f1_2[0] (SSN/EIN), f1_7[0]..f1_8[0] (Tax period dates), f1_9[0] (Amount to be refunded/abated), c1_1[0]..c1_6[0] (Line 3 type of tax/fee), f1_14[0] (Line 7 Reasonable Cause explanation) Line 7 (f1_14[0]) has /Ff 4096 (Multiline) enabled, but overflow text beyond the box height shrinks or clips unless line breaks are normalized.

If you want to inspect or fill individual forms in the browser before automating a batch, you can open the batch-fill IRS Form 1095-C for ACA reporting workspace, prepare a fillable IRS Form 2848 Power of Attorney for client authorizations, complete an online Form SS-4 EIN application for new LLC formations, or draft an IRS Form 843 penalty abatement request directly online.


3. Structuring the CSV / Excel Input (aca_1095c_batch.csv)

Never map spreadsheet columns by positional index (col[0], col[1]). A single reordered column in Excel will silently write an employee's SSN into the wrong Part I box. Instead, define a strict column-to-AcroForm mapping file (1095c_mapping.json) and validate every header before filling row 1.

Here is a sample aca_1095c_batch.csv for two employees (all values synthetic):

employee_id,emp_first_name,emp_middle_initial,emp_last_name,emp_ssn,ale_name,ale_ein,ale_contact_phone,plan_start_month,line14_all12,line15_all12,line16_all12,part3_covered_ind
EMP_001,Maya,J,Lin,900-00-0001,Northwind Logistics LLC,00-0000001,555-010-0192,01,1E,138.50,2G,false
EMP_002,Carlos,M,Rivera,900-00-0002,Northwind Logistics LLC,00-0000001,555-010-0192,01,1A,0.00,2C,true
Enter fullscreen mode Exit fullscreen mode

And the corresponding deterministic mapping configuration (1095c_mapping.json):

{
  "output_pattern": "1095C_{employee_id}_{emp_last_name}.pdf",
  "strip_xfa": true,
  "fields": {
    "emp_first_name": "f1_1[0]",
    "emp_middle_initial": "f1_2[0]",
    "emp_last_name": "f1_3[0]",
    "emp_ssn": "f1_4[0]",
    "ale_name": "f1_9[0]",
    "ale_ein": "f1_10[0]",
    "ale_contact_phone": "f1_12[0]",
    "plan_start_month": "f1_13[0]",
    "line14_all12": "f1_14[0]",
    "line15_all12": "f1_27[0]",
    "line16_all12": "f1_40[0]",
    "part3_covered_ind": "c1_1[0]"
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Deterministic Python Batch Mail-Merge Script (csv / .xlsx → IRS PDFs)

The script below loads the IRS PDF template once per row, strips any conflicting /XFA entry so /AcroForm values are authoritative across all PDF viewers, sets /NeedAppearances to true, and writes both text fields (/Tx) and checkboxes (/Btn with /AP/N state inspection):

#!/usr/bin/env python3
"""
batch_fill_irs_pdf.py
Deterministic CSV/Excel mail merge into official IRS AcroForm PDFs.
Requires: pip install pypdf
"""

import csv
import json
import sys
from pathlib import Path
from pypdf import PdfReader, PdfWriter
from pypdf.generic import BooleanObject, NameObject, TextStringObject


def index_terminal_widgets(acroform_obj) -> dict:
    """Map terminal /T names (e.g., 'f1_1[0]') and full paths to widget dicts."""
    by_name = {}

    def walk(ref, prefix=""):
        node = ref.get_object()
        t_val = str(node.get("/T", ""))
        full = f"{prefix}.{t_val}" if prefix and t_val else (t_val or prefix)
        kids = node.get("/Kids")
        if kids:
            for kid in kids:
                walk(kid, full)
            return
        if t_val:
            by_name[t_val] = node
        if full:
            by_name[full] = node

    for root_ref in acroform_obj.get("/Fields", []):
        walk(root_ref)
    return by_name


def detect_checked_name(widget) -> NameObject:
    """Inspect /AP/N to find the exact checked state name (e.g., /1 or /Yes)."""
    ap = widget.get("/AP")
    if ap and "/N" in ap.get_object():
        normal = ap.get_object()["/N"].get_object()
        if hasattr(normal, "keys"):
            for key in normal.keys():
                if str(key) != "/Off":
                    return NameObject(str(key))
    return NameObject("/1")


def set_widget_value(widget, raw_value: str) -> None:
    ft = str(widget.get("/FT", ""))
    if not ft and "/Parent" in widget:
        ft = str(widget["/Parent"].get_object().get("/FT", ""))

    if ft == "/Btn":
        truthy = str(raw_value).strip().lower() in {"1", "true", "yes", "x", "checked"}
        state = detect_checked_name(widget) if truthy else NameObject("/Off")
        widget[NameObject("/V")] = state
        widget[NameObject("/AS")] = state
    else:
        text = "" if raw_value is None else str(raw_value).strip()
        widget[NameObject("/V")] = TextStringObject(text)
        if NameObject("/AP") in widget:
            # Force viewer to regenerate glyph stream from /V + /DA
            del widget[NameObject("/AP")]


def fill_single_row(template_pdf: str, mapping: dict, row: dict, out_dir: Path) -> Path:
    reader = PdfReader(template_pdf)
    writer = PdfWriter()
    writer.append(reader)

    root = writer._root_object
    acroform = root[NameObject("/AcroForm")].get_object()

    if mapping.get("strip_xfa", True) and NameObject("/XFA") in acroform:
        del acroform[NameObject("/XFA")]

    acroform[NameObject("/NeedAppearances")] = BooleanObject(True)
    widgets = index_terminal_widgets(acroform)

    for col_name, target_field in mapping["fields"].items():
        if col_name not in row:
            raise KeyError(f"Missing required CSV column: {col_name}")
        if target_field not in widgets:
            raise KeyError(f"PDF field '{target_field}' not found in {template_pdf}")
        set_widget_value(widgets[target_field], row[col_name])

    filename = mapping["output_pattern"].format(**row)
    out_path = out_dir / filename
    with open(out_path, "wb") as f:
        writer.write(f)
    return out_path


def main(template_pdf: str, mapping_json: str, csv_path: str, out_dir_str: str) -> None:
    mapping = json.loads(Path(mapping_json).read_text(encoding="utf-8"))
    out_dir = Path(out_dir_str)
    out_dir.mkdir(parents=True, exist_ok=True)

    with open(csv_path, newline="", encoding="utf-8-sig") as f:
        reader = csv.DictReader(f)
        for idx, row in enumerate(reader, start=1):
            out_path = fill_single_row(template_pdf, mapping, row, out_dir)
            print(f"[{idx}] Generated {out_path}")


if __name__ == "__main__":
    if len(sys.argv) != 5:
        print("Usage: python batch_fill_irs_pdf.py template.pdf mapping.json data.csv ./output")
        sys.exit(1)
    main(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])
Enter fullscreen mode Exit fullscreen mode

Why del widget[NameObject("/AP")] Matters for Text Inputs

When an IRS PDF is saved with pre-existing blank appearance streams (/AP << /N ... >>) on text fields, updating /V alone can leave PDF.js or macOS Preview rendering the cached empty /AP/N stream instead of your new string. Deleting /AP on modified /Tx widgets while setting /NeedAppearances true on /AcroForm forces the viewer to synthesize a fresh appearance stream from /V and /DA. (For checkboxes /Btn, however, you must keep /AP intact because /AP/N holds the vector checkmark glyph!)


5. Browser-Based Mail Merge When You Don't Want to Maintain Field Maps

Maintaining f1_14[0]..f1_52[0] JSON lookup tables makes sense when you are embedding PDF generation inside a backend microservice. When an accounting, HR, or operations team simply has an .xlsx or .csv file and needs filled PDFs without writing Python or buying Adobe Acrobat Pro seats:


6. Pre-Flight Validation Checklist Before Generating 100+ Tax Forms

Before running a production batch of 1095-C, W-9, 2848, or SS-4 forms:

  1. Encoding Check (utf-8-sig): Excel's "Save as CSV (UTF-8)" prepends a 3-byte UTF-8 Byte Order Mark (\xef\xbb\xbf). Always open CSV files with encoding="utf-8-sig" in Python so the first column header is parsed as "employee_id" rather than "\ufeffemployee_id".
  2. Leading Zero Preservation: EINs (01-2345678), SSNs, and New England ZIP codes (02110) lose their leading zeros if Excel auto-formats the column as General or Number. Format TIN/ZIP columns as Text before exporting, or left-pad ZIP codes (str(zip_code).zfill(5)) in your ingestion step.
  3. 5-Row Visual Spot Check: Always inspect the first 5 generated PDFs across at least two independent renderers (e.g., Chrome PDFium and macOS Preview / Adobe Reader) to verify that long entity names do not clip on /MaxLen boundaries and that every checkbox /AS state renders visibly.

Disclosure: I am the solo developer of JustFill.

AI disclosure: AI coding agents drafted and revised this article and verified the code examples above.

Top comments (0)