Pydantic v1 vs v2 Validation Contracts for IRS 990 Ingestion

Decision guide for choosing or migrating a Pydantic version for IRS 990 validation contracts: @validator vs @field_validator, model_config, model_dump, Decimal money strictness, Rust-core batch speed, and 2 CFR §200.302 alignment.

This guide is part of the IRS 990 Data Schema Mapping section within the broader Core Architecture & Compliance Mapping framework, and it answers one narrow question: when you build the validation contract that guards a batch of parsed IRS Form 990 records, do you write it against Pydantic v1 or Pydantic v2 — and if you already have a v1 contract, is a migration worth it?

The short answer is default to v2 for any new 990 ingestion contract, and migrate an existing v1 contract on a scheduled boundary rather than in place. The rest of this guide is the decision framework behind that recommendation: what actually changes in the validator syntax, the config surface, the serialization API, and — the part that matters most for regulated financial data — how each version coerces money and enum fields on 990 line items. The two versions are not drop-in compatible, so choosing wrong means either a rewrite later or a silent coercion bug on a dollar figure that an auditor will eventually find.

Two-column comparison of Pydantic v1 and v2 across five contract dimensions with a v2 recommendation Two vertical columns compare Pydantic v1 (left) and Pydantic v2 (right) for IRS 990 validation contracts across five dimensions: validator syntax uses the decorator at-validator in v1 versus at-field-validator and at-model-validator in v2; configuration uses a nested Config class with orm_mode in v1 versus a model_config dict with from_attributes in v2; serialization uses dot-dict and dot-json in v1 versus dot-model-dump and dot-model-dump-json in v2; money and enum strictness relies on loose coercion in v1 versus a strict mode that rejects float-to-Decimal drift in v2; and batch performance runs on pure Python in v1 versus a compiled Rust core roughly five to fifty times faster in v2. A recommendation banner across the bottom reads: default to v2 for new 990 contracts; migrate v1 with bump-pydantic on a release boundary. Same 990 contract, two runtimes — pick the dimension that binds you Pydantic v1 Pydantic v2 · recommended Validator syntax @validator @field_validator · @model_validator Configuration class Config · orm_mode model_config · from_attributes Serialization .dict() · .json() .model_dump() · .model_dump_json() Money / enum strictness loose coercion (float drift) strict=True rejects float→Decimal Batch performance pure Python Rust core · ~5–50× faster bump-pydantic on a release boundary Recommendation Default to v2 for every new 990 validation contract — the strict money mode and Rust-core throughput are decisive for regulated batch ingestion. Migrate an existing v1 contract with bump-pydantic on a scheduled release boundary, never field-by-field in a live pipeline.

When to Use This Approach

Use this decision framework when you own the validation contract that sits between the IRS 990 Data Schema Mapping stage and everything downstream — the typed model that every parsed 990 record must pass through before it is trusted. The choice of Pydantic version is a property of that contract, so decide it once, at the module boundary, not per field.

Three preconditions push you toward v2 unambiguously:

  • The records carry money. Form 990 Part VIII line 1h (total contributions) and Part IX line 25 (total functional expenses) are dollar figures that must reconcile against financial-management records under 2 CFR §200.302. v2’s strict mode rejects a float where a Decimal is declared instead of silently binarizing it — that single behavior difference is the strongest reason to prefer v2 for financial contracts.
  • You validate in batch. A season’s worth of 990 filings is thousands of records validated in one pass. v2’s validation core is compiled Rust, and for large batches it is materially faster than v1’s pure-Python path, which matters when the Async Batch Processing Pipelines stage fans the work across workers.
  • The contract is new or unpinned. Greenfield contracts have no migration cost, so there is no reason to author them against a version in maintenance-only status.

The one scenario where v1 legitimately wins is a frozen dependency: an existing service pinned to a first-party library that itself pins pydantic<2 (older FastAPI, older SQLModel), where the upgrade blast radius exceeds the benefit this cycle. In that case, keep v1, isolate the contract behind an interface, and migrate on the next dependency bump.

Deciding the shape of the 990 schema — which line items map to which JSON keys — is out of scope here and lives in the sibling guide How to Map IRS 990 Part VII to JSON Schema. The public-support arithmetic on Schedule A lives in Mapping 990 Schedule A Public Support Test to JSON. This guide only decides the runtime the contract executes on.

Step-by-Step Implementation

The decision is easiest to reach by writing the same 990 field contract both ways and comparing them line for line. The reference targets Python 3.10+; install exactly one major version per environment — the two cannot coexist in one interpreter.

text
# choose ONE environment
pydantic==1.10.15    # legacy contract
# --- or ---
pydantic==2.7.4      # recommended; ships pydantic-core (Rust) as a wheel

Step 1: Write the contract in Pydantic v1

Here is the v1 form of a minimal 990 revenue contract: @validator methods, a nested class Config, orm_mode for loading from an ORM row, and Decimal for the money fields. Note that v1 accepts a float for a Decimal field by default — the coercion is loose.

python
import logging
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, validator  # Pydantic v1

logger = logging.getLogger("grant.validation.irs990")


class ReturnType(str, Enum):
    """IRS 990 return variants."""
    FULL = "990"
    EZ = "990EZ"
    PF = "990PF"


class Form990Revenue(BaseModel):
    """v1 contract for IRS Form 990 Part VIII revenue lines."""

    ein: str
    return_type: ReturnType
    total_contributions: Decimal   # Part VIII, line 1h
    total_revenue: Decimal         # Part VIII, line 12

    class Config:
        orm_mode = True            # load from an ORM row
        anystr_strip_whitespace = True

    @validator("ein")
    def ein_is_nine_digits(cls, value: str) -> str:
        digits = value.replace("-", "")
        if len(digits) != 9 or not digits.isdigit():
            raise ValueError("EIN must be nine digits (Part I header).")
        return digits

    @validator("total_revenue")
    def revenue_covers_contributions(cls, value: Decimal, values: dict) -> Decimal:
        floor = values.get("total_contributions")
        if floor is not None and value < floor:
            raise ValueError("Part VIII line 12 cannot be below line 1h.")
        return value

The cross-field check in revenue_covers_contributions reaches into values, a plain dict of already-validated fields — a v1 idiom whose ordering fragility is one of the things v2 fixes. orm_mode is the flag that lets Form990Revenue.from_orm(row) pull attributes off a database row.

Step 2: Write the identical contract in Pydantic v2

Now the same contract in v2. Every one of the four migration surfaces changes: @validator becomes @field_validator, the cross-field rule becomes a @model_validator(mode="after") that receives the constructed model instead of a values dict, class Config becomes a model_config = ConfigDict(...), and orm_mode becomes from_attributes.

python
import logging
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, ConfigDict, field_validator, model_validator  # Pydantic v2

logger = logging.getLogger("grant.validation.irs990")


class ReturnType(str, Enum):
    FULL = "990"
    EZ = "990EZ"
    PF = "990PF"


class Form990Revenue(BaseModel):
    """v2 contract for IRS Form 990 Part VIII revenue lines."""

    model_config = ConfigDict(
        from_attributes=True,          # replaces orm_mode
        str_strip_whitespace=True,     # replaces anystr_strip_whitespace
    )

    ein: str
    return_type: ReturnType
    total_contributions: Decimal       # Part VIII, line 1h
    total_revenue: Decimal             # Part VIII, line 12

    @field_validator("ein")
    @classmethod
    def ein_is_nine_digits(cls, value: str) -> str:
        digits = value.replace("-", "")
        if len(digits) != 9 or not digits.isdigit():
            raise ValueError("EIN must be nine digits (Part I header).")
        return digits

    @model_validator(mode="after")
    def revenue_covers_contributions(self) -> "Form990Revenue":
        if self.total_revenue < self.total_contributions:
            raise ValueError("Part VIII line 12 cannot be below line 1h.")
        return self

@field_validator requires the explicit @classmethod decorator, and the mode="after" model validator receives the fully built self, so the cross-field rule reads real attributes with type checking instead of dict lookups. This is strictly safer for a rule that compares two dollar figures.

Step 3: Choose the strictness that protects 990 money and enum fields

This is the dimension that should decide the version. By default v1 will coerce a JSON float such as 12345.67 into a Decimal, inheriting binary floating-point drift; v2 in strict mode refuses it, forcing the upstream parser to hand you a string or an already-exact Decimal. Declare strict money fields explicitly so the contract, not luck, controls coercion.

python
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field  # Pydantic v2


class StrictMoney990(BaseModel):
    """Reject lossy float coercion on regulated dollar fields."""

    model_config = ConfigDict(strict=True)

    # Part IX, line 25 — total functional expenses
    total_functional_expenses: Decimal = Field(strict=True, ge=Decimal("0"))


def parse_expense_line(raw: str) -> StrictMoney990:
    """Validate a single Part IX total from a string, not a float."""
    model = StrictMoney990.model_validate({"total_functional_expenses": raw})
    logger.info("Validated Part IX line 25: %s", model.total_functional_expenses)
    return model

Passing {"total_functional_expenses": 12345.67} (a float) to this v2 model raises a ValidationError; passing the string "12345.67" yields an exact Decimal. On v1 the float is accepted silently — the class of bug that produces a penny-off reconciliation against 2 CFR §200.302 records months later. Enum fields tighten the same way: v2 strict mode rejects a near-miss string that is not an exact ReturnType member instead of coercing it.

Step 4: Update serialization from .dict()/.json() to model_dump()

The last mechanical change is the output side. v1’s .dict() and .json() become v2’s .model_dump() and .model_dump_json(), and v2 adds mode="json" so a Decimal serializes to a JSON-safe string in one call. Keep money as strings across the wire so no consumer re-introduces float drift.

python
def to_audit_record(model: "Form990Revenue") -> str:
    """Serialize a validated 990 revenue model for the audit ledger (v2)."""
    # mode="json" renders Decimal/Enum as JSON-safe primitives
    payload: str = model.model_dump_json()          # replaces .json()
    as_dict = model.model_dump(mode="json")          # replaces .dict()
    logger.info("Serialized EIN %s for return %s", as_dict["ein"], as_dict["return_type"])
    return payload

If you are on a hard deadline mid-migration, v2 still exposes .dict() and .json() as deprecated shims that emit a DeprecationWarning — usable to keep a pipeline running, but not something to leave in a compliance-critical contract.

Step 5: Migrate an existing v1 contract with bump-pydantic

For an existing v1 contract, do not hand-edit each decorator. Run the official bump-pydantic codemod, which rewrites @validator to @field_validator, class Config to model_config, and the config key renames automatically; then review the diff by hand for the cases it flags but cannot safely rewrite — chiefly root validators and any validator that mutated values.

python
# shell, run once on a release boundary:
#   pip install bump-pydantic
#   bump-pydantic grant_validation/
#
# Then hand-review what the codemod leaves as TODO. Root validators are the
# main gotcha: a v1 @root_validator becomes a v2 @model_validator(mode="before"|"after").
from pydantic import BaseModel, model_validator  # Pydantic v2


class Form990Reconciliation(BaseModel):
    total_revenue: Decimal
    total_expenses: Decimal
    net_assets_delta: Decimal

    @model_validator(mode="after")           # was @root_validator in v1
    def reconcile_totals(self) -> "Form990Reconciliation":
        expected = self.total_revenue - self.total_expenses
        if self.net_assets_delta != expected:
            raise ValueError("Net-asset change must equal revenue minus expenses.")
        return self

The gotcha to watch: a v1 @root_validator received a mutable values dict and often mutated it; a v2 @model_validator(mode="after") receives an immutable-by-convention model and should return it. Do the migration on a scheduled release boundary with the full test suite green, never field-by-field in a live pipeline where half the contract is v1 and half is v2.

Verification

Confirm the version choice is behaving correctly with four checks:

  1. Strict money rejects floats. Assert that the v2 StrictMoney990 model raises ValidationError on a float input and accepts the equivalent string, so no dollar field can silently absorb binary drift.
  2. The cross-field rule fires on both versions. Assert that a record with total_revenue < total_contributions raises on the v1 @validator and the v2 @model_validator alike — behavior parity proves the migration preserved the compliance rule.
  3. Serialization round-trips exactly. Assert that model_dump_json() output re-validates back into an equal model and that money fields are strings, not JSON numbers.
  4. The batch path uses the compiled core. For a v2 install, assert pydantic.version.version_short() starts with 2 and that pydantic_core imports, confirming the Rust validation core — not a pure-Python fallback — is what your batch job runs on.

A compliant validation run emits one structured INFO line per accepted record and a WARNING carrying the failing line item and rule name on rejection; ship those to the write-once audit tier so the trail supports the financial-management traceability required by 2 CFR §200.302.

python
from decimal import Decimal
import pydantic
from pydantic import ValidationError

# v2 strict money rejects a float, accepts the exact string
try:
    StrictMoney990.model_validate({"total_functional_expenses": 12345.67})
    raise AssertionError("strict mode must reject a float")
except ValidationError:
    pass

ok = StrictMoney990.model_validate({"total_functional_expenses": "12345.67"})
assert ok.total_functional_expenses == Decimal("12345.67")
assert pydantic.version.version_short().startswith("2")

Common Errors & Fixes

Error Cause Fix
PydanticUserError: @validator is deprecated v1 decorator left in a v2 model Replace @validator("f") with @field_validator("f") plus @classmethod; run bump-pydantic to do it in bulk.
Money field silently off by binary drift v1 loose coercion accepted a float into a Decimal Move the contract to v2 with ConfigDict(strict=True) and Field(strict=True); feed money as strings from the parser.
AttributeError: 'Config' object has no attribute 'orm_mode' v2 model still using nested class Config / orm_mode Use model_config = ConfigDict(from_attributes=True); orm_mode was renamed from_attributes.
AttributeError: 'Form990Revenue' object has no attribute 'dict' in strict lint Calling removed-by-policy .dict()/.json() Switch to .model_dump() / .model_dump_json(); the v1 names remain only as deprecated shims.
@root_validator codemod left as a manual TODO v1 root validator mutated the values dict Rewrite as @model_validator(mode="after") that reads self and returns it; do not mutate in place.
ImportError: cannot import name 'field_validator' Environment still on pydantic<2 Pin pydantic==2.7.4; the two majors cannot coexist in one interpreter.