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.
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.
# 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 values2. 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.
# 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 self3. 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.
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 v14. 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.
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
ValueErrormessage can include the offending input; on a claim model that input may be a302-C2Cardholder ID or a raw407-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
floatwhere aDecimalis declared, which is desirable for409-D9/412-DCmoney, 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-D7asint; that drops leading zeros and corrupts the labeler segment. Validate length and digit-ness on astr. @root_validator→@model_validator(mode="after")semantics differ. The v1 validator sees avaluesdict (possibly with missing keys); the v2aftervalidator 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 withROUND_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.
Related
- Claim State Infrastructure Selection — the parent decision that frames validation alongside transport and state store.
- NCPDP D.0 Message Parsing Strategies — the parsing stage that produces the segment dictionary this model validates.
- Schema Validation & Error Categorization — how validation failures are categorized and mapped to reject codes without leaking claim values.
- Kafka vs RabbitMQ for PBM Adjudication — the transport that carries the validated canonical claim.
- Redis vs DynamoDB for Ephemeral Claim State — the state store that holds the validated claim between transitions.
← Back to Claim State Infrastructure Selection