Merging#
How Merging Works#
graph TD
S1["Source 1"] --> R1["Read source data"]
S2["Source 2"] --> R2["Read source data"]
SN["Source N"] --> RN["Read source data"]
R1 --> SK1{"skip_invalid_fields?"}
SK1 -- yes --> D1["Drop fields that fail\ntype or constraint check"] --> M["Apply merge strategy\n(last_wins / first_wins / …)"]
SK1 -- no --> HV1{"Has field\nvalidators?"}
HV1 -- yes --> FP1["Validate fields\nprovided by this source"] --> M
HV1 -- no --> M
R2 --> SK2{"skip_invalid_fields?"}
SK2 -- yes --> D2["Drop fields that fail\ntype or constraint check"] --> M
SK2 -- no --> HV2{"Has field\nvalidators?"}
HV2 -- yes --> FP2["Validate fields\nprovided by this source"] --> M
HV2 -- no --> M
RN --> SKN{"skip_invalid_fields?"}
SKN -- yes --> DN["Drop fields that fail\ntype or constraint check"] --> M
SKN -- no --> HVN{"Has field\nvalidators?"}
HVN -- yes --> FPN["Validate fields\nprovided by this source"] --> M
HVN -- no --> M
M --> FGR["field_groups: enforce that related fields\nare all set by the same source"]
FGR --> FMR["field_merges: combine field values\nacross sources (e.g. merge lists)"]
FMR --> CONSTR["Construct dataclass\n+ run all validators\n(root_validators + Annotated on defaults)"]
CONSTR --> D["Dataclass"] When your config is spread across several sources — defaults in a file, overrides from the environment, and so on — dature loads each source independently, layers them by priority, and produces one validated instance. Crucially, each source is validated on its own contribution, so an override only needs to be valid for the fields it actually sets.
- Load and validate each source independently. Every source is read (with
expand_env_varssubstitution andmask_secrets/secret_field_namesmasking). Ifskip_invalid_fieldsis on, invalid values are dropped per source before field validation. Then each source's own values are coerced and run through field-level validation — type coercion,Annotatedconstraints, source-levelvalidators=. A source is judged only on the fields it provides, never on the merged whole. Missing or broken sources can be tolerated withskip_if_missing/skip_if_broken;first_foundtolerates them automatically. - Layer the sources together (
strategy). Sources are merged by the chosen strategy:last_wins(default — later sources override earlier),first_wins(earlier sources win),first_found(take the first that loads, ignore the rest), orraise_on_conflict(error if two sources disagree on a value). Nested sections merge key-by-key; lists and scalars are replaced wholesale according to the strategy. - Apply cross-source rules.
field_groups=enforce that related fields are always overridden together — never half from one source, half from another.field_merges=apply per-field aggregation (e.g. concatenating lists or picking the max) instead of a plain last/first-wins replacement. - Construct and validate dataclass. The merged values are assembled into the instance.
root_validators=run once on the finished object — checks that span several fields at once (e.g. "start must be before end"). Fields no source supplied take their dataclass defaults; anyAnnotatedconstraints on those defaults are still enforced. - Result. One typed, validated config — or a report of every problem, each tied to the source it came from.
Steps 4–5 are identical to single-source loading: multi-source is just single-source with a per-source load-and-validate step and a merge step in front. One source is the N=1 case.
Basic Merging#
Pass multiple Source objects to dature.load():
from dataclasses import dataclass
import dature
@dataclass
class Config:
host: str
port: int
tags: list[str]
config = dature.load(
dature.Yaml12Source(file=SHARED_DIR / "common_defaults.yaml"),
dature.Yaml12Source(file=SHARED_DIR / "common_overrides.yaml"),
schema=Config,
strategy="last_wins",
)
assert config.host == "production.example.com"
assert config.port == 8080
assert config.tags == ["web", "api"]
Multiple Sources#
Multiple sources use "last_wins" by default:
from dataclasses import dataclass
import dature
@dataclass
class Config:
host: str
port: int
tags: list[str]
config = dature.load(
dature.Yaml12Source(file=SHARED_DIR / "common_defaults.yaml"),
dature.Yaml12Source(file=SHARED_DIR / "common_overrides.yaml"),
schema=Config,
)
assert config.host == "production.example.com"
assert config.port == 8080
assert config.tags == ["web", "api"]
The decorator also uses `"last_wins"``:
import os
from dataclasses import dataclass
import dature
os.environ["APP_HOST"] = "env_localhost"
@dature.load(
dature.Yaml12Source(file=SHARED_DIR / "common_defaults.yaml"),
dature.EnvSource(prefix="APP_"),
)
@dataclass
class Config:
host: str
port: int
debug: bool = False
config = Config()
assert config.host == "env_localhost"
assert config.port == 3000
assert config.debug is False
Merge Strategies#
| Strategy | Behavior |
|---|---|
"last_wins" | Last source overrides (default) |
"first_wins" | First source wins |
"first_found" | Uses the first source that loads successfully, skips broken sources automatically |
"raise_on_conflict" | Raises MergeConflictError if the same key appears in multiple sources with different values |
Nested dicts are merged recursively. Lists and scalars are replaced entirely according to the strategy.
Last source overrides earlier ones. This is the default strategy.
from dataclasses import dataclass
import dature
@dataclass
class Config:
host: str
port: int
tags: list[str]
config = dature.load(
dature.Yaml12Source(file=SHARED_DIR / "common_defaults.yaml"),
dature.Yaml12Source(file=SHARED_DIR / "common_overrides.yaml"),
schema=Config,
strategy="last_wins",
)
assert config.host == "production.example.com"
assert config.port == 8080
assert config.tags == ["web", "api"]
First source wins on conflict. Later sources only fill in missing keys.
from dataclasses import dataclass
import dature
@dataclass
class Config:
host: str
port: int
tags: list[str]
config = dature.load(
dature.Yaml12Source(file=SHARED_DIR / "common_defaults.yaml"),
dature.Yaml12Source(file=SHARED_DIR / "common_overrides.yaml"),
schema=Config,
strategy="first_wins",
)
assert config.host == "localhost"
assert config.port == 3000
assert config.tags == ["default"]
Uses the first source that loads successfully and ignores the rest. Broken sources (missing file, parse error) are skipped automatically — no skip_if_broken needed. Type errors (wrong type, missing field) are not skipped.
from dataclasses import dataclass
import dature
@dataclass
class Config:
host: str
port: int
tags: list[str]
config = dature.load(
dature.Yaml12Source(file=SHARED_DIR / "nonexistent.yaml"),
dature.Yaml12Source(file=SHARED_DIR / "common_defaults.yaml"),
dature.Yaml12Source(file=SHARED_DIR / "common_overrides.yaml"),
schema=Config,
strategy="first_found",
)
assert config.host == "localhost"
assert config.port == 3000
assert config.tags == ["default"]
Raises MergeConflictError if the same key appears in multiple sources with different values. Works best when sources have disjoint keys.
from dataclasses import dataclass
import dature
@dataclass
class Config:
host: str
port: int
debug: bool
config = dature.load(
dature.Yaml12Source(file=SHARED_DIR / "common_raise_on_conflict_a.yaml"),
dature.Yaml12Source(file=SHARED_DIR / "common_raise_on_conflict_b.yaml"),
schema=Config,
strategy="raise_on_conflict",
)
assert config.host == "localhost"
assert config.port == 3000
assert config.debug is True
strategy is not limited to the names above — any object implementing the SourceMergeStrategy Protocol is accepted, so you can plug in your own merge logic (e.g. let env sources override files unconditionally) while still composing the built-in strategies. See Custom Source Strategy.
For per-field strategy overrides, see Per-Field Merge Strategies. To enforce that related fields are always overridden together, see Field Groups.
Merge Parameters#
All merge-related parameters are passed directly to dature.load() as keyword arguments:
| Parameter | Description |
|---|---|
strategy | Global merge strategy. Default: "last_wins". See Merge Strategies |
field_merges | Per-field merge strategy overrides. See Per-Field Merge Strategies |
field_groups | Enforce related fields are overridden together. See Field Groups |
skip_if_broken | Skip sources that fail to parse (invalid syntax, config error). See Skipping Sources with Parse Errors |
skip_if_missing | Skip sources whose file does not exist. See Skipping Missing Sources |
skip_invalid_fields | Drop fields with invalid values. See Skipping Invalid Fields |
expand_env_vars | ENV variable expansion mode. See ENV Expansion |
secret_field_names | Extra secret name patterns for masking. See Masking |
mask_secrets | Enable/disable secret masking for all sources. See Masking |
nested_resolve_strategy | Default priority when both JSON and flat keys exist: "flat" (default) or "json". Applies to all sources. See Nested Resolve |
nested_resolve | Default per-field strategy overrides for all sources. See Nested Resolve |