DEV Community

Cover image for Why no Python configuration library covers everything
Nikolai Vidov
Nikolai Vidov

Posted on

Why no Python configuration library covers everything

Pick any popular Python configuration library and you'll find that it does one or two things very well and quietly ignores the rest. That's not a flaw in the libraries; they're built for different jobs. The trouble starts when your job doesn't match any of them.

I've been writing Python since 2017, and in that time 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.

This is the second article in a series. The first one traced how the industry arrived at modern approaches to configuration management, and ended with seven requirements for a production-grade config tool: many sources, controlled merging, type safety, value validation, clear errors, secrets at the schema level, and debugging.

Here I go through four notable tools: python-decouple, Dynaconf, Hydra, and pydantic-settings. I'll check how each one measures up against those seven requirements, then pull everything into one view: who covers what, and where the tools have an obvious hole.

TL;DR

  • python-decouple is fine for scripts; Dynaconf wins on sources but has no schema; Hydra is built for ML experiments; pydantic-settings is the best default if Pydantic is already in your project.
  • Type safety and validation are well covered (pydantic-settings), and so is source variety (Dynaconf).
  • Nobody solves controlled merging, error coordinates (file and line), or source auditing out of the box.
  • Those gaps are what pushed me to write my own library, which is the subject of Part 3.

python-decouple

python-decouple solves a narrow problem: it reads specific setting values from external sources. It's essentially one function, config(), that knows three sources (.env, .ini, environment variables) and can cast types:

from decouple import config


DEBUG = config("DEBUG", default=False, cast=bool)
DATABASE_URL = config("DATABASE_URL")
PORT = config("PORT", cast=int)
Enter fullscreen mode Exit fullscreen mode

Source lookup is transparent: the library first looks for settings.ini, then .env, then falls back to os.environ. That's handy when you already have a legacy .ini from an old project and want to migrate to .env gradually instead of rewriting everything at once. Through cast you can pass any converter function: int, float, bool, or your own (cast=lambda v: v.split(",") for lists). For the common list case there's a ready-made Csv() helper, and bool understands "yes"/"no", "1"/"0", and "true"/"false" out of the box. That's where the list of sources ends: no YAML, no TOML, no Docker secrets.

Value validation can formally be bolted on through the same cast: a converter can not only coerce a type but also check that a port is in 1-65535 and a URL starts with http. But that's manual work for each field, not validation as part of a single schema.

Historically, python-decouple grew up as a replacement for os.environ.get() in Django projects. That's still its main audience: Django tutorials happily recommend it, and for a project with 5-10 settings it's enough. But as soon as the config grows beyond that, the limitations show up all at once. The schema isn't declared in one place; config() calls are scattered across imports:

# settings/database.py
DATABASE_URL = config("DATABASE_URL")


# settings/cache.py
REDIS_URL = config("REDIS_URL", default="redis://localhost:6379/0")


# settings/auth.py
JWT_SECRET = config("JWT_SECRET")
JWT_EXPIRES = config("JWT_EXPIRES", default=3600, cast=int)
Enter fullscreen mode Exit fullscreen mode

The only way to find the full list of variables the app expects is grep config( across the whole repository. There's no schema-as-contract in one place, and therefore no type safety: the IDE won't suggest a field, mypy won't catch a typo, and the mistake surfaces at runtime as an AttributeError in production, which is exactly what a startup check is meant to avoid.

There are no nested structures at all: for database.primary.host you have to write out a flat DATABASE_PRIMARY_HOST and assemble the structure by hand. Merging several sources isn't supported either: the library reads one source of truth. python-decouple can't override values, for example set defaults in .ini and then overlay them from .env. So conflict-resolution strategies (last wins, first wins, fail with an error) aren't even a topic here.

The rest of the checklist is missing too. A missing variable raises UndefinedValueError with the key name but without file and line. A secret like JWT_SECRET stays a plain string without a SecretStr-like type and happily shows up in repr(). And there's no standard way to find out where a value came from: .ini, .env, or the environment.

I keep python-decouple in mind for one-off scripts and small utilities where the config is five variables and a single .env file. Once a project grows nested settings or environments more complicated than "local/prod," you usually end up migrating to something else. That's fine: python-decouple honestly does what it promises and doesn't claim more. Comparing it with the other participants only makes sense within its own genre: simple scripts.

Dynaconf

Dynaconf bets on flexibility and runtime configuration. It reads YAML, TOML, JSON, INI, .env, and Python files, connects Vault and Redis via plugins, and supports dynamic reloading. In terms of sources it's the richest participant in this review: most formats work out of the box, with Vault and Redis via plugins, so "many sources" is covered with room to spare. The input is a list of files, and the output is an object you can ask for anything:

from dynaconf import Dynaconf


settings = Dynaconf(settings_files=["config.toml"])

print(settings.HOST)
print(settings.PORT)
Enter fullscreen mode Exit fullscreen mode

Here's where a philosophical split starts. Dynaconf deliberately doesn't require you to declare a structure. settings.HOSTT (with a typo) won't fail; it just returns nothing. The IDE can't help, because there's nothing to suggest: the list of fields isn't recorded anywhere. mypy is powerless for the same reason. The schema doesn't live in code, so there's no contract checked at startup. The configuration is valid exactly until someone touches a field.

For its class of tasks this is a feature, not a bug. If you're building an admin panel where an admin can add arbitrary settings through a UI, or a platform where plugins extend the config, having no schema is appropriate. But if you're writing an ordinary service with a fixed, known-in-advance set of settings, the absence of a schema turns into a permanent source of "wait, why is it None?"

Validation exists in Dynaconf, but as a separate annex to the building:

from dynaconf import Dynaconf, Validator


settings = Dynaconf(
    settings_files=["config.toml"],
    validators=[
        Validator("PORT", gte=1, lte=65535),
        Validator("HOST", must_exist=True),
    ],
)

settings.validators.validate()
Enter fullscreen mode Exit fullscreen mode

It can check ranges and required fields, but it lives outside the schema. A validator refers to a field by string. Add a new field, forget its validator, and nobody reminds you. This isn't an implementation flaw; it's the price of working without a schema: with no structure, validation can't be a part of it. The same pattern shows up in merging: settings are inherited via special keys in the configuration files themselves:

[databases]

dynaconf_merge = true
port = 5433
Enter fullscreen mode Exit fullscreen mode

Infrastructure logic leaks into the data. Everyone who edits the config has to know about dynaconf_merge, or they'll accidentally wipe a nested section. Worse, the merge strategy is set by the data rather than the code: to extend a list instead of replacing it, you have to write the right key into the file itself. And the familiar "on conflict, fail with an error" rule doesn't exist at all.

The remaining three criteria fall out differently. Source auditing is a strength: dynaconf list -o or inspecting the object shows which file and layer a value came from, so you can avoid detective work in production. Secrets are more modest: there's no dedicated type like SecretStr that would mask the value in repr(), because there's no schema to put it in; sensitive fields are protected at the source level (the same Vault), not the model. Errors inherit the main problem of the schemaless approach: a missing field isn't a crash with file and line, it's a quiet None that will blow up somewhere further down the code.

When Dynaconf is justified: you need remote sources (Vault, Redis) out of the box, Jinja templates in values, environment switching via [development]/[production] sections in one file, a plugin ecosystem, and CLI utilities. That's serious infrastructure, and building it from scratch takes a long time.

When it isn't: you want the config to be a typed contract, with the IDE and mypy catching typos before launch.

Hydra

Hydra from Meta Research is a framework. Unlike an ordinary library that we call from our code, Hydra works the other way around: it takes control. Hydra wraps the application entry point and runs our code inside its own lifecycle. For an ML experiment that's convenient: the @hydra.main decorator solves a pile of small tasks for us:

@hydra.main(config_path="conf", config_name="config")
def app(cfg: DictConfig) -> None:
    print(cfg.database.host)
Enter fullscreen mode Exit fullscreen mode

Hydra will create an outputs/YYYY-MM-DD/HH-MM-SS/ directory, redirect logs there, change the process working directory, stitch configs from several files through the defaults list, and pass CLI overrides. Hydra's merging is well thought out: the defaults list composes configs predictably and command-line overrides layer on top. That said, all of this machinery is built for iterating over experiments, not for merging "defaults + local .env + secrets." For running 50 experiments with different hyperparameters, it's exactly what you need.

For a web service whose entry point is uvicorn and whose working directory is where templates and static files live, Hydra's design gets in the way. Relative paths break, logging configuration gets overwritten, and an outputs/ directory appears in the file system for every run.

Hydra reads only YAML. Environment variables are available through the OmegaConf resolver ${oc.env:VAR}, but .env, TOML, JSON, or INI don't work without third-party plugins. So "many sources" is half covered: YAML and environment variables right away, other formats through plugins.

The returned object is an OmegaConf.DictConfig, a special container that looks like a dataclass but isn't one. Autocomplete works in a limited way and mypy partially. Only basic types are supported: primitives, enum, and containers. Union, datetime, IPv4Address, and SecretStr are not. Validation is only type checking through OmegaConf:

# no value validation


@dataclass
class Config:
    port: int = 8080  # any int is accepted, even -1


cfg = OmegaConf.structured(Config)
Enter fullscreen mode Exit fullscreen mode

Type safety is half-baked (mypy and autocomplete work partially), and value validation like "port 1-65535" or "URL starts with http" doesn't exist as a class: only the type is checked, not whether the value makes sense. The secrets criterion lands here too: since SecretStr isn't among the supported types, you can't mark a field as sensitive in the schema, and a password stays an ordinary string.

One more thing worth keeping in mind when choosing: Hydra is a living project, but its design is still geared toward ML experiments. Config composition, run output directories, command-line overrides, working directory management: all of it is convenient for hyperparameter sweeps and too heavy for a web service. It's not about maintenance; it's that the framework imposes its own way of life on your application's entry point.

Debugging and errors are in order with Hydra, a legacy of its ML nature. Source auditing is strong: --cfg job prints the final config, and --info shows which files and overrides it was assembled from, so "where did the value come from" is answered by design. OmegaConf errors are also meaningful: on access to a nonexistent key or a type mismatch you get a message with a dotted path to the field (database.port) rather than a bare stack trace. It doesn't reach coordinates in the input file, though (which YAML, which line): the path points inside the config, not to the source.

Hydra is ideal for ML experiments with hyperparameter sweeps and object instantiation from config (hydra.utils.instantiate is a whole separate feature). For application configuration (web, CLI, daemon), it's not what it was designed for.

pydantic-settings

pydantic-settings is, as far as I know, the only truly type-safe configuration library in Python with mass adoption. If Pydantic is already in your project, pydantic-settings plugs in naturally:

from pydantic_settings import BaseSettings


class Config(BaseSettings):
    host: str
    port: int
    debug: bool = False
Enter fullscreen mode Exit fullscreen mode

Types are checked, the IDE suggests fields, and Pydantic validators work out of the box. For the typical scenario of "read environment variables plus an optional .env file," it's the best choice in the ecosystem, and there's nothing to discuss.

Strengths others don't have. Before getting to the limitations, it's worth saying: pydantic-settings has a set of capabilities its competitors either lack or can only get with considerable effort.

  • The built-in CLI (CliSettingsSource) turns a Settings model into a CLI application, with subcommands, async handlers, and automatic help. That's convenient when a tool must work both as a service and as a CLI utility (migrations, backups, one-off operations).
  • Reading from pyproject.toml via PyprojectTomlConfigSettingsSource is the natural path for libraries and CLI tools that want to keep their settings in the shared project file instead of adding another one.
  • Nested secrets directories (NestedSecretsSettingsSource) handle Docker-secrets-style files with arbitrary nesting.
  • Tight integration with FastAPI, SQLModel, and LangChain: config can be passed as a dependency, Pydantic validators can be reused for request/response models, and computed_field can be shared.
  • Validators (field_validator, model_validator with mode="before"/mode="after", computed_field) cover practically any validation scenario you might need.
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings


class Config(BaseSettings):
    host: str
    port: int
    use_ssl: bool = False
    cert_path: str | None = None

    @field_validator("port")
    @classmethod
    def port_in_range(cls, v: int) -> int:
        if not (1 <= v <= 65535):
            raise ValueError("port must be between 1 and 65535")
        return v

    @model_validator(mode="after")
    def ssl_requires_cert(self) -> "Config":
        if self.use_ssl and not self.cert_path:
            raise ValueError("cert_path is required when use_ssl is True")
        return self
Enter fullscreen mode Exit fullscreen mode

Limitations. pydantic-settings v2 added built-in JsonConfigSettingsSource, YamlConfigSettingsSource, and TomlConfigSettingsSource. But each has to be wired up explicitly through settings_customise_sources:

class Settings(BaseSettings):
    model_config = SettingsConfigDict(toml_file="config.toml")

    @classmethod
    def settings_customise_sources(cls, settings_cls, **kwargs):
        return (
            kwargs["init_settings"],
            kwargs["env_settings"],
            TomlConfigSettingsSource(settings_cls),
        )
Enter fullscreen mode Exit fullscreen mode
  • INI, JSON5, YAML 1.1, and TOML 1.1 aren't supported. There's no skip_if_broken for missing files: if a file is specified but doesn't exist, you have to catch the exception and decide by hand. There are no field_groups for the case when a group of fields must come entirely from one source. There's no ENV expansion (${VAR:-default}) in config files, so all substitutions have to happen before loading or on top of it.
  • Source merging uses a fixed priority: init → environment variables → dotenv → secrets → defaults. No strategies, no per-field rules, no conflict control. If you want "merge the allowed_hosts list, but replace the plain strings," that's not supported. Implementing any such scenario requires a custom SettingsSource class overriding _extract_field_info and _field_is_complex. It works, but it's not an out-of-the-box scenario.
  • Custom types require implementing __get_pydantic_core_schema__. For most cases this is solved through Annotated[YourType, BeforeValidator(parser)] and is simpler than it looks, but it isn't zero code. Mixing BaseSettings with other base classes (for example, mixins) sometimes causes metaclass conflicts: Pydantic replaces __init_subclass__ and does other intrusive things at runtime.

Performance: a myth for static config, a reality for dynamic config. You sometimes hear the argument "pydantic is slower than dataclasses, pick dataclasses." For configuration it's a shallow argument. Microbenchmarks do show an order-of-magnitude difference: creating a simple Pydantic model costs about 1.5 µs, a dataclass around 200-300 ns (see the benchmarks linked below). But an application config is loaded once at startup, and that difference vanishes next to opening the file, parsing YAML/TOML, and reading environment variables. If you have a web service that lives for weeks, you can ignore the startup overhead.

The picture changes in three scenarios:

  • Cold start of short-lived processes. AWS Lambda, Cloud Functions, CLI utilities, periodic tasks in Kubernetes: anywhere a process starts fresh for a short job. Here the time to useful work is the sum of importing dependencies, initializing the logger, and loading config. On a Lambda cold start, import pydantic alone costs tens or hundreds of milliseconds depending on the Python and pydantic versions (import time discussion in pydantic). You can easily check the order of magnitude locally with python -X importtime -c "import pydantic", and it's already noticeable in an SLA. For a 20-field config, the difference between BaseSettings and @dataclass is a few milliseconds, which isn't critical. What matters is the very fact of dragging Pydantic into your dependencies if the project doesn't need it anywhere else.
  • Hot reload and dynamic updates. Modern applications increasingly don't restart to change settings. Pool size, feature flags, log levels, request limits: you want to turn them live. If the config is re-read every 30 seconds, and at peak we do that in 100 workers in parallel, the accumulated CPU load is visible on graphs. Pydantic validates data from scratch on every update (it has no validation caching between calls); a dataclass does so only if you put validation in __post_init__, and then we control it.
  • Secret rotation through Vault and the like. This is a special case of the previous point, with its own specifics. If the application subscribes to changes in Vault and reacts to rotation, secrets get reloaded regularly: every few hours or even minutes, depending on policy. There's also the network call to Vault, whose latency is an order of magnitude bigger than any validation, so the difference between Pydantic and dataclass fades again, but pydantic-settings' overhead of re-creating models at refresh time stays non-zero.

Performance is not the main reason to choose or reject pydantic-settings. It has a powerful validation system, a broad ecosystem, and excellent FastAPI integration, all of which are serious pluses. But if we're building a long-lived service with dynamic configuration and want fine control over reload cost, a standard dataclass gives more control, because we decide what is validated and when, and don't pay for reprocessing when it can be avoided.

Errors. Pydantic's messages are informative in content:

validation error for Settings

port
  Input should be a valid integer, unable to parse string as an integer
    [type=int_parsing, input_value='abc', input_type=str]
Enter fullscreen mode Exit fullscreen mode

But when there are several sources, the message doesn't say which file holds the invalid value. If defaults.yaml, override.yaml, and environment variables all set port, you hunt for the culprit by hand.

In sum: pydantic-settings is a solid default when you have one or two sources and Pydantic is already in the project. If you run into merging several files with per-field rules, debugging "where did this value come from," or errors like "valid integer, unable to parse 'abc' — but in which of the three files is it?", that's a signal you need a different tool.

All the libraries through the lens of the seven requirements

Lay the market out against the seven criteria and the picture is clear at once: some requirements are covered well, some only partially, and a few barely at all. Here is how each requirement plays out.

Many sources. The point of the requirement is to read different sources out of the box without hooking up a separate adapter per format: YAML for defaults, environment variables for secrets, .env for local tweaks.

The clear leader is Dynaconf. It reads YAML, TOML, JSON, INI, .env, and Python modules out of the box, and Vault, Redis, and AWS Secrets Manager connect via plugins.

pydantic-settings supports environment variables, .env, JSON, YAML, and TOML natively, but each additional source must be explicitly wired through settings_customise_sources. Hydra only works with YAML; other formats go through third-party plugins. python-decouple is limited to .env, .ini, and environment variables, and makes no claim to universality here.

Controlled merging. It's important to control the merge: choose a strategy for conflicts (last wins / first wins / fail with an error) and set it per field.

This is a systemic hole in the market. pydantic-settings gives a rigid priority of init > env > dotenv > secrets > defaults with no way to change the strategy or define rules for individual fields.

Dynaconf supports per-field merge via the dynaconf_merge = true key, but it's written in the configuration files themselves, and everyone editing them must know about it. Hydra can stitch configs through the defaults list, which is powerful for ML but has no notion of "this policy for this field." python-decouple doesn't support merging at all.

Type safety. The point is to describe the config structure right in code, so the IDE suggests fields, mypy catches typos, and errors appear at startup rather than at runtime.

pydantic-settings covers this fully: the model is described in code, the IDE suggests fields, mypy catches typos.

Hydra covers it partially, via structured configs in OmegaConf: fields are checked, but DictConfig still isn't a dataclass, and some tools work worse with it.

Dynaconf and python-decouple provide no type safety by design: you access a field by string.

Value validation. Checking the type is necessary but not enough: int will accept the port -1, and a string will accept a non-URL. The criterion is about checking the values themselves (ranges, formats, business rules) and about doing so as part of the schema rather than as a separate system you can forget to attach to a new field.

pydantic-settings is the leader: field_validator, model_validator, Annotated validators, and a ready ecosystem of types (PositiveInt, HttpUrl, EmailStr).

Dynaconf has a separate Validator(...) system bound to string field names: it works, but is physically detached from the schema. Hydra is limited to type checking through OmegaConf; ranges, regexes, and business rules you write by hand. python-decouple gives you cast= for type coercion and your own code for value checks.

Clear errors. A good error message answers two questions: what's wrong and where to fix it. The first is human-readable text instead of a parser stack trace; the second is coordinates in the source. As it turns out, everyone handles the first, and nobody handles the second.

Pydantic gives an informative message, Input should be a valid integer; Dynaconf and Hydra raise standard exceptions with a type and a value. But none of them has coordinates in the input file: which of three sources brought the garbage value, in which file and on which line, the user finds out by hand.

Secrets protection. The criterion is about marking a password or token right in the model with a special type so it doesn't leak in plain text through repr(), logs, or an error trace.

The most advanced option today is SecretStr in pydantic-settings: the field hides its value in repr and logs. That covers the basic scenario, but requires the developer to mark the field explicitly. Forget SecretStr and the value flies into the log like an ordinary string. There's no automatic triggering "by field name" or "by value shape." Dynaconf, Hydra, and python-decouple have no masking out of the box at all.

Debugging. This isn't about an invalid config: the value is fine by type and range, it's just not what you expected. The criterion is about provenance: the tool should tell you where port=9090 came from: a file, an environment variable, or a default. Without such auditing you're left with a detective story of print(config) and image rebuilds.

This is the least covered of the seven. Dynaconf has inspect_settings(), the closest to "where did the value come from," but as a free-form dictionary rather than a structured report. Hydra writes everything applied into outputs/, which is useful for experiment reproducibility but not for answering "why is port=9090 in prod." pydantic-settings and python-decouple give no debugging information: you get an object, and then it's grep through the code.

Boiled down to a line: the market covers type safety and validation (pydantic-settings) and source universality (Dynaconf). It partially covers secrets protection (only pydantic-settings, and only by type) and clear errors (there's text, but no coordinates in the input file), and almost doesn't cover controlled merging and source auditing. These gaps became my personal motivation to build my own library.

By genre of task, the layout is:

  • A 50-line script → python-decouple.
  • Remote sources + working without a schema → Dynaconf.
  • One or two sources with Pydantic already in the project → pydantic-settings.
  • An ML experiment → Hydra.

There's no ready-made tool for the most common case: a service that lives for years. It needs everything at once: several sources, smart merging, clear errors with file and line, masked secrets, and the ability to check where a value came from. Different tools can do these things separately. Nobody does them all together.

Motivation: why yet another library

The seven-requirement lens shows the market's gaps abstractly. But before I sat down to build my own library, those gaps looked different: as a set of concrete pains I'd been hitting for years, from project to project.

Pain: per-field merging (and consistency of related fields). One of the sorest spots in this lens, for me personally, is controlled merging. For years it showed up for me in two related scenarios.

The first: a list (CORS origins, allowed hosts) should be merged across sources, while simple fields (database URL, pool size) should be replaced by priority. pydantic-settings gives a fixed priority, "everything is replaced, the last one wins." Dynaconf gives dynaconf_merge, but it's written in the configuration files themselves, not in code. To get the behavior you want on pydantic-settings, you have to write a custom SettingsSource: dozens of lines of boilerplate for a single field.

The second scenario is a special case of the same story. defaults.yaml holds the related settings of the primary DB (db_host, db_port, db_name, db_user). In staging.yaml they need to be overridden as a whole. Someone in a hurry set only db_host and forgot the rest; review missed it. The app starts and connects to staging-host:5432/production_db: a half-switched config that nobody notices until a request hits the wrong database.

The problem isn't validators: each field is valid on its own. The problem is consistency across sources: these four fields are a group, so either all four come from the same source or it's an error. Among the popular tools I reviewed, I found no out-of-the-box way to express this declaratively and with types. In practice people write something of their own with model_validator, brittle and verbose, or catch it after the fact in production.

Both pains are about the same thing: a developer wants to control merging declaratively and in code, not through special keys in the data or manual stitching.

Pain: "where did this even come from?" A very common config-debugging scenario in my practice is finding where a specific value came from. The config is assembled from three or four sources: defaults.yaml, override.yaml, environment variables, secrets from Vault. And at some point I want to understand exactly where this port=8080 or this ValidationError came from.

There are two debugging scenarios here, and they differ slightly.

After a successful load: the config is assembled, the service is running, but a value behaves strangely. Say production has DEBUG instead of INFO and the log is flooding. You want to ask the config, "Where did you get this DEBUG from?" and get the answer "from override.yaml, line 12" or "from the APP_LOG_LEVEL variable." Instead, you open all the files one by one, grep, check the container's environment, and do it all over again every time.

On a parse failure, you get ValidationError: port should be a valid integer, and need to work out where exactly the offending line lives. The error text itself is fine, but in which of the three files should you look for port:? If one of them is mounted from a ConfigMap, that's five to ten minutes by hand for every failure in CI.

Both scenarios are about the same thing: the coordinates of a value should be part of the load result. This information is available right at parse time (ruamel.yaml, tomlkit, and other AST parsers preserve (line, col) for each node), but nobody carries it further. Pydantic doesn't name the file at all. Dynaconf and Hydra know the source but don't hand it out as a structured report. So on every such task you become a detective again, even though the parser already holds the data for the investigation.

Pain: I want validators both in the type and outside it. When the dataclass is mine, I want the validator right in the field annotation: port: Annotated[int, V >= 1, V <= 65535]. Add a field and the constraints come with it; rename it and mypy catches it. pydantic-settings has long supported this form, so for your own models the market has reached a decent state.

It's harder when the dataclass isn't mine: it came from someone else's library or a domain model you want to reuse as config. You can't change the class, but validation is still needed. In pydantic-settings the way is a wrapper or a subclass. In Dynaconf it's Validator("PORT", gte=1) with a string field name; it works, but renaming PORT in the schema silently breaks the check, and you find out only when an invalid value has already reached production.

I want both forms out of the box, and both without string references to fields: Annotated for your own classes, and a separate validation parameter for foreign ones, going through the same typed field path that can be checked statically.

Pain: secret masking doesn't work everywhere it should. Secrets protection in the lens is only partially covered, and only in pydantic-settings, and only by type. It has SecretStr, which hides the value in repr and logs. That's enough as long as the developer remembers to mark the field as SecretStr. Forget, and the value flies into the log as a plain string.

In reality, people forget regularly: a new hire adds a database_password: str field by analogy with the neighboring database_url: str, and at the first print(config) or logger.info(config) the password goes into the log in plain text. There's no automatic triggering "by field name" or "by value shape," and I'd like one, because names of secret fields are fairly predictable (password, token, api_key, secret), and randomly generated tokens statistically differ from ordinary strings.

Pain: "we need our own source." In large companies it's rare to hear "let's take public Vault and go." More often it's a corporate analogue of a secret manager, a REST endpoint "give me the settings for this module," an encrypted archive unpacked via an internal KMS, or just an old SOAP endpoint where feature flags live. Making that work with pydantic-settings means writing your own SettingsSource overriding _field_is_complex and _extract_field_info, and then regression-testing every format hiccup. That's dozens of lines of infrastructure code for one HTTP call, plus your own error and type-coercion pipeline.

I want "your own source" to be a minimal contract: one class, one method that returns a dictionary. Everything else (merging with other sources, validation, clear errors pointing at this source, debugging) should come from the shared pipeline instead of being copied into every new source.

The architectural pain: a library, not a framework. Hydra captures the application entry point and swaps the working directory. pydantic-settings intrusively threads BaseModel through all your methods. Dynaconf reads its settings.toml from somewhere in the project root at every turn.

I want a tool that behaves like a function: call load(...), get a plain dataclass, done. No global effects, no swapped directories, no "our ecosystem you now live in." A stdlib @dataclass as the result is a contract that works with any other tool: ORMs, FastAPI, tests, serialization. There's no vendor lock-in: a user of the tool isn't tied to its ecosystem.

Here's a summary table of the tools reviewed.

Parameter python-decouple Dynaconf pydantic-settings Hydra
Schema in code No No Pydantic model YAML + dataclass
Result Individual variables Dynaconf (dict-like) BaseSettings (Pydantic) DictConfig (OmegaConf)
Formats .env, .ini, env vars YAML, TOML, JSON, INI, .env, Python .env, env vars, JSON, YAML, TOML YAML
Source merging No Layers + dynaconf_merge Fixed priority Defaults list
Type safety No No Yes (Pydantic) Partial (OmegaConf)
Validation cast Separate Validators Pydantic validators Types only
Errors (file/line) No No No No
Secret masking No No SecretStr No
Source auditing No inspect_settings No Output dir
ENV expansion No @format, @jinja No ${oc.env:VAR}
CLI No CLI utilities CliSettingsSource CLI overrides
Remote sources No Vault, Redis No No
IDE / mypy No No Yes Partial
Dependencies None None pydantic (Rust core) hydra-core, omegaconf

The table is not a verdict. 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: pydantic-settings leads on type safety and validation, and Dynaconf on source variety. Hydra fits ML experiments, and python-decouple fits small scripts. None of the four gives you file-and-line errors or fine-grained merge control, and that's the gap Part 3 tries to close.

Next up: Part 3 shows how my library, dature, grew out of these seven requirements and six pains.

Which of these tools are you using today, and what made you pick it? Tell me in the comments.

Top comments (0)