Pydantic v2 Model Patterns for ERN Payloads
Once a DDEX ERN message has been parsed into an intermediate dict or lxml element tree, the real engineering work begins: turning deeply nested NewReleaseMessage, Deal, and SoundRecording structures into Python objects that a royalty engine can trust. This is a distinct problem from Schema Validation with Pydantic in the abstract — it is about choosing the right Pydantic v2 idioms so that model construction, not just field-level type checking, mirrors the cardinality and business rules of the ERN standard itself. Get the model shape wrong and a valid-looking payload silently drops a Deal variant or coerces a territory list into a single string, corrupting the split calculation three stages downstream.
Modeling Trade-offs
| Approach | Strength | Weakness | Royalty Impact |
|---|---|---|---|
Flat BaseModel per XML element |
Simple, fast to write | Loses parent-child cardinality, hard to reuse | Orphaned Deal records with no Release parent slip past validation |
Nested BaseModel composition |
Mirrors ERN tree, reusable base classes | More classes to maintain | Enforces that every Deal resolves to a valid ReleaseId, blocking unmatched payouts |
discriminated union for DealTerms |
Type-safe branching on CommercialModelType |
Requires a stable discriminator field from the source XML | Prevents a SubscriptionModel deal being paid out under PayAsYouGoModel rates |
model_validator(mode="after") cross-field checks |
Catches split-sum and date-range errors at construction time | Runs after all fields parse, so per-field errors surface first | Stops a 92% + 12% contributor split from ever reaching the ledger |
Loose dict/Any fallback fields |
Fast to ship for unknown DDEX extensions | Defeats static guarantees entirely | Unvalidated extension blocks can carry silently wrong right_share_pct values |
Prerequisites & Assumptions
This pattern set targets Pydantic v2.5+ and Python 3.11+, consuming dicts already produced by an lxml-based ERN parser (see the sibling guide on validating DDEX XML against XSD schemas for the upstream XSD-conformance step). Field names in raw ERN XML use PascalCase or namespaced tags (ISRC, TerritoryCode, CommercialModelType), so every model below relies on populate_by_name and Field(alias=...) rather than renaming keys by hand before parsing. Assume right_share_pct values arrive as strings and must be coerced to Decimal, never float, to avoid cumulative rounding drift across thousands of contributor rows.
Step 1: A reusable base model with alias and strict-type config
Define one ERNBaseModel that every domain model inherits from, so alias handling, Decimal coercion, and extra-field policy are declared once instead of per-class.
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
class ERNBaseModel(BaseModel):
"""Shared configuration for every ERN-derived model."""
model_config = ConfigDict(
populate_by_name=True, # accept both the Python name and the XML alias
str_strip_whitespace=True,
extra="forbid", # unknown ERN extension fields fail loudly, not silently
frozen=True, # ERN records are immutable once reconciled
)
class ContributorShare(ERNBaseModel):
payee_id: str = Field(alias="PartyId")
role: str = Field(alias="ContributorRole")
right_share_pct: Decimal = Field(alias="RightSharePercent", ge=0, le=100)
extra="forbid" matters more here than in most APIs: DDEX message profiles evolve every year, and a new, unmodeled <Deal> child element should raise ValidationError at ingestion rather than vanish, which would otherwise mean a payee’s revised split terms never make it into the batch.
Step 2: Nested composition mirroring ERN cardinality
Model the Release → SoundRecording → ContributorShare hierarchy as nested models rather than a single flattened row, so a field_validator can enforce sibling-level constraints such as split sums.
from pydantic import field_validator
class SoundRecording(ERNBaseModel):
isrc: str = Field(alias="ISRC", pattern=r"^[A-Z]{2}[A-Z0-9]{3}\d{7}$")
title: str = Field(alias="ReferenceTitle")
contributors: list[ContributorShare] = Field(alias="ContributorList")
@field_validator("isrc")
@classmethod
def normalize_isrc(cls, v: str) -> str:
return v.upper().replace("-", "")
@field_validator("contributors")
@classmethod
def require_at_least_one_contributor(
cls, v: list[ContributorShare]
) -> list[ContributorShare]:
if not v:
raise ValueError("SoundRecording has zero contributor shares")
return v
class Release(ERNBaseModel):
release_id: str = Field(alias="ReleaseId")
upc: str = Field(alias="ICPN", pattern=r"^\d{12,14}$")
sound_recordings: list[SoundRecording] = Field(alias="SoundRecordingList")
Step 3: Discriminated unions for deal types
ERN Deal blocks vary by CommercialModelType — SubscriptionModel, PayAsYouGoModel, AdvertisementSupportedModel — and each carries different payout-relevant fields. A discriminated union lets Pydantic pick the right subclass from a single tag field instead of relying on brittle isinstance checks after the fact.
from typing import Annotated, Literal, Union
from pydantic import Field as PydField
class SubscriptionDeal(ERNBaseModel):
commercial_model_type: Literal["SubscriptionModel"] = Field(alias="CommercialModelType")
price_tier: str = Field(alias="PriceInformation")
class PayAsYouGoDeal(ERNBaseModel):
commercial_model_type: Literal["PayAsYouGoModel"] = Field(alias="CommercialModelType")
unit_price: Decimal = Field(alias="WholesalePricePerUnit")
class AdSupportedDeal(ERNBaseModel):
commercial_model_type: Literal["AdvertisementSupportedModel"] = Field(alias="CommercialModelType")
revenue_share_pct: Decimal = Field(alias="RevenueSharePercent", ge=0, le=100)
DealTerms = Annotated[
Union[SubscriptionDeal, PayAsYouGoDeal, AdSupportedDeal],
PydField(discriminator="commercial_model_type"),
]
class Deal(ERNBaseModel):
release_reference: str = Field(alias="ReleaseReference")
territory_code: list[str] = Field(alias="TerritoryCode")
deal_terms: DealTerms = Field(alias="DealTerms")
Because the discriminator is validated before the branch-specific fields, a deal with a typo’d commercial_model_type fails with a single clear error listing the three valid literals, rather than three separate stack traces from three failed union members.
Step 4: Cross-field validation with model_validator
Split-sum and date-range checks depend on multiple fields at once and belong in model_validator(mode="after"), run once every field has already parsed successfully.
from pydantic import model_validator
class SoundRecordingWithSplitCheck(SoundRecording):
@model_validator(mode="after")
def splits_must_sum_to_100(self) -> "SoundRecordingWithSplitCheck":
total = sum(c.right_share_pct for c in self.contributors)
if abs(total - Decimal("100")) > Decimal("0.01"):
raise ValueError(
f"ISRC {self.isrc}: contributor splits sum to {total}, expected 100"
)
return self
Verification & Validation
Validate parsed dicts with model_validate, not the constructor, so alias resolution and strict coercion both apply:
release = Release.model_validate(raw_release_dict)
assert all(
abs(sum(c.right_share_pct for c in sr.contributors) - 100) < 1
for sr in release.sound_recordings
)
Track two metrics per batch: the ISRC resolution rate (fraction of SoundRecording models that construct without a ValidationError) and the split-sum pass rate (fraction passing splits_must_sum_to_100). Write both payee_id and batch_id alongside every ValidationError.errors() payload into the append-only audit log — Pydantic’s structured error list (loc, msg, type) maps directly to log fields without custom formatting.
Edge Cases & Gotchas
DSPs occasionally emit a single <TerritoryCode>Worldwide</TerritoryCode> instead of an ISO 3166-1 list; a field_validator on territory_code should expand "Worldwide" to your canonical territory set rather than passing the literal string downstream. Namespace drift between ERN 4.2 and 4.3 message profiles can rename RightSharePercent to SharePercentage in newer feeds — keep alias lists as Field(validation_alias=AliasChoices("RightSharePercent", "SharePercentage")) rather than hard-failing on the old name. Watch for NULL or empty-string contributor splits arriving as "0" versus a genuinely missing field; a strict Decimal coercion on an empty string raises before your validator ever runs, so pre-clean blank strings to None for Optional fields. Finally, frozen=True models cannot be mutated post-construction, so any enrichment step (attaching a resolved ISWC, for instance) must produce a new model via model_copy(update={...}) rather than attribute assignment.
Related
These model patterns assume upstream documents already passed structural checks under validating DDEX XML against XSD schemas; Pydantic here enforces the business-logic layer that XSD cannot express. Both feed the same Schema Validation with Pydantic contract layer before records reach reconciliation.