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.

python
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 ReleaseSoundRecordingContributorShare hierarchy as nested models rather than a single flattened row, so a field_validator can enforce sibling-level constraints such as split sums.

python
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 CommercialModelTypeSubscriptionModel, 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.

python
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.

python
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:

python
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.

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.