A deployment complains that port in production is 9090, while defaults.yaml says 8080. Was it override.yaml, an environment variable, or a Kubernetes ConfigMap? Without the right tooling, finding out can eat half a working day.
I've been writing Python since 2017, and I've worked with configuration in every form: INI files in legacy projects, environment variables in Docker containers, YAML with templates in Helm, pydantic models in fresh services, Vault and Consul in bigger infrastructure.
In the previous two articles I traced how we got to the way we handle configuration today and why no existing Python library covers all of it, which left me and my team unsatisfied. This one is about what I built in response.
TL;DR
- dature loads config from many sources into your own stdlib
@dataclass, with no base class and no framework. - Every value remembers where it came from, so errors name the file and line, and a debug report shows each field's origin.
- Merging is configurable per field, and sources can be conditional (
when=) or depend on each other. - It's not a replacement for everything: if you need tight FastAPI dependency injection, pydantic-settings is a more direct path.
From pains to a spec
Six pains became the specification:
- merging individual fields from different sources;
- no way to tell where an invalid value came from;
- validators wanted both inside the type and outside it;
- secret masking doesn't work everywhere;
- adding your own source should be easy;
- it must be a library, not a framework.
My library, dature, grew out of these. If tight integration with FastAPI dependency injection is critical for you, pydantic-settings gives a more direct path. dature has its own niche; it doesn't displace everything else, it closes specific pains that my team and I ran into.
dature's philosophy
dature is a library. Its only job is to load data from arbitrary sources into a standard-library @dataclass and return a ready object. It doesn't capture your entry point, doesn't change the working directory, doesn't manage logging.
The dataclass is the single source of truth. Type hints describe the structure, the types, and the constraints. There's no need to duplicate definitions in YAML files, separate validators, or Pydantic models.
from dataclasses import dataclass
import dature
@dataclass
class Config:
host: str
port: int
debug: bool = False
config = dature.load(dature.Yaml12Source(file="config.yaml"), schema=Config)
# isinstance(config, Config) → True
# Full IDE and mypy support, __post_init__ works
Loading from any source while keeping provenance
The main idea of dature is not to lose a value's origin after parsing. For every field it's known which source and which line it came from, and that is available both in validation errors and in the debug report.
The list of supported sources is, as you'd expect, broad:
- eight file formats with auto-detection by extension: YAML 1.1/1.2, JSON, JSON5, TOML 1.0/1.1, INI,
.env; - environment variables;
- Docker secrets;
- Vault, Consul, etcd, ZooKeeper, AWS SSM, AWS Secrets Manager, Azure App Configuration, Azure Key Vault, GCP Secret Manager;
- argparse CLI;
- a custom CLI source.
One call, several sources:
from dataclasses import dataclass
import dature
@dataclass
class Config:
host: str
port: int
secret_key: str
debug: bool = False
config = dature.load(
dature.Yaml12Source(file="defaults.yaml"),
dature.Toml11Source(file="config.toml", skip_if_missing=True),
dature.EnvFileSource(file=".env", skip_if_broken=True),
dature.EnvSource(prefix="APP_"), # environment variables with the APP_ prefix
schema=Config,
)
skip_if_broken=True means that if a file is invalid, the source is silently skipped. skip_if_missing=True means that if a file doesn't exist, the source is silently skipped. This lets you describe the chain "defaults → local overrides → environment variables" without checking for file existence by hand.
The format is detected automatically from the extension. If you need a non-standard loader, you write your own Source.
Merge strategies
Four global strategies decide how to resolve conflicts between sources:
-
last_wins: the last source overrides the earlier ones (the default); -
first_wins: the first source takes priority; -
first_found: take the first source that isn't broken and ignore the rest; -
raise_on_conflict: an error if two sources set different values.
You can set separate rules for individual fields. For example, a list of servers from different sources should be combined rather than replaced:
from dataclasses import dataclass
import dature
@dataclass
class Config:
servers: list[str]
port: int
config = dature.load(
dature.Yaml12Source(file="defaults.yaml"),
dature.Yaml12Source(file="local.yaml", skip_if_broken=True),
dature.EnvSource(prefix="APP_"),
field_merges={dature.F[Config].servers: "append_unique"},
schema=Config,
)
Field groups let you enforce consistency: if one field of a group is overridden in a new source, the rest must be overridden too:
import dature
dature.load(
dature.Yaml12Source(file="defaults.yaml"),
dature.Yaml12Source(file="override.yaml"),
field_groups=((dature.F[Config].db_host, dature.F[Config].db_port, dature.F[Config].db_name),),
schema=Config,
)
# If override.yaml changes db_host but not db_port → error
Validation
dature uses Annotated for built-in validators:
from dataclasses import dataclass
from typing import Annotated
import dature
@dataclass
class Config:
port: Annotated[int, (dature.V >= 1) & (dature.V <= 65535)]
host: Annotated[str, dature.V.len() >= 1]
log_level: Annotated[str, dature.V.matches(r"^(DEBUG|INFO|WARNING|ERROR)$")]
The validator lives right in the field's type, and that matters. When you add a new field to the dataclass, you physically can't forget its constraints: they're part of the signature. Validators can also be supplied separately, through a Source, when you want to reuse one schema with different rules or move the checks into another module:
import dature
config = dature.load(
dature.Yaml12Source(
file="config.yaml",
validators={
dature.F[Config].port: (dature.V >= 1) & (dature.V <= 65535),
},
),
schema=Config,
)
For checks across fields there are root validators:
import dature
def check_ssl(config: Config) -> bool:
return not config.use_ssl or config.cert_path is not None
config = dature.load(
dature.Yaml12Source(
file="config.yaml",
root_validators=(dature.V.root(func=check_ssl, error_message="cert_path required when use_ssl is True"),),
),
schema=Config,
)
The standard __post_init__ works too, because dature returns an ordinary dataclass. When validation fails, the error points at the exact place:
Config loading errors (1)
[port] Must be greater than 0
├── port: -1
│ ^^
└── FILE 'config.yaml', line 2
The message has everything you need to fix it in a minute: the file path, the line number, the value itself, and carets under the offending piece. That's the difference that's worth an hour of debugging in production logs.
Secrets and debugging
Fields of type SecretStr are masked automatically in errors and on output:
from dataclasses import dataclass
import dature
from dature.fields import SecretStr
@dataclass
class Config:
database_url: str
api_key: SecretStr
config = dature.load(dature.Yaml12Source(file="config.yaml"), schema=Config)
print(config.api_key) # SecretStr('**********')
print(config.api_key.get_secret_value()) # the real value
Masking works on three levels:
-
By type:
SecretStr,PaymentCardNumber. -
By field name: by default, fields containing the substrings
password,passwd,secret,token,key,auth,credential,uri,url,connection_stringare masked. The list is configurable viaMaskingConfig.secret_field_names. -
By heuristic: bigram analysis. The string is lowercased and split into bigrams, and the share of rare bigrams is compared against a threshold (
heuristic_threshold, 0.5 by default). If more than half are rare, the string is considered a randomly generated token and is masked. The minimum string length for analysis is 8 characters (min_heuristic_length). The heuristic requires the optionalrandom_string_detectordependency; without it, only the first two levels work.
Debug mode shows where each value came from:
import dature
config = dature.load(
dature.Yaml12Source(file="defaults.yaml"),
dature.EnvSource(prefix="APP_"),
schema=Config,
debug=True,
masking_mode="secrets_only", # so ordinary values appear in the report
)
report = dature.load_report(config)
for origin in report.field_origins:
print(f"{origin.key} = {origin.value} ← {origin.source_file or origin.source_loader_type}")
# host = 'localhost' ← defaults.yaml
# port = 9090 ← env
# debug = False ← defaults.yaml
Debug mode is especially useful when a config is assembled from several sources and it's unclear which one dominates a given field: debug=True shows each value's path from source to final object.
Custom fields and sources
dature works with your own types and formats. Registering a loader for a new type:
from dataclasses import dataclass
import dature
@dataclass(frozen=True, slots=True)
class Rgb:
r: int
g: int
b: int
def rgb_from_string(value: str) -> Rgb:
parts = value.split(",")
return Rgb(r=int(parts[0]), g=int(parts[1]), b=int(parts[2]))
@dataclass
class AppConfig:
name: str
color: Rgb
config = dature.load(
dature.Yaml12Source(
file=SOURCES_DIR / "custom_type_common.yaml",
type_loaders={Rgb: rgb_from_string}, # applies only to this file source
),
schema=AppConfig,
)
A custom format is a subclass of FileSource:
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
import dature
from adaptix import Provider, loader
from dature.loaders import bool_loader, float_from_string
from dature.sources.base import FileSource
from dature.types import FileOrStream, JSONValue
@dataclass(kw_only=True, repr=False)
class XmlSource(FileSource):
format_name = "xml"
def _load_file(self, path: FileOrStream) -> JSONValue:
if not isinstance(path, Path):
msg = "XmlSource only supports file paths"
raise TypeError(msg)
tree = ET.parse(path) # noqa: S314
root = tree.getroot()
return {child.tag: child.text or "" for child in root}
def format_loaders(self) -> list[Provider]:
return [
loader(bool, bool_loader),
loader(float, float_from_string),
]
@dataclass
class Config:
host: str
port: int
debug: bool
config = dature.load(
XmlSource(file="custom_loader.xml"),
schema=Config,
)
Real-world scenarios where dature helps
So the features don't hang in the air, here are several typical scenarios. In these situations you normally end up writing a lot of boilerplate, while with dature we get by in a few lines.
Scenario: merge lists, replace everything else. An application config: common defaults for CORS origins, plus extra ones for each environment. The behavior you want: lists are combined, while database.host is replaced by source priority.
Before, you had to write a custom SettingsSource in pydantic-settings, override _field_is_complex, and check that deep-merge is enabled for the specific field. That came to twenty lines of infrastructure code for a single business scenario.
With dature it's one field_merges line:
config = dature.load(
dature.Yaml12Source(file="defaults.yaml"),
dature.Yaml12Source(file=f"config.{env}.yaml", skip_if_broken=True),
dature.EnvSource(prefix="APP_"),
field_merges={
dature.F[Config].cors_origins: "append_unique",
},
schema=Config,
)
Everything else is last_wins by default, and no custom source class is needed.
Scenario: the config itself answers where each field came from. The classic production detective story. A deployment complains that port in production is 9090 while defaults.yaml says 8080. You need to quickly find the override's source: override.yaml, environment variables, or a Kubernetes ConfigMap. So the config should not only hold the final value but also show its origin.
Without a suitable tool, the detective work starts with a trip into the container: env | grep APP_, check mounted files, reread docker-compose. Understanding the origin of a single value can sometimes eat half a working day.
With dature:
config = dature.load(
dature.Yaml12Source(file="defaults.yaml"),
dature.Yaml12Source(file="override.yaml", skip_if_broken=True),
dature.EnvSource(prefix="APP_"),
schema=Config,
debug=True,
)
report = dature.load_report(config)
for origin in report.field_origins:
print(f"{origin.key} = {origin.value} ← {origin.source_file or origin.source_loader_type}")
The output immediately shows port = 9090 ← env. The value came from an environment variable, and the case is closed in a minute rather than half a day of digging through files in a container. It's especially useful in CI: you can run load(..., debug=True) as part of a test stage and verify where each field is supposed to come from in production.
Scenario: a custom source for an internal configuration service. A large company has its own configuration service: a REST endpoint that returns settings for the requesting module. Nobody is going to replace it with "ordinary Vault," and without dature the options are to load it manually and glue it to the other sources by hand, or to write a custom SettingsSource in pydantic-settings with all that entails.
With dature, a custom source is a subclass of Source with one _load method:
from dataclasses import dataclass
import requests
import dature
from dature.sources.base import Source
from dature.types import JSONValue
@dataclass(kw_only=True, repr=False)
class InternalConfigApiSource(Source):
service: str
def _load(self) -> JSONValue:
response = requests.get(
f"https://config.internal/api/v1/{self.service}",
timeout=5,
)
response.raise_for_status()
return response.json()
config = dature.load(
dature.Yaml12Source(file="defaults.yaml"),
InternalConfigApiSource(service="payments"),
dature.EnvSource(prefix="APP_"),
schema=Config,
)
No custom error pipeline, no merge strategies, no secret masking of your own. The custom source slots into the common chain and gets everything else automatically.
Scenario: consistent changes to related fields. A team is moving to a staging environment, and all the DB parameters change at once: db_host, db_port, db_name, db_user. You want a guarantee: either staging.yaml overrides all four, or loading fails with a clear error. No half-migrations.
field_groups does exactly that:
config = dature.load(
dature.Yaml12Source(file="defaults.yaml"),
dature.Yaml12Source(file="staging.yaml", skip_if_broken=True),
field_groups=(
(
dature.F[Config].db_host,
dature.F[Config].db_port,
dature.F[Config].db_name,
dature.F[Config].db_user,
),
),
schema=Config,
)
# If staging.yaml changes db_host but not db_port → an error naming
# the group and which field stayed from defaults.yaml
The error is caught at load time, not at the moment of the first connection to someone else's database.
Scenario: dynamic configuration without a restart. A long-lived service has request limits in YAML, and you want to change them on the fly without a restart. Re-reading on every request is expensive; re-reading by hand is unreliable.
cache=timedelta(...) solves it in one line: every class that goes through a shared Loader is invalidated at the same moment, and the next call gets fresh values:
from datetime import timedelta
import dature
loader = dature.Loader(
dature.Yaml12Source(file="config.yaml"),
schema=Config,
cache=timedelta(seconds=30),
)
# Anywhere in the code
config = loader.load() # within one bucket, a single real re-parse
Between "re-read on every call" and "once at startup" there is now a middle ground, and its cost is one parameter.
Scenario: the config path comes from outside. A container doesn't know where its config lives: the path is mounted at release time and passed through an environment variable. Sometimes a Vault token or the active profile name comes along too. You get a two-stage load: first read the ENV, then build the arguments for the main sources from those values.
This is usually written by hand: os.environ.get, a check for absence, path concatenation, and only then the loader call. With more than two sources, imperative glue appears between them.
With dature, sources refer to each other via ${@tag.key} right in their arguments:
config = dature.load(
dature.JsonSource(file="${@env.config_path}"),
dature.VaultSource(
path="secret/app",
token="${@env.VAULT_TOKEN}",
),
dature.EnvSource(prefix="APP_"),
schema=Config,
)
The order of sources in load() doesn't matter: dature builds the dependency graph itself and loads them in the right order. Cycles, missing tags, and unresolvable references are caught before the first I/O.
Scenario: the same load() in dev and prod. In production, secrets come from Vault; in dev, from a local .env file, and Vault isn't even reachable. You want the config-loading code to be identical in both environments, not smeared with if ENV == "prod".
Without a suitable tool you get either two different loading functions, or a try/except around VaultSource in the hope that in dev it will simply fail. The second is especially unpleasant: the source still tries to hit the network and waits for a timeout before giving up.
With dature, each source gets a when= condition, and a disabled source isn't opened at all:
config = dature.load(
dature.VaultSource(
tag="secrets",
path="secret/app",
token="${VAULT_TOKEN}",
when=dature.When("${APP_ENV}") == "prod",
),
dature.EnvFileSource(
tag="secrets",
file="vault_dev.env",
when=dature.When("${APP_ENV}").in_("dev", "local"),
),
schema=Config,
)
Both sources live under the same tag="secrets", and the conditions are mutually exclusive, so only one is active. In prod, VaultSource goes to Vault; in dev it doesn't even try, so no network timeouts at startup. If both conditions accidentally overlap, dature fails at construction time, before any I/O.
Why it's built this way
Several decisions in dature aren't obvious at first glance and deserve an explanation. If you want to use the library seriously, it helps to understand why.
Why a stdlib @dataclass and not a custom class. Pydantic chose BaseModel. attrs chose its own AttrsInstance. Each has its own ecosystem, its own conventions, and its own virality: when the base class starts leaking into user code, you can't detach from it without refactoring. BaseModel, for example, brings model_validate, model_dump, a whole meta-infrastructure. It's convenient while you're inside the Pydantic ecosystem, but gets in the way when you try to use your schema in someone else's context.
The stdlib @dataclass is Python's common denominator since 3.7. Everything works with it: mypy, IDEs, ORMs (SQLAlchemy 2.0 supports dataclass-style model declarations), FastAPI (usable as request/response models), serialization libraries, test frameworks. By returning a dataclass, dature doesn't force a new data-modeling paradigm into your project.
The price of this choice: a dataclass is poorer in features. No Field(alias=...), no built-in validators, no model_dump. dature makes up for this on top: Annotated validators replace class-method decorators, and mapping source keys to fields is done in the Source (not in the schema itself).
Why adaptix, and not cattrs or msgspec. Type conversion is a separate hard problem. When YAML gives "true" and the dataclass field is bool, somebody has to coerce it. When "2024-01-15" goes into a datetime field, same thing. When a nested dictionary goes into a nested dataclass, that's already recursion with handling of optional fields and defaults.
You can write your own, but that means rewriting functionality that adaptix, cattrs, and msgspec already provide. cattrs also handles dataclasses, but adaptix fit dature's task better thanks to its retort + provider-recipe model. Each provider overrides one aspect of loading, and configuration can be attached not to a whole class but to specific fields and types.
For full validation, msgspec requires inheriting from msgspec.Struct, which contradicts dature's idea of "a stdlib dataclass as the single source of truth." And msgspec's dec_hook is noticeably poorer than the provider model when you need different rules for the same type in different fields. An extra bonus of adaptix is no auto-casting by default: the loader doesn't try to guess a value from a variety of input formats, and for configs that's the right behavior (an explicit error beats a silently coerced "yes" → True).
One mandatory dependency is a compromise. dature is not dependency-free like python-decouple. But it's a dependency that does its job and doesn't drag in pydantic-core (a compiled Rust extension).
Why Annotated validators are the main path. The Annotated style isn't unique anymore: pydantic-settings has long supported Annotated[int, Ge(1), Le(65535)] alongside the familiar @field_validator. But in dature it's the primary path for schema-bound validation. The validator lives in the field annotation itself:
@dataclass
class Config:
port: Annotated[int, (V >= 1) & (V <= 65535)]
The rule stays next to the field, but isn't locked inside a particular class. Two consequences follow. First, a typed preset Port = Annotated[int, (V >= 1) & (V <= 65535)] can live in a shared module and be imported into any schema. Second, the V-DSL composes through &, |, ~ and has shortcuts for collections (V.each(...), V.unique_items()), so complex predicates are assembled without decorators on top of a class.
And if the dataclass came from someone else's package and you can't touch it, validators are passed as the validators={F[Config].port: ...} parameter of a Source, through the same typed field path, without falling back to strings. That covers the foreign-dataclass scenario: you can add validation from outside without changing the class, writing a wrapper, or referencing fields by string.
The downside: people who've long relied on @field_validator will look for the familiar class method at first.
Why good error messages are the library's top priority. Most libraries return ValidationError: port should be valid integer without saying which source failed. dature deliberately carries the node coordinates (line, col) through the entire pipeline, from the YAML/TOML/JSON parser to the point where the final error message is built.
Preserving a specific value's coordinates from the parser to the final error message is technically nontrivial. Standard parsers (yaml.safe_load, json.load) don't preserve coordinates; you have to use an AST mode (ruamel.yaml, tomlkit), and its API isn't as convenient as safe_load. Then those coordinates must be carried through every transformation stage: from the dictionary to the dataclass, through validation, into the final error message.
The implementation cost is high, which is why almost nobody does it. And that's exactly where dature gets its competitive edge: not in yet another merge strategy or a new format, but in error UX. The debugging time saved in production is the value that makes trying a new configuration library worthwhile.
A feature comparison table.
| Parameter | python-decouple | Dynaconf | pydantic-settings | Hydra | dature |
|---|---|---|---|---|---|
| Schema in code | No | No | Pydantic model | YAML + dataclass | stdlib dataclass |
| Result | Individual variables | Dynaconf (dict-like) |
BaseSettings (Pydantic) |
DictConfig (OmegaConf) |
Your @dataclass
|
| Formats | .env, .ini, env vars | YAML, TOML, JSON, INI, .env, Python | .env, env vars, JSON, YAML, TOML | YAML | YAML, JSON, JSON5, TOML, INI, .env, env vars, Docker secrets |
| Source merging | No | Layers + dynaconf_merge
|
Fixed priority | Defaults list | 4 built-in strategies + custom ones + per-field rules |
| Type safety | No | No | Yes (Pydantic) | Partial (OmegaConf) | Yes (dataclass + adaptix) |
| Validation | cast |
Separate Validators |
Pydantic validators | Types only |
Annotated and/or separate validators + root validators |
| Errors with coordinates | No | No | No | No | Yes |
| Secret masking | No | No | SecretStr |
No | Yes (type, name, heuristic) |
| Source auditing | No | inspect_settings |
No | Output dir |
debug=True (per-field source) |
| ENV expansion | No |
@format, @jinja
|
No | ${oc.env:VAR} |
${VAR:-default} |
| CLI | No | CLI utilities | CliSettingsSource |
CLI overrides |
ArgparseSource, CliSource
|
| Remote sources | No | Vault, Redis | No | No | Vault, GCP, Azure, and more |
| IDE / mypy | No | No | Yes | Partial | Yes |
| Dependencies | None | None | pydantic (Rust core) | hydra-core, omegaconf | adaptix |
If you don't have the requirement in the left column, the corresponding cell doesn't matter. A microservice, an ML experiment, and a CLI utility need different rows.
In short: dature is the only one here that returns your own stdlib dataclass, reports errors with file and line, and offers per-field merge rules. In exchange, it has no deep FastAPI integration, and its ecosystem is much smaller than pydantic's.
What may surprise you when migrating from pydantic-settings
Most people who look at dature have hit the ceiling of pydantic-settings. Here's a short list of places you might trip when moving.
Loading logic lives in load(...), not in the schema. The ENV prefix, the path to .env, the chain of sources: all of these are parameters of Source objects and arguments of load(...), not model_config in the class.
Validators go through Annotated and validators={...}. There are no @field_validator/@model_validator-style method decorators. Checks that need several fields are passed via the root_validators=(...) parameter of a Source.
Coercion is stricter. adaptix doesn't silently turn "true" into True. On YAML/TOML you won't notice; on ENV it may require explicit loaders from dature.loaders.
Optional[X] doesn't make a field optional automatically. That's a trait of stdlib dataclasses: you have to write = None by hand.
SecretStr is dature's own class, not pydantic.SecretStr. The API matches, but they're two different types.
model_dump() needs your own helper. dature returns an ordinary dataclass. dataclasses.asdict() doesn't know about SecretStr and will leak the secret into the dump. repr(config) is safe for logging.
Most of these are different design choices rather than "dature is missing something." If they don't fit your way of thinking, that's a normal signal that pydantic-settings suits you better.
Conclusion
The layout is the same as in the previous article: for scripts, python-decouple is enough; for dynamic setups without a schema, Dynaconf; if Pydantic is already in your project, pydantic-settings; for ML sweeps, Hydra for as long as it stays alive.
dature is for where none of these fits cleanly. Most often that's a long-lived service whose config is assembled from several sources, and one day you spend half a day on the question "where did this port=9090 even come from?"
If you recognize the scenario, I invite you to try it. Some of the pains are only half closed so far; if you run into something new that isn't there, file an issue. Those issues are how I decide what to do next.
How do you handle config merging today: pydantic-settings, hand-written glue, something else? Tell me in the comments.
Useful links
- Part 1: The evolution of configuration approaches
- Part 2: Why no Python configuration library covers everything
Libraries reviewed:
Serialization libraries:


Top comments (0)