DEV Community

Cover image for dature: type-safe configuration through plain dataclasses
Nikolai Vidov
Nikolai Vidov

Posted on Edited on

dature: type-safe configuration through plain dataclasses

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 overview

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
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)$")]
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_string are masked. The list is configurable via MaskingConfig.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 optional random_string_detector dependency; 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
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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)]
Enter fullscreen mode Exit fullscreen mode

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.

Summary

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

Libraries reviewed:

Serialization libraries:

Top comments (0)