Pydantic v1 vs v2 for NCPDP Validation

Choosing between Pydantic v1 and v2 to validate parsed NCPDP D.0 claims is a decision that touches both the per-claim latency budget and the maintenance cost of every model in the adjudication path. Pydantic sits at the edge of the engine, converting a parsed segment dictionary into a strict, typed canonical claim before any pricing or clinical logic runs — so its validation throughput is a direct tax on the sub-2-second B1 budget, and its API shape is baked into every model the platform owns. Pydantic v2 reimplements the validation core (pydantic-core) in Rust and reaches 5–50x the throughput of v1 on the same models, but the migration renames a swath of the API and changes several defaults. This page is the validation-library head-to-head under Claim State Infrastructure Selection, scoped to the exact v1-versus-v2 call and its migration cost.

The Exact Decision

The claim model validates high-volume, structurally strict input: an 407-D7 Product/Service ID that must stay an 11-digit string with leading zeros intact, a 302-C2 Cardholder ID that is already tokenized, a 409-D9 Ingredient Cost that must be Decimal and never float, and a set of cross-field rules (a 442-E7 Quantity Dispensed that must agree with a 405-D5 Days Supply). Pydantic v1 does this in pure Python; validating tens of thousands of claims per second spends real CPU in interpreter overhead. Pydantic v2 runs the same schema through pydantic-core, compiled Rust, cutting per-claim validation from tens of microseconds to low single digits and shrinking tail latency under load. The cost of that speed is an API migration — @validator becomes @field_validator, the inner Config class becomes model_config, .dict() becomes .model_dump() — plus behavior changes like v2’s stricter type coercion. For a new adjudication platform the decision is settled: start on v2. For an existing v1 codebase the decision is when to migrate, weighed against the throughput and long-term-support pressure, since v1 is end-of-life for new work.

Decision Matrix

Dimension Pydantic v1 Pydantic v2
Validation throughput Baseline (pure Python) ~5–50x faster (pydantic-core in Rust)
Per-claim validate (typical model) ~20–60µs ~1–5µs
Field validator @validator("407-D7") @field_validator("407-D7")
Model-wide validator @root_validator @model_validator(mode="after")
Config inner class Config model_config = ConfigDict(...)
Serialize to dict .dict() .model_dump()
Serialize to JSON .json() .model_dump_json()
Strict typing opt-in, looser coercion strict mode first-class; stricter defaults
Decimal handling supported supported; strict mode blocks float→Decimal coercion
Ecosystem / support maintenance only; EOL for new work active; required by modern FastAPI, current libs
Migration effort mechanical renames + a few behavior fixes

The verdict: pydantic v2 for anything new or throughput-sensitive, which the adjudication edge always is. The remaining question for legacy code is migration sequencing — v2 ships a pydantic.v1 compatibility shim so a large codebase can move model-by-model rather than in one cutover.

NCPDP validation pipeline and the v1 to v2 API migration map A parsed NCPDP D.0 segment dictionary enters a claim model that validates field formats such as the 407-D7 NDC and the 409-D9 ingredient cost as Decimal, runs cross-field rules, and emits a typed canonical claim. Below, a migration map pairs each Pydantic v1 API element with its v2 replacement: the validator decorator becomes field_validator, the root validator becomes model_validator, the inner Config class becomes model_config, and the dict and json methods become model_dump and model_dump_json. Version 2 runs the core in Rust for higher throughput. Parsed D.0 segments dict of NCPDP fields Claim model validate 407-D7 str · 409-D9 Decimal · cross-field Typed canonical claim strict, PHI-tokenized x v1 → v2 migration map Pydantic v1 Pydantic v2 (Rust core) @validator @field_validator @root_validator @model_validator(mode="after") class Config: model_config = ConfigDict(...) .dict() .model_dump() .json() .model_dump_json()

Figure: The validation pipeline turns parsed D.0 segments into a strict typed claim, and the migration map pairs each v1 API element with its v2 replacement — the mechanical core of a v1-to-v2 move.

Step-by-Step: The Same NCPDP Claim Model in v1 and v2

The model validates the load-bearing fields and one cross-field rule. Below, the v1 form and the v2 form side by side, both keeping 409-D9 as Decimal, both preserving 407-D7 leading zeros, and both refusing to echo raw claim values in a validation error.

1. The v1 model. Field validators use @validator, the config is an inner class, and cross-field logic uses @root_validator.

python
# Pydantic v1 (legacy) -- maintenance only, pure-Python core.
from decimal import Decimal
from pydantic import BaseModel, validator, root_validator


class ClaimV1(BaseModel):
    ndc_407d7: str            # 407-D7 Product/Service ID (NDC), 11-digit string
    days_supply_405d5: int    # 405-D5 Days Supply
    quantity_442e7: Decimal   # 442-E7 Quantity Dispensed
    ingredient_cost_409d9: Decimal  # 409-D9 Ingredient Cost -- Decimal, never float

    class Config:
        anystr_strip_whitespace = True
        extra = "forbid"

    @validator("ndc_407d7")
    def _ndc_is_11_digits(cls, v: str) -> str:
        if not (v.isdigit() and len(v) == 11):  # leading zeros must survive
            raise ValueError("invalid 407-D7 format")  # no raw value echoed
        return v

    @root_validator
    def _qty_supply_agree(cls, values: dict) -> dict:
        qty, sup = values.get("quantity_442e7"), values.get("days_supply_405d5")
        if qty is not None and sup and qty <= 0:
            raise ValueError("442-E7 must be positive for a positive 405-D5")
        return values

2. The v2 model. Field validators use @field_validator with @classmethod, config is model_config, and cross-field logic uses @model_validator(mode="after") operating on the built instance. Strict mode blocks silent float→Decimal coercion, which is exactly the guard adjudication money needs.

python
# Pydantic v2 -- pydantic-core (Rust); default for new adjudication models.
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, field_validator, model_validator


class ClaimV2(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True, extra="forbid", strict=False)

    ndc_407d7: str            # 407-D7 Product/Service ID (NDC), 11-digit string
    days_supply_405d5: int    # 405-D5 Days Supply
    quantity_442e7: Decimal   # 442-E7 Quantity Dispensed
    ingredient_cost_409d9: Decimal  # 409-D9 Ingredient Cost -- Decimal, never float

    @field_validator("ndc_407d7")
    @classmethod
    def _ndc_is_11_digits(cls, v: str) -> str:
        if not (v.isdigit() and len(v) == 11):  # leading zeros preserved
            raise ValueError("invalid 407-D7 format")  # shape only, no raw value
        return v

    @model_validator(mode="after")
    def _qty_supply_agree(self) -> "ClaimV2":
        if self.quantity_442e7 <= 0 and self.days_supply_405d5 > 0:
            raise ValueError("442-E7 must be positive for a positive 405-D5")
        return self

3. Update the serialization call sites. Anything that produced .dict()/.json() for the state plane or the audit ledger becomes .model_dump()/.model_dump_json(). This is the change most likely to be missed because it fails at runtime, not import time.

python
claim = ClaimV2(ndc_407d7="00093015001", days_supply_405d5=30,
                quantity_442e7=Decimal("30"), ingredient_cost_409d9=Decimal("42.10"))
payload = claim.model_dump()          # was claim.dict() in v1
audit_line = claim.model_dump_json()  # was claim.json() in v1

4. Migrate incrementally where needed. In a large legacy codebase, from pydantic.v1 import BaseModel keeps a v1 model working under an installed v2, so models move one at a time rather than in a single risky cutover.

Verification: Behavior Parity Across Versions

The property to pin is that both models accept the same valid claim, reject the same malformed 407-D7, and never let a float silently become the ingredient cost. Drive both with the same fixtures.

python
import pytest
from decimal import Decimal


VALID = dict(ndc_407d7="00093015001", days_supply_405d5=30,
             quantity_442e7=Decimal("30"), ingredient_cost_409d9=Decimal("42.10"))


def test_valid_claim_parses_identically():
    v2 = ClaimV2(**VALID)
    assert v2.ndc_407d7 == "00093015001"          # leading zeros preserved
    assert v2.ingredient_cost_409d9 == Decimal("42.10")


def test_bad_ndc_rejected_without_echoing_value():
    with pytest.raises(ValueError) as exc:
        ClaimV2(**{**VALID, "ndc_407d7": "93-15-1"})
    assert "93-15-1" not in str(exc.value)          # PHI/claim value never echoed


def test_cross_field_rule_rejects_nonpositive_quantity():
    with pytest.raises(ValueError):
        ClaimV2(**{**VALID, "quantity_442e7": Decimal("0")})


def test_model_dump_replaces_dict():
    dumped = ClaimV2(**VALID).model_dump()
    assert dumped["ndc_407d7"] == "00093015001"
    assert isinstance(dumped["ingredient_cost_409d9"], Decimal)

Gotchas & PHI Guardrails

  • Validation errors must not echo raw claim values. Pydantic’s default ValueError message can include the offending input; on a claim model that input may be a 302-C2 Cardholder ID or a raw 407-D7. Raise messages that describe the shape of the failure (“invalid 407-D7 format”), never the value, so a validation error in a log line is not a PHI disclosure — the boundary enforced across Schema Validation & Error Categorization.
  • .dict().model_dump() is a silent runtime break. It imports and type-checks fine, then fails at runtime on v2. Grep the whole codebase for .dict(/.json( before declaring a migration done.
  • Strict mode changes coercion. v2’s stricter defaults reject a float where a Decimal is declared, which is desirable for 409-D9/412-DC money, but they also reject a numeric string where v1 quietly coerced — audit every field that arrives as a string from the parsed segments in NCPDP D.0 Message Parsing Strategies.
  • Keep NDC a string. Neither version should type 407-D7 as int; that drops leading zeros and corrupts the labeler segment. Validate length and digit-ness on a str.
  • @root_validator@model_validator(mode="after") semantics differ. The v1 validator sees a values dict (possibly with missing keys); the v2 after validator sees a fully built, type-validated instance. Cross-field rules that relied on partial state need review, not just a rename.
  • Money stays Decimal end to end. Every monetary field is Decimal, quantized on output with ROUND_HALF_UP; a float slipping through validation is a payer audit finding. See the Pydantic documentation and the Python decimal module for strict-mode and rounding behavior.

← Back to Claim State Infrastructure Selection