Map IRS 990 Part VII to JSON Schema

Deterministically map IRS Form 990 Part VII compensation records to a type-safe JSON schema: streamed ingestion, Pydantic v2 coercion, audit metadata, and idempotent output.

This guide is part of the IRS 990 Data Schema Mapping cluster within the Core Architecture & Compliance Mapping reference. The problem it solves is narrow and concrete: turning IRS Form 990 Part VII — the compensation schedule — into a canonical, type-safe JSON payload that downstream grant-automation stages can consume without re-parsing the original filing.

Part VII is awkward to map because it bundles three structurally distinct subsections under one heading: Section A (Officers, Directors, Trustees, and Key Employees), Section B (Five Highest Compensated Independent Contractors), and the related governance rows that share Section A’s column layout. Each carries unbounded array cardinality, monetary fields with no currency tag, and optional flags that older e-file XML omits entirely. A reliable mapping enforces streamed ingestion, explicit decimal coercion, and an immutable audit trail at every stage.

Part VII mapping pipeline: stream, validate, stamp lineage, emit deterministic JSON Part VII records stream in bounded chunks, are validated and Decimal-coerced by a Pydantic v2 model, stamped with immutable audit lineage, then serialized to sorted-key JSON. Records that fail validation are routed out to a quarantine file, and trace_id, schema_version and compliance_tags form an audit rail beneath all four stages. 1 · Streamed Ingestion 2 · Schema Validation 3 · Compliance Mapping 4 · Deterministic Output lxml.iterparse tag=PartVII · chunks elem.clear() · flat heap PartVIIPerson model extra = forbid Decimal coercion · 2dp ComplianceAuditLog UUID4 audit_id tags + schema_version orjson · OPT_SORT_KEYS access-boundary check byte-identical output quarantine.jsonl failed records routed out ValidationError Immutable audit rail — carried unchanged through all four stages trace_id schema_version compliance_tags

When to Use This Approach

Reach for this streamed, four-stage mapping when any of the following holds:

  • Input format is IRS e-file XML or a bulk MeF export. The lxml.iterparse ingestion below assumes a <PartVII> element tree. If your input is a flat CSV already normalized upstream, skip Stage 1 and feed records straight into the Stage 2 validator.
  • Filings carry large contractor arrays. Section B and large Section A rosters routinely push 500+ records per organization. Loading the whole DOM spikes heap usage; chunked streaming keeps the memory footprint flat.
  • Downstream consumers require reconciliation-grade money. Compensation reported under Part VII, Section A, columns (D), (E), and (F) must reconcile against grant budgets. Float conversion introduces IEEE 754 drift, so monetary fields are coerced to decimal.Decimal with explicit two-place quantization, per Python’s decimal arithmetic standard.
  • You owe an audit trail. Every emitted payload must carry immutable lineage that conforms to Compliance Metadata Standards so that compensation figures can be traced back to a specific filing and validation run.

If you only need the dollar amounts for a one-off report and have no audit obligation, a single Pydantic model without the staged isolation is sufficient. The full pipeline earns its complexity when reproducibility and lineage matter.

Step-by-Step Implementation

The pipeline is split into four stages with strict boundaries: each stage consumes only the explicit output of its predecessor, and no cross-stage state is shared. This isolation is what makes the mapping deterministic and horizontally scalable during peak filing windows.

Step 1 — Stream Part VII records with bounded memory

Ingestion isolates raw parsing from everything downstream. chunk_size caps how many <PartVII> elements buffer before they are serialized and the buffer is cleared, and the structured logger emits one audit line per chunk so throughput is observable.

python
import logging
from typing import Generator
from lxml import etree

# Structured audit logger for ingestion telemetry.
# Python's stdlib has no JSONFormatter; use a plain Formatter with JSON-shaped output.
AUDIT_LOGGER = logging.getLogger("pipeline.ingestion")
AUDIT_LOGGER.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(
    logging.Formatter(
        fmt='{"time":"%(asctime)s","level":"%(levelname)s","msg":"%(message)s"}',
        datefmt="%Y-%m-%dT%H:%M:%SZ",
    )
)
AUDIT_LOGGER.addHandler(_handler)


def stream_part_vii_chunks(
    xml_path: str,
    chunk_size: int = 50,
) -> Generator[list[dict], None, None]:
    """Stream IRS 990 Part VII records via lxml.iterparse with chunk boundaries.

    Yields lists of plain dicts; never retains DOM references between chunks,
    which keeps the heap footprint independent of filing size.
    """
    try:
        context = etree.iterparse(xml_path, events=("end",), tag="PartVII")
        buffer: list = []
        for _, elem in context:
            buffer.append(elem)
            if len(buffer) >= chunk_size:
                yield _serialize_chunk(buffer)
                AUDIT_LOGGER.info("chunk_yielded | records=%d", len(buffer))
                buffer.clear()
                elem.clear()  # release parsed element back to the allocator
        if buffer:
            yield _serialize_chunk(buffer)
            AUDIT_LOGGER.info("final_chunk_yielded | records=%d", len(buffer))
    except etree.XMLSyntaxError as exc:
        AUDIT_LOGGER.error("xml_parse_failure | error=%s", exc)
        raise RuntimeError(f"Ingestion aborted: malformed XML at {xml_path}") from exc


def _serialize_chunk(elements: list) -> list[dict]:
    """Convert lxml elements to native dicts without retaining DOM references."""
    return [
        {child.tag: child.text for child in elem if child.text}
        for elem in elements
    ]

Parameters that matter: chunk_size trades memory for log granularity — 50 is safe for nonprofit-scale filings on a 512 MB worker. The elem.clear() call is what actually frees parsed nodes; omit it and iterparse still accumulates the whole tree. Document-level extraction mechanics (OCR confidence, coordinate handling) are out of scope here and belong to the Field Mapping & Normalization ingestion stage.

Step 2 — Validate and coerce types with Pydantic v2

Validation is purely functional: no network calls, no state mutation. The model sets extra="forbid" so any unexpected field is a hard error rather than silent drift, and aliases map IRS e-file element names (for example ReportableCompensationFromOrgAmt, Part VII Section A column (D)) onto canonical snake_case fields.

python
from decimal import Decimal, InvalidOperation
from typing import Optional
from pydantic import BaseModel, Field, ValidationError, field_validator


class PartVIIPerson(BaseModel):
    """Strictly typed model for one Part VII compensation record."""

    model_config = {"extra": "forbid"}

    name: str = Field(..., min_length=1, max_length=150)
    position_title: str = Field(..., min_length=1, max_length=100)
    ein: Optional[str] = Field(None, pattern=r"^\d{2}-\d{7}$")
    reportable_comp_filing_org: Optional[Decimal] = Field(
        None, alias="ReportableCompensationFromOrgAmt"          # Sec A, col (D)
    )
    reportable_comp_related_org: Optional[Decimal] = Field(
        None, alias="ReportableCompensationFromRltdOrgAmt"      # Sec A, col (E)
    )
    other_compensation: Optional[Decimal] = Field(
        None, alias="OtherCompensationAmt"                      # Sec A, col (F)
    )

    @field_validator(
        "reportable_comp_filing_org",
        "reportable_comp_related_org",
        "other_compensation",
        mode="before",
    )
    @classmethod
    def coerce_to_decimal(cls, value: Optional[object]) -> Optional[Decimal]:
        """Coerce monetary strings to 2-place Decimal; never touch float."""
        if value is None:
            return None
        try:
            return Decimal(str(value)).quantize(Decimal("0.01"))
        except InvalidOperation as exc:
            raise ValueError(f"Invalid monetary value: {value!r}") from exc


def validate_batch(
    raw_records: list[dict],
) -> tuple[list[PartVIIPerson], list[dict]]:
    """Validate a chunk; return (valid_models, quarantined_exceptions)."""
    valid: list[PartVIIPerson] = []
    quarantined: list[dict] = []
    for idx, record in enumerate(raw_records):
        try:
            valid.append(PartVIIPerson.model_validate(record))
        except ValidationError as exc:
            quarantined.append({
                "record_index": idx,
                "error_type": "SchemaValidationError",
                "field_errors": exc.errors(),
                "raw_payload": record,
            })
    return valid, quarantined

Parameters that matter: mode="before" runs the coercer on the raw string before Pydantic attempts its own cast, which is the only reliable way to keep money out of float. Failed records are not raised — they are returned in quarantined so the batch survives a few malformed rows. The canonical alias contract here is governed by the parent IRS 990 Data Schema Mapping layer.

Step 3 — Inject immutable compliance metadata

Compliance mapping reads only validated output. It aggregates compensation and stamps each batch with UUID4 lineage. Jurisdictional checks are tagged, not evaluated here — registration logic belongs to State Charity Registration Compliance and funder eligibility to Grantor-Specific Rule Taxonomies.

python
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any


def generate_compliance_metadata(
    valid_records: list[PartVIIPerson],
    filing_year: int,
    organization_ein: str,
) -> dict[str, Any]:
    """Stamp a validated batch with immutable audit lineage and tags."""
    total_comp = sum(
        (r.reportable_comp_filing_org or Decimal("0.00") for r in valid_records),
        start=Decimal("0.00"),
    )
    return {
        "audit_id": str(uuid.uuid4()),
        "schema_version": "irs_990_partvii_v1.0",
        "filing_year": filing_year,
        "organization_ein": organization_ein,
        "record_count": len(valid_records),
        "aggregate_compensation": str(total_comp),
        "compliance_tags": {
            "state_charity_registration": "pending_crosswalk",
            "grantor_rule_taxonomy": "threshold_check_applied",
            "metadata_standard": "v2.1.0",
        },
        "validation_timestamp": datetime.now(timezone.utc).isoformat(),
    }

Parameters that matter: aggregate_compensation is serialized as a string to preserve Decimal precision through JSON. The audit_id is the join key that lets a downstream auditor trace any figure back to this exact run, satisfying the lineage requirement in Compliance Metadata Standards.

Step 4 — Emit deterministic, idempotent JSON output

Output is terminal: no re-validation or recalculation. Sorted keys plus fixed indentation make the byte output reproducible across runs, and a retry decorator — scoped strictly to I/O — implements bounded exponential backoff. Write access is gated by the access role defined in Data Security & Access Boundaries, and transient write failures fall through to Pipeline Fallback & Retry Logic.

python
import time
from functools import wraps
from typing import Any, Callable, TypeVar
import orjson

T = TypeVar("T")


def retry_with_backoff(max_attempts: int = 3, base_delay: float = 1.0):
    """Retry I/O with capped exponential backoff; re-raise on exhaustion."""
    def decorator(func: Callable[..., T]) -> Callable[..., T]:
        @wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> T:
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except (OSError, orjson.JSONEncodeError) as exc:
                    if attempt == max_attempts:
                        raise RuntimeError(
                            f"Retry exhausted after {max_attempts} attempts"
                        ) from exc
                    time.sleep(min(base_delay * 2 ** (attempt - 1), 10.0))
            raise RuntimeError("unreachable")
        return wrapper
    return decorator


@retry_with_backoff(max_attempts=3)
def serialize_and_dispatch(
    valid_records: list[PartVIIPerson],
    compliance_meta: dict[str, Any],
    output_path: str,
) -> str:
    """Write a deterministic, sorted-key JSON payload; return the path."""
    payload = {
        "schema_version": compliance_meta["schema_version"],
        "compliance_metadata": compliance_meta,
        "records": [r.model_dump(by_alias=True, mode="json") for r in valid_records],
    }
    serialized = orjson.dumps(
        payload, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS
    )
    with open(output_path, "wb") as fh:
        fh.write(serialized)
    AUDIT_LOGGER.info(
        "payload_written | audit_id=%s | records=%d",
        compliance_meta["audit_id"],
        len(valid_records),
    )
    return output_path

Parameters that matter: OPT_SORT_KEYS is what makes two runs over identical input produce byte-identical files — essential for hashing and diff-based audit. The retry decorator catches only OSError and JSONEncodeError; a schema bug must not be silently retried.

Verification

Confirm correctness at the boundary between every stage rather than only at the end:

  1. Determinism check. Run serialize_and_dispatch twice over the same chunk and compare SHA-256 digests — they must match. If they differ, an unsorted dict or a float leaked into the payload.

  2. Decimal fidelity. Assert that no serialized monetary value contains floating-point noise:

    python
    import hashlib
    
    first = serialize_and_dispatch(models, meta, "/tmp/run_a.json")
    second = serialize_and_dispatch(models, meta, "/tmp/run_b.json")
    assert hashlib.sha256(open(first, "rb").read()).hexdigest() \
        == hashlib.sha256(open(second, "rb").read()).hexdigest()
    assert all("." in str(m.reportable_comp_filing_org or "0.00") for m in models)
    
  3. Audit-log assertion. Each successful dispatch emits exactly one payload_written line carrying the audit_id. Grep the log stream and assert the count equals the number of chunks dispatched.

  4. Quarantine accounting. len(valid) + len(quarantined) must equal the raw record count for every chunk; a mismatch means a record was dropped rather than routed.

Common Errors & Fixes

Error Cause Fix
ValidationError: extra fields not permitted A Part VII element (often a Section B contractor column) has no alias in PartVIIPerson Add the field with its IRS e-file alias, or pre-filter unmapped tags in _serialize_chunk — never relax extra="forbid"
Monetary value off by a fraction of a cent A float was cast before reaching coerce_to_decimal Keep mode="before" and pass the raw string; never call float() upstream of the validator
MemoryError / OOM kill on large filings elem.clear() missing, so iterparse retains the whole tree Clear each element after buffering and cap chunk_size to fit the worker’s memory ceiling
Two runs produce different output bytes OPT_SORT_KEYS omitted or a Python set serialized Always serialize with sorted keys and use ordered types only
Retry storm on a malformed payload Schema error wrongly caught by the retry decorator Scope the except to OSError/JSONEncodeError only, so logic errors fail fast

Frequently Asked Questions

Does this handle Part VII Section B independent contractors?

Yes — Section B rows share the column layout, but their alias set differs (no related-org compensation). Add a sibling model or extend PartVIIPerson with contractor-specific aliases and a discriminator field; the four-stage flow is unchanged.

Pydantic v1 or v2?

This mapping targets Pydantic v2 (model_config, model_validate, field_validator). On v1 the validator decorator and config syntax differ; pin pydantic>=2.5 to use the code as written.

Where do jurisdictional thresholds get evaluated?

Not here. Stage 3 only tags records. Gross-revenue registration thresholds (for example the California Attorney General CT-1 trigger) are resolved downstream in State Charity Registration Compliance.