| name | python-data-modeling |
| description | Guides expert-level Python data modeling: Pydantic v2 model design, dataclass vs Pydantic decision tree, TypedDict for API contracts, validation patterns, serialization strategies, and schema evolution. Covers designing data structures that enforce correctness at runtime.
Use when the user asks about Pydantic models, data validation in Python, dataclass vs Pydantic decisions, TypedDict, or serialization and deserialization patterns.
Do NOT use when the user asks about Python project setup (use `python-project-setup`), type system features like generics (use `python-type-system`), or general idioms (use `python-idioms`).
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"python best-practices database","category":"software-engineering","subcategory":"languages-runtimes","depends":"","disclaimer":"none","difficulty":"intermediate"} |
Python Data Modeling
When to Use
Use this skill when:
- The user asks how to define or redesign Pydantic models, including field constraints, validators, model configuration, or schema customization in Pydantic v2
- The user is deciding between
@dataclass, Pydantic BaseModel, TypedDict, NamedTuple, or plain dicts for a specific use case and needs a principled recommendation
- The user wants to validate data arriving from an external source -- API requests, webhooks, CSV uploads, config files, environment variables, or third-party API responses
- The user needs to design serialization and deserialization strategies: controlling how models emit JSON, handling aliases, excluding fields, or mapping between snake_case and camelCase
- The user wants to evolve a data schema -- adding fields to an existing model, handling deprecated fields, or versioning API contracts without breaking existing consumers
- The user asks about Pydantic BaseSettings or similar patterns for loading and validating application configuration from environment variables and
.env files
- The user needs to model complex domain hierarchies: polymorphic types using discriminated unions, nested models, recursive structures, or models with inter-field constraints
- The user wants to define reusable validation types using
Annotated and share them across multiple models in a codebase
Do NOT use this skill when:
- The user wants to set up a Python project from scratch, configure
pyproject.toml, or install dependencies → use python-project-setup
- The user is asking about Python's type system at the generic/parametric level:
TypeVar, Generic[T], Protocol, covariance, or type narrowing → use python-type-system
- The user wants general Python idioms and code style: comprehensions, context managers, decorators as language features → use
python-idioms
- The user is writing tests for data models (mocking, hypothesis property-based testing of validators) → use
python-testing-patterns
- The user wants structured error handling patterns beyond what validators raise → use
python-error-handling
- The user is asking specifically about ORM models -- SQLAlchemy
DeclarativeBase, Django ORM field definitions -- as those are persistence-layer patterns with their own mapping concerns, not data modeling in the Pydantic/dataclass sense
- The user is asking about async data streaming or message queue schema definitions (Avro, Protobuf, JSON Schema as file formats) as primary artifacts -- these have dedicated schema registry patterns
Process
Step 1: Classify the Data Modeling Need
Apply this decision tree precisely. Getting this wrong leads to over-engineering or missing validation at the wrong boundary.
- External data with validation required (API requests, webhook payloads, config files, user form submissions, CSV rows): use
Pydantic BaseModel. Pydantic v2 uses Rust-backed validation, benchmarks at ~100x faster than pure-Python alternatives, and fails fast with structured ValidationError containing all field errors at once.
- Internal domain objects with no external validation (objects constructed only within your own code, passed between internal services): use
@dataclass. A frozen dataclass is hashable and usable as a dict key or set element -- critical for domain value objects like Money, UserId, or Coordinate.
- Typed dict-like data for type-checker contracts only (API response shapes that are already validated, data passed to functions that accept dict-like args, TypedDict interop with JSON libraries): use
TypedDict. Zero runtime overhead; purely a type annotation tool. Use Required and NotRequired to model optional keys.
- Immutable record types used as tuple-like values (lightweight coordinate pairs, 2--3 field named records that benefit from tuple unpacking): use
NamedTuple. These are actual tuples at runtime, so they serialize natively with json.dumps and are interoperable with code expecting sequences.
- Application configuration loaded from environment variables: use
pydantic-settings (BaseSettings). It supports .env files, environment variable prefix namespacing, type coercion from string env vars (e.g., "true" → bool), and nested settings via JSON-encoded env vars.
- High-throughput internal pipeline records (processing millions of records in a tight loop where even Pydantic v2 overhead is measurable at scale): validate once at the inbound boundary with Pydantic, convert to a
dataclass(slots=True) internal representation for efficient in-memory processing.
Step 2: Design the Pydantic Model Architecture
When you have determined Pydantic is the right tool, design the model structure before writing any code.
- Separate models by concern. Define at minimum: an inbound model (
OrderCreate, UserRequest) that validates external input, a domain model (Order, User) that represents the internal state, and optionally a response model (OrderResponse, UserResponse) that controls what serializes to API clients. Never let the persistence or domain model leak directly into API output -- it creates accidental coupling where adding an internal field changes the API contract.
- Define reusable annotated types first. Before writing any model, identify fields that share the same validation semantics across models.
NonEmptyStr = Annotated[str, Field(min_length=1, max_length=255)], PositiveDecimal = Annotated[Decimal, Field(gt=0, decimal_places=2)], HttpsUrl (Pydantic's built-in). Place these in a types.py module. Reuse -- do not duplicate Field constraints inline.
- Choose
model_config settings explicitly. Never leave model_config unset. At minimum configure: str_strip_whitespace=True (prevents whitespace-only strings from passing min_length=1 constraints), validate_default=True (ensures default values are also validated -- catches bugs at class definition time, not at runtime), populate_by_name=True if using alias generators.
- Use
alias_generator for API boundary translation. If the external API uses camelCase and your Python code uses snake_case (the standard case), set alias_generator=to_camel (from pydantic.alias_generators) and populate_by_name=True. This means both createdAt and created_at are accepted for input, and you control which name is used for output via model_dump(by_alias=True).
- Layer validators in the correct order. Pydantic v2 validation order: (1) field-level type coercion, (2)
field_validator with mode="before" (raw input, before type coercion), (3) type annotation validation, (4) field_validator with mode="after" (already typed value, most common), (5) model_validator(mode="after") (all fields validated, cross-field checks). Putting a cross-field check in a field_validator is a code smell -- use .
Step 3: Define Field-Level Validation with Precision
Field-level validation is where most correctness guarantees live. Apply these patterns:
- Use
Field() constraints for numeric bounds. ge (≥), gt (>), le (≤), lt (<) correspond to JSON Schema minimum/exclusiveMinimum. For financial data, always use Decimal with decimal_places=2 rather than float -- floating-point rounding at the field level causes silent accounting errors. Example: amount: Annotated[Decimal, Field(ge=Decimal("0.00"), decimal_places=2)].
- Use
pattern= for string format validation. For fields where regex is the source of truth (postal codes, phone numbers, ISBNs, slug identifiers), specify pattern=r"^[a-z0-9-]{3,64}$" in Field(). This also exports correctly to JSON Schema for API documentation. Do not validate regex in a field_validator when pattern= suffices -- it duplicates logic and doesn't appear in the schema.
- Use
field_validator only for logic that cannot be expressed as a constraint. Correct uses: normalizing a value (e.g., uppercasing a country code, stripping internal whitespace from a name), business-logic checks that require computation (e.g., verifying a Luhn checksum on a card number), or cross-referencing a small static lookup (e.g., validating a currency code against an ISO 4217 set). Incorrect uses: range checks, length checks, regex -- use Field() constraints instead.
- Return the (possibly transformed) value from
field_validator. In Pydantic v2, field_validator must always return the value. Returning None implicitly sets the field to None, which is almost never intended. Raise ValueError (never TypeError or custom exceptions) for invalid data -- Pydantic wraps ValueError into ValidationError automatically.
- Use
BeforeValidator and AfterValidator via Annotated for reusable normalization. Instead of a field_validator that only normalizes, define UpperCaseStr = Annotated[str, AfterValidator(str.upper)] and apply it to all fields that need it. This composes cleanly and avoids validator boilerplate on each model.
Step 4: Design Dataclass Models Correctly
When dataclasses are appropriate, apply these patterns to avoid common pitfalls:
- Use
slots=True for any dataclass instantiated at high frequency. @dataclass(slots=True) generates __slots__ automatically (Python 3.10+), reducing per-instance memory from approximately 264 bytes to approximately 56 bytes for a simple 3-field object. This matters when processing millions of records. The trade-off: slotted dataclasses cannot have instance __dict__, so dynamic attribute assignment is prohibited -- which is desirable for value objects anyway.
- Use
frozen=True for value objects. A frozen dataclass generates __hash__ and makes all fields immutable. This enables use as dict keys, set members, and LRU cache keys. Combine with slots=True for maximum efficiency: @dataclass(frozen=True, slots=True).
- Never use mutable defaults.
items: list[str] = [] in a dataclass is a TypeError in Python 3.7+ (the interpreter will raise it at class definition time). Use field(default_factory=list). For nested dataclasses as defaults, use field(default_factory=YourDataclass).
- Use
__post_init__ for derived fields and invariant enforcement. Compute derived fields (e.g., self.full_name = f"{self.first_name} {self.last_name}") and enforce business invariants (raise ValueError if end_date < start_date) here. In a frozen dataclass, use object.__setattr__(self, "full_name", ...) since direct assignment is blocked.
- Use
kw_only=True (Python 3.10+) to prevent positional argument ambiguity. For dataclasses with more than 3 fields, kw_only=True forces all fields to be specified by name. This makes construction sites self-documenting and prevents argument-order bugs that type checkers don't always catch.
- Use
field(compare=False) for implementation-detail fields. Fields like _cache, created_at timestamps, or internal state flags that should not affect equality comparison should use compare=False. This is critical for domain value objects where equality should reflect identity, not incidental metadata.
Step 5: Design Serialization Strategies
Serialization is where data models cross process boundaries. Design it explicitly.
- Pydantic
model_dump() options you must know. model_dump(mode="json") performs the same type coercion as JSON serialization but returns a dict (useful when you need a dict but with JSON-safe values like strings instead of datetime objects). model_dump(exclude_none=True) removes fields whose value is None -- use for sparse API payloads. model_dump(include={"field1", "field2"}) for projection to a subset. model_dump(by_alias=True) uses alias names rather than Python attribute names.
- Use
model_dump_json() for performance-critical serialization. model_dump_json() uses Pydantic's Rust backend and is 2--4x faster than json.dumps(model.model_dump()). Always prefer it when emitting JSON directly to a response body or file.
- Define
model_serializer for custom output shapes. When the serialized form structurally differs from the model (e.g., flattening a nested object, renaming fields conditionally), use @model_serializer(mode="wrap"). This is cleaner than post-processing model_dump() output in the calling code.
- Never use
dataclasses.asdict() when performance matters. dataclasses.asdict() recursively copies all nested structures and is implemented in pure Python. For hot serialization paths, write an explicit to_dict() method or use a __slots__ aware serializer. For JSON, dataclasses.asdict() still requires a second json.dumps() pass, making it two full traversals.
- Use separate response models to enforce API output stability. Define
UserResponse(BaseModel) with only the fields you intend to expose. Compute it from User domain objects with a class method: UserResponse.from_domain(user). This prevents accidentally exposing internal fields (hashed passwords, audit timestamps, internal IDs) when the domain model grows.
- Handle
datetime serialization explicitly. Pydantic v2 serializes datetime to ISO 8601 strings by default. If your API contract requires Unix timestamps, use field_serializer: @field_serializer("created_at") def serialize_dt(self, v: datetime) -> int: return int(v.timestamp()). Always store objects as timezone-aware UTC internally, regardless of what format you serialize to.
Step 6: Handle Schema Evolution and Backward Compatibility
Data models change over time. Design for it from the start.
- Additive changes are safe when fields have defaults. Adding
new_field: str = "" or new_field: str | None = None to a Pydantic model is backward compatible -- old payloads without the field will use the default. Adding a required field (no default) is a breaking change. Track required vs. optional changes in your changelog.
- Use
model_validator(mode="before") for field renaming migration. When renaming phone to phone_number, add a mode="before" validator that copies data["phone"] to data["phone_number"] if "phone" is present. This allows a gradual migration window where both old and new field names are accepted. Remove the migration validator after all producers have migrated.
- Use discriminated unions for polymorphic event schemas. When a field can be one of several model shapes (e.g.,
event: OrderCreated | OrderShipped | OrderCancelled), define a type field as Literal["order_created"] etc. on each model, then use Annotated[Union[OrderCreated, OrderShipped, OrderCancelled], Field(discriminator="type")]. Pydantic selects the correct model in O(1) by the discriminator value rather than trying all models in sequence. This is 10--50x faster than non-discriminated unions for large union types.
- Version models explicitly for breaking changes. For API contracts where you cannot break consumers, create
v1/models.py and v2/models.py. Use an adapter layer (v2.Order.from_v1(v1_order)) to translate between versions internally. Do not try to handle version differences with optional fields beyond one generation.
- Use
model_rebuild() after defining mutually recursive models. Models like Category that contain a list of Category children require a forward reference: children: list["Category"] = []. After the class is defined, call Category.model_rebuild() to resolve the forward reference. In complex module structures, call model_rebuild() in a module-level initialization function that runs after all models are imported.
Step 7: Validate at the Right Boundary
Knowing where to validate is as important as knowing how.
- Validate at the process entry point, not deep in business logic. Parse external data into a validated model at the first point it enters your system: the request handler, the Celery task entry, the file reader function. Once data is in a typed model, treat it as trusted throughout the call stack. Re-validating at every layer adds overhead without safety benefit.
- Use
model_validate_json() instead of json.loads() + model_validate(). YourModel.model_validate_json(raw_bytes) is a single-pass operation using Pydantic's Rust JSON parser. It is 20--30% faster than parsing JSON separately and then validating the resulting dict. Use it whenever you receive raw JSON bytes or strings.
- Catch
ValidationError at the boundary and translate to domain errors. Do not let pydantic.ValidationError propagate to callers who don't know or care about Pydantic. At the API layer, translate it to a structured HTTP 422 response. In a service layer, translate it to a domain exception (InvalidOrderData(errors=e.errors())). Use error.errors() to get a list of dicts with loc (field path), msg (human-readable message), type (error code), and input (the failing value).
- Use
TypeAdapter for validating non-model types. To validate a raw list[int] or dict[str, float] without defining a full model, use TypeAdapter: ta = TypeAdapter(list[int]); ta.validate_python([1, 2, "3"]). This applies the same Pydantic validation pipeline to any type annotation. Also useful for validating Union types without a wrapper model.
Step 8: Optimize for Performance in High-Throughput Contexts
When data modeling is on a hot path (>10,000 validations/second), apply targeted optimizations.
- Profile before optimizing. Use
cProfile or pyinstrument to confirm Pydantic validation is the bottleneck before making architectural changes. Premature optimization here (switching to raw dicts) destroys type safety for negligible gain.
- Use
model_construct() to skip validation for trusted internal data. MyModel.model_construct(field1=val1, field2=val2) bypasses all validation and creates the model directly. Use only for data you have already validated or generated internally -- never for external input. This is 5--10x faster than model_validate() and appropriate for database read paths where the DB schema enforces constraints.
- Batch validate with
TypeAdapter for list inputs. TypeAdapter(list[MyModel]).validate_python(raw_list) is faster than [MyModel.model_validate(item) for item in raw_list] because Pydantic can process the entire list in a single Rust call. The speedup is approximately 15--25% for lists of 100+ items.
- Reuse
TypeAdapter instances as module-level singletons. Constructing TypeAdapter(MyType) has non-trivial overhead for building the validator. Create it once at module level and reuse: _adapter = TypeAdapter(list[MyModel]). Constructing it inside a function called in a hot loop is a common performance mistake.
Output Format
When responding to a user's data modeling question, structure your answer as follows:
Decision
State which modeling approach is appropriate and why, citing the specific factors from the user's context that drove the decision (external data, performance constraints, API boundary, etc.).
Model Architecture
Describe the model layer structure before showing code:
- Inbound/request models and their validation purpose
- Domain models and what invariants they enforce
- Response/output models and what they expose
- Shared annotated types to define centrally
Implementation
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
TypeAdapter,
field_serializer,
field_validator,
model_validator,
)
from pydantic.alias_generators import to_camel
NonEmptyStr = Annotated[str, Field(min_length=1, max_length=255)]
PositiveDecimal = Annotated[Decimal, Field(gt=Decimal("0"), decimal_places=2)]
SlugStr = Annotated[str, Field(pattern=r"^[a-z0-9-]{3,64}$")]
class StatusEnum(StrEnum):
ACTIVE = "active"
INACTIVE = "inactive"
class EntityCreate(BaseModel):
"""
Validates incoming [entity] creation requests from [source].
Enforces: [list of key invariants].
"""
model_config = ConfigDict(
str_strip_whitespace=True,
validate_default=True,
populate_by_name=True,
alias_generator=to_camel,
)
field_name: NonEmptyStr = Field(
...,
description=,
examples=[],
)
optional_field: | = Field(
default=,
description=,
)
numeric_field: PositiveDecimal
status: StatusEnum = StatusEnum.ACTIVE
() -> :
v.lower()
() -> :
.status == StatusEnum.INACTIVE .optional_field :
ValueError()
():
model_config = ConfigDict(populate_by_name=, alias_generator=to_camel)
:
field_name:
status: StatusEnum
created_at: datetime
() -> :
v.isoformat()
() -> :
cls(
=(domain_obj.),
field_name=domain_obj.field_name,
status=domain_obj.status,
created_at=domain_obj.created_at,
)
Validation and Error Handling at the Boundary
from pydantic import ValidationError
def handle_create_request(raw_payload: dict) -> EntityResponse:
try:
request = EntityCreate.model_validate(raw_payload)
except ValidationError as e:
raise InvalidRequestError(errors=e.errors()) from e
domain_obj = create_entity(request)
return EntityResponse.from_domain(domain_obj)
Key Design Decisions
| Decision | Choice | Rationale |
|---|
| Model type | Pydantic BaseModel | External data with validation |
| Alias strategy | to_camel alias generator | API uses camelCase |
| Serialization | model_dump_json() | Performance, single Rust pass |
| Cross-field validation | model_validator(mode="after") | Needs all fields validated first |
Rules
-
NEVER validate external data without Pydantic (or equivalent) at the entry point. Using if "field" in data and isinstance(data["field"], str) scattered through business logic is unmaintainable and misses coercion, normalization, and schema documentation. Validate once, cleanly, at the boundary.
-
ALWAYS use Pydantic v2 API. The v2 API (field_validator, model_validator, model_dump, model_config = ConfigDict(...)) is not backward compatible with v1. Mixing @validator (v1) with @field_validator (v2) in the same model causes PydanticUserError at class definition time. Use pip show pydantic | grep Version to confirm the installed version is ≥ 2.0.0.
-
NEVER use float for monetary or financial values. 0.1 + 0.2 == 0.30000000000000004 in Python. Use Decimal with explicit decimal_places constraints. Financial APIs that send amounts as floats require conversion at the boundary: Decimal(str(float_value)) -- never Decimal(float_value) which preserves the floating-point imprecision.
-
ALWAYS store datetime objects as timezone-aware UTC. Store datetime objects created with datetime.now(tz=timezone.utc) or parsed with datetime.fromisoformat(). Pydantic v2 accepts naive datetimes by default but this is a trap -- configure model_config = ConfigDict(... ) alongside a validator that raises for naive datetimes in any model that handles timestamps crossing timezone boundaries.
-
NEVER expose internal domain models or ORM models directly in API responses. SQLAlchemy model attributes, internal audit fields, hashed passwords, internal state machines, and foreign key IDs are all accidental leaks if you model_dump() a domain object directly into an API response. Define *Response models with only the fields you explicitly intend to expose.
-
ALWAYS use field(default_factory=...) for mutable defaults in dataclasses. @dataclass class Foo: items: list = [] raises ValueError in Python 3.7+ at class definition time. The correct form is . For Pydantic, mutable defaults work differently -- Pydantic deep-copies them automatically -- but still use for clarity and consistency.
Edge Cases
Circular References Between Models
Two models that reference each other (e.g., User has a list[Post] and Post has an author: User) cause NameError at class definition time if you import both at module level.
Resolution: Use from __future__ import annotations at the top of the file (defers all annotation evaluation to strings), then call User.model_rebuild() and Post.model_rebuild() after both classes are defined. Place model rebuild calls in a _rebuild_models() function at the bottom of the module and call it once. For cross-module circular references, use TYPE_CHECKING guard imports: if TYPE_CHECKING: from .post import Post -- the string annotation handles the runtime reference, and the TYPE_CHECKING import satisfies mypy without creating a runtime circular import.
Migrating a Live Codebase from Pydantic v1 to v2
The bump-pydantic CLI tool automates ~70% of v1-to-v2 migrations: it handles @validator → @field_validator, @root_validator → @model_validator, Config inner class → model_config = ConfigDict(...), .dict() → .model_dump(), .json() → .model_dump_json(), and .parse_raw() → .model_validate_json(). Run it with bump-pydantic --target-version v2 src/. After the automated pass, manually audit: validator calls that used always=True (now validate_default=True in ConfigDict), validator calls with each_item=True (now handle by applying the validator to list[T] typed fields), and __fields__ access (now model_fields).
Models with Dynamic or Unknown Fields
When a third-party API returns fields you don't control and you must preserve them:
class FlexibleModel(BaseModel):
model_config = ConfigDict(extra="allow")
known_field: str
obj = FlexibleModel(known_field="x", surprise="y")
obj.model_extra
obj.model_dump()
Use extra="allow" for pass-through proxy models. Use extra="forbid" for internal configuration models where unknown keys indicate a misconfiguration. Use extra="ignore" (the default) for public API inbound models where you want to silently discard fields -- this is the safest default for public-facing endpoints.
Very Large Payloads (Thousands of Nested Items)
Pydantic v2 validation of a list of 10,000 complex nested models takes approximately 50--200ms depending on model complexity. For batch API endpoints that accept large arrays:
- Validate structure (item count, top-level shape) before running full item validation.
- Use
TypeAdapter(list[ItemModel]).validate_python(raw_list) rather than a model with a list field -- this avoids one extra wrapper model traversal.
- Consider streaming validation: process items in chunks of 500--1000, catching
ValidationError per chunk to provide partial success responses.
- For truly hot paths (>100,000 items/second), validate only a structural sample and rely on database constraints for the rest -- but document this trade-off explicitly.
Schema Evolution with Deprecated Fields
When a field is being removed, do not remove it immediately. Apply a two-phase deprecation:
Phase 1 (deprecation period): Mark the field as deprecated with Field(deprecated=True) (Pydantic v2.4+). This causes Pydantic to emit a PydanticDeprecatedSince20 warning when the field is used. Keep the field as Optional so existing producers still work.
Phase 2 (removal): Remove the field entirely. If old payloads still arrive, add a model_validator(mode="before") that silently drops the old field: data.pop("old_field_name", None); return data. This prevents validation errors from old producers during the transition window.
TypedDict vs Pydantic for Internal API Response Typing
TypedDict has zero runtime overhead (it is erased at runtime) and is the right choice for typing the return shape of functions that already return plain dicts (e.g., Django REST Framework serializers, third-party library responses). Pydantic response models carry runtime overhead but offer model_dump(by_alias=True) and model_dump_json().
Use TypedDict when:
- You're adding types to an existing codebase that already returns dicts and you cannot change the return type
- You're typing function parameters that accept dict-like data from a library with its own serialization
- You need to interop with
json.dumps without any conversion step
Use Pydantic response models when:
- You control the serialization path and want a single
model_dump_json() call
- You need field-level serialization customization (
field_serializer, alias generation)
- The response model benefits from computed properties or from-domain factory methods
Pydantic BaseSettings with Complex Configuration
pydantic-settings BaseSettings loads values from environment variables, .env files, AWS Secrets Manager, and other sources in a configurable priority order. Common pitfalls:
- Nested settings with JSON env vars: A nested model field like
database: DatabaseSettings requires the environment variable to be JSON-encoded: DATABASE='{"host":"localhost","port":5432}'. Use this for staging/production configuration. For local development, use a .env file with DatabaseSettings-prefixed variables by setting model_config = SettingsConfigDict(env_nested_delimiter="__") which maps DATABASE__HOST=localhost to database.host.
- Secret files:
BaseSettings supports secrets_dir for reading secrets from mounted Kubernetes secret files. Set model_config = SettingsConfigDict(secrets_dir="/run/secrets") and create files named after the field (e.g., /run/secrets/database_password). This is the correct pattern for container orchestration environments.
- Validator execution order:
BaseSettings runs field validators after loading from the environment, so @field_validator("database_url", mode="after") will see the already-coerced string. Use this to normalize URLs, validate connection string format, or raise at startup if a required secret is missing -- failing fast at startup rather than at first database call.
Example
Input: "I'm building a webhook processor that receives Stripe-style payment events. Events can be payment.succeeded, payment.failed, or refund.created. Each event type has different fields. The payload comes as raw JSON bytes. I need to validate, route, and serialize these to our internal event store. What's the right data modeling approach?"
Output:
Decision
This is a classic discriminated union problem with a JSON entry boundary. Three model shapes share a common envelope (id, type, created, livemode) but carry different payload structures. Pydantic v2's discriminated unions with Literal discriminators handle this in O(1) without trying each model in sequence. We validate at the entry point with model_validate_json(), then dispatch to handlers by type. Internal storage uses a separate persistence shape to avoid coupling the storage schema to the inbound API schema.
Model Architecture
- Inbound envelope models: One per event type, sharing a
BaseEvent with the common fields. Discriminator field type selects the correct model.
- Payload models: Nested models for
PaymentSucceededData, PaymentFailedData, RefundCreatedData carrying event-specific fields.
- Internal event model: A storage-oriented model that flattens what we need and adds internal metadata (received_at, processing_id).
- Shared types:
MoneyAmount (Decimal with currency), StripeId (prefixed string pattern).
Implementation
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from typing import Annotated, Literal, Union
from pydantic import (
BaseModel,
ConfigDict,
Field,
TypeAdapter,
field_validator,
model_validator,
)
CentsAmount = Annotated[int, Field(ge=0, le=99_999_999)]
StripeObjectId = Annotated[
str,
Field(pattern=r"^[a-zA-Z0-9_]{8,255}$", description="Stripe object identifier"),
]
class PaymentMethodDetails(BaseModel):
"""Nested payment method info. Only fields we care about."""
model_config = ConfigDict(extra="ignore")
brand: str | None = None
last4: str | None = Field(default=None, pattern=r"^\d{4}$")
funding: str | None = None
():
model_config = ConfigDict(extra=, str_strip_whitespace=)
payment_intent_id: StripeObjectId = Field(alias=)
amount: CentsAmount
amount_received: CentsAmount
currency: = Field(min_length=, max_length=, description=)
customer_id: StripeObjectId | = Field(default=, alias=)
payment_method_details: PaymentMethodDetails | =
receipt_email: | =
() -> :
v.upper()
() -> :
.amount_received > .amount:
ValueError(
)
() -> Decimal:
Decimal(.amount) / Decimal()
():
model_config = ConfigDict(extra=)
payment_intent_id: StripeObjectId = Field(alias=)
amount: CentsAmount
currency: = Field(min_length=, max_length=)
failure_code: | =
failure_message: | = Field(default=, max_length=)
customer_id: StripeObjectId | = Field(default=, alias=)
() -> :
v.upper()
():
model_config = ConfigDict(extra=)
refund_id: StripeObjectId = Field(alias=)
payment_intent_id: StripeObjectId | = Field(
default=, alias=
)
amount: CentsAmount
currency: = Field(min_length=, max_length=)
reason: | =
status: [, , , ]
() -> :
v.upper()
() -> Decimal:
Decimal(.amount) / Decimal()
():
model_config = ConfigDict(
extra=,
str_strip_whitespace=,
populate_by_name=,
)
event_id: = Field(alias=, description=)
created: = Field(description=)
livemode: = Field(description=)
api_version: | =
() -> datetime:
datetime.fromtimestamp(.created, tz=timezone.utc)
():
: []
data: PaymentSucceededData
() -> :
(v.get(), ) v[]:
v = {**v, : v[][]}
v
():
: []
data: PaymentFailedData
() -> :
(v.get(), ) v[]:
v = {**v, : v[][]}
v
():
: []
data: RefundCreatedData
() -> :
(v.get(), ) v[]:
v = {**v, : v[][]}
v
StripeEvent = Annotated[
[PaymentSucceededEvent, PaymentFailedEvent, RefundCreatedEvent],
Field(discriminator=),
]
_stripe_event_adapter: TypeAdapter[StripeEvent] = TypeAdapter(StripeEvent)
():
model_config = ConfigDict(
str_strip_whitespace=,
validate_default=,
)
processing_id: = Field(description=)
stripe_event_id:
event_type:
amount_cents: |
currency: |
customer_id: |
failure_code: |
is_livemode:
event_occurred_at: datetime
received_at: datetime = Field(
default_factory=: datetime.now(tz=timezone.utc)
)
raw_type:
() -> :
cls(
processing_id=processing_id,
stripe_event_id=event.event_id,
event_type=,
amount_cents=event.data.amount_received,
currency=event.data.currency,
customer_id=event.data.customer_id,
failure_code=,
is_livemode=event.livemode,
event_occurred_at=event.created_at,
raw_type=event.,
)
() -> :
cls(
processing_id=processing_id,
stripe_event_id=event.event_id,
event_type=,
amount_cents=event.data.amount,
currency=event.data.currency,
customer_id=event.data.customer_id,
failure_code=event.data.failure_code,
is_livemode=event.livemode,
event_occurred_at=event.created_at,
raw_type=event.,
)
() -> :
cls(
processing_id=processing_id,
stripe_event_id=event.event_id,
event_type=,
amount_cents=event.data.amount,
currency=event.data.currency,
customer_id=,
failure_code=,
is_livemode=event.livemode,
event_occurred_at=event.created_at,
raw_type=event.,
)
uuid
pydantic ValidationError
() -> StoredEvent:
:
event = _stripe_event_adapter.validate_json(raw_bytes)
ValidationError e:
InvalidWebhookPayloadError(
,
errors=e.errors(),
) e
processing_id = (uuid.uuid4())
(event, PaymentSucceededEvent):
StoredEvent.from_payment_succeeded(