Complex Config Example¶
This page uses a larger config to show why schema versioning matters. The example is intentionally config-shaped: nested Pydantic models, lists, defaults, renamed fields, removed fields, and version metadata stored outside the core model.
A Plain Pydantic Config¶
Imagine a deployment tool that reads YAML and validates it with Pydantic:
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class ResourceLimits(BaseModel):
cpu: str = "500m"
memory: str = "512Mi"
class RetryPolicy(BaseModel):
attempts: int = 3
backoff_seconds: float = 1.0
class WorkerConfig(BaseModel):
name: str
image: str
replicas: int = 1
resources: ResourceLimits = Field(default_factory=ResourceLimits)
retry: RetryPolicy = Field(default_factory=RetryPolicy)
class TelemetryConfig(BaseModel):
enabled: bool = True
exporter: Literal["none", "otlp", "prometheus"] = "none"
class PipelineConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
service_name: str
workers: list[WorkerConfig]
telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig)
A user might have this config checked into Git:
schema_version: "1"
service_name: invoice-ingest
workers:
- name: parser
image: ghcr.io/example/parser:1.0
replicas: 2
retry:
attempts: 5
telemetry:
enabled: false
This works until the application evolves.
Why Plain Model Changes Are Fragile¶
Now imagine the software changes:
service_nameis renamed toname;retry.attemptsis renamed toretry.max_attempts;- default retry backoff changes from
1.0to2.0; - telemetry is moved under a new
observabilityfield; - each worker gets a new
queuefield with default"default".
If you simply edit the Pydantic models, the old YAML may fail validation or load with subtly different behavior. That creates a bad operational choice:
- pin the old software version forever for old configs;
- update every config immediately during a software rollout;
- add one-off compatibility code around each loader.
pydantic-versions is meant to keep that compatibility policy next to the model
definition instead.
A Versioned Current Model¶
The decorated model is the current schema. Historical versions are generated from it with patches:
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic_versions import (
field_default,
field_removed,
field_renamed,
migration,
schema_version,
validate_versioned,
versioned_schema,
)
class ResourceLimits(BaseModel):
cpu: str = "500m"
memory: str = "512Mi"
class RetryPolicy(BaseModel):
max_attempts: int = 3
backoff_seconds: float = 2.0
class WorkerConfig(BaseModel):
name: str
image: str
replicas: int = 1
queue: str = "default"
resources: ResourceLimits = Field(default_factory=ResourceLimits)
retry: RetryPolicy = Field(default_factory=RetryPolicy)
class ObservabilityConfig(BaseModel):
enabled: bool = True
exporter: Literal["none", "otlp", "prometheus"] = "none"
@versioned_schema(
name="pipeline_config",
versions=["1", "2"],
current="2",
version_field="schema_version",
)
@schema_version(
"1",
patches=[
field_renamed("name", "service_name"),
field_renamed("observability", "telemetry"),
],
)
class PipelineConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
workers: list[WorkerConfig]
observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig)
The top-level v1 field names are now understood:
result = validate_versioned(
PipelineConfig,
{
"schema_version": "1",
"service_name": "invoice-ingest",
"workers": [
{
"name": "parser",
"image": "ghcr.io/example/parser:1.0",
"replicas": 2,
}
],
"telemetry": {"enabled": False},
},
)
assert result.source_version == "1"
assert result.current_version == "2"
assert result.current_model.name == "invoice-ingest"
assert result.current_model.observability.enabled is False
Nested Schema Changes¶
Nested models can be versioned too. If RetryPolicy changed between schema
versions, decorate it separately:
@versioned_schema(name="retry_policy", versions=["1", "2"], current="2")
@schema_version(
"1",
patches=[
field_renamed("max_attempts", "attempts"),
field_default("backoff_seconds", 1.0),
],
)
class RetryPolicy(BaseModel):
max_attempts: int = 3
backoff_seconds: float = 2.0
Then any versioned model that references RetryPolicy can generate a matching
historical nested schema for list[WorkerConfig], dict[str, RetryPolicy],
tuples, sets, and optional nested model fields where applicable.
When Patches Are Not Enough¶
Patches handle field-level compatibility. Use migrations for value-level or shape-level changes that need code.
In this example, v1 had a telemetry.enabled: false value. In v2, the product
decides that disabled telemetry should also force the exporter to "none":
@migration(PipelineConfig, "1", "2")
def migrate_pipeline_v1_to_v2(data: dict) -> dict:
observability = data.setdefault("observability", {})
if observability.get("enabled") is False:
observability["exporter"] = "none"
return data
Migration functions receive dictionaries using current field names. They run after source-version validation and historical rename mapping, then the result is validated again with the current model.
Version Metadata Outside The Model¶
Some formats keep version metadata in wrapper fields. Kubernetes-style resources
often use apiVersion; other configs may store version information under
metadata.
Use a top-level custom field:
@versioned_schema(
name="pipeline_crd",
versions=["example.com/v1", "example.com/v2"],
current="example.com/v2",
version_field="apiVersion",
)
class PipelineResource(BaseModel):
kind: Literal["Pipeline"]
spec: PipelineConfig
Or a nested path:
@versioned_schema(
name="pipeline_document",
versions=["1", "2"],
current="2",
version_field=("metadata", "schema_version"),
)
class PipelineDocument(BaseModel):
spec: PipelineConfig
For nested paths, the metadata wrapper does not have to be part of the Pydantic model. The version field is read before validation and removed before the source model is validated.
Rendering Older Configs¶
You can render the old shape for examples, generated starter configs, or compatibility output:
from pydantic_versions import dump_versioned
v1_example = dump_versioned(
PipelineConfig,
version="1",
data=PipelineConfig(
name="invoice-ingest",
workers=[
WorkerConfig(
name="parser",
image="ghcr.io/example/parser:2.0",
retry=RetryPolicy(max_attempts=5),
)
],
),
)
assert "service_name" in v1_example
assert "telemetry" in v1_example
assert "name" not in v1_example
This makes the compatibility contract explicit: the latest software can keep validating and rendering old config schemas without pretending that schema versions and software versions are the same thing.