<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nikolai Vidov</title>
    <description>The latest articles on DEV Community by Nikolai Vidov (@niccolum).</description>
    <link>https://dev.to/niccolum</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3122012%2F9a2d5087-287d-4561-b168-e1d1dfdf5a14.jpg</url>
      <title>DEV Community: Nikolai Vidov</title>
      <link>https://dev.to/niccolum</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/niccolum"/>
    <language>en</language>
    <item>
      <title>Why no Python configuration library covers everything</title>
      <dc:creator>Nikolai Vidov</dc:creator>
      <pubDate>Sat, 24 Oct 2026 14:45:14 +0000</pubDate>
      <link>https://dev.to/niccolum/why-no-python-configuration-library-covers-everything-1phe</link>
      <guid>https://dev.to/niccolum/why-no-python-configuration-library-covers-everything-1phe</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is the second article in a series. &lt;a href="https://dev.to/niccolum/1-python-configuration-how-we-got-here-and-why-it-still-hurts-3kpn"&gt;The first one&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Type safety and validation are well covered (pydantic-settings), and so is source variety (Dynaconf).&lt;/li&gt;
&lt;li&gt;Nobody solves controlled merging, error coordinates (file and line), or source auditing out of the box.&lt;/li&gt;
&lt;li&gt;Those gaps are what pushed me to write my own library, which is the subject of Part 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  python-decouple
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decouple&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;


&lt;span class="n"&gt;DEBUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DEBUG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cast&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PORT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cast&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Historically, python-decouple grew up as a replacement for &lt;code&gt;os.environ.get()&lt;/code&gt; 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; &lt;code&gt;config()&lt;/code&gt; calls are scattered across imports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings/database.py
&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="c1"&gt;# settings/cache.py
&lt;/span&gt;&lt;span class="n"&gt;REDIS_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REDIS_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis://localhost:6379/0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="c1"&gt;# settings/auth.py
&lt;/span&gt;&lt;span class="n"&gt;JWT_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;JWT_EXPIRES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT_EXPIRES&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cast&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only way to find the full list of variables the app expects is &lt;code&gt;grep config(&lt;/code&gt; 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 &lt;code&gt;AttributeError&lt;/code&gt; in production, which is exactly what a startup check is meant to avoid.&lt;/p&gt;

&lt;p&gt;There are no nested structures at all: for &lt;code&gt;database.primary.host&lt;/code&gt; you have to write out a flat &lt;code&gt;DATABASE_PRIMARY_HOST&lt;/code&gt; 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 &lt;code&gt;.ini&lt;/code&gt; and then overlay them from &lt;code&gt;.env&lt;/code&gt;. So conflict-resolution strategies (last wins, first wins, fail with an error) aren't even a topic here.&lt;/p&gt;

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

&lt;p&gt;I keep python-decouple in mind for one-off scripts and small utilities where the config is five variables and a single &lt;code&gt;.env&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dynaconf
&lt;/h2&gt;

&lt;p&gt;Dynaconf bets on flexibility and runtime configuration. It reads YAML, TOML, JSON, INI, &lt;code&gt;.env&lt;/code&gt;, 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dynaconf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Dynaconf&lt;/span&gt;


&lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Dynaconf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings_files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config.toml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's where a philosophical split starts. Dynaconf deliberately doesn't require you to declare a structure. &lt;code&gt;settings.HOSTT&lt;/code&gt; (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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;None&lt;/code&gt;?"&lt;/p&gt;

&lt;p&gt;Validation exists in Dynaconf, but as a separate annex to the building:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dynaconf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Dynaconf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Validator&lt;/span&gt;


&lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Dynaconf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;settings_files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config.toml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;validators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;Validator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PORT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gte&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lte&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;65535&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Validator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HOST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;must_exist&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;validators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;databases&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;dynaconf_merge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5433&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Infrastructure logic leaks into the data. Everyone who edits the config has to know about &lt;code&gt;dynaconf_merge&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;The remaining three criteria fall out differently. Source auditing is a strength: &lt;code&gt;dynaconf list -o&lt;/code&gt; 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 &lt;code&gt;SecretStr&lt;/code&gt; that would mask the value in &lt;code&gt;repr()&lt;/code&gt;, 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 &lt;code&gt;None&lt;/code&gt; that will blow up somewhere further down the code.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;When it isn't:&lt;/strong&gt; you want the config to be a typed contract, with the IDE and mypy catching typos before launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hydra
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;@hydra.main&lt;/code&gt; decorator solves a pile of small tasks for us:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@hydra.main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DictConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hydra will create an &lt;code&gt;outputs/YYYY-MM-DD/HH-MM-SS/&lt;/code&gt; directory, redirect logs there, change the process working directory, stitch configs from several files through the &lt;code&gt;defaults list&lt;/code&gt;, and pass CLI overrides. Hydra's merging is well thought out: the &lt;code&gt;defaults list&lt;/code&gt; 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 &lt;code&gt;.env&lt;/code&gt; + secrets." For running 50 experiments with different hyperparameters, it's exactly what you need.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;outputs/&lt;/code&gt; directory appears in the file system for every run.&lt;/p&gt;

&lt;p&gt;Hydra reads only YAML. Environment variables are available through the OmegaConf resolver &lt;code&gt;${oc.env:VAR}&lt;/code&gt;, but &lt;code&gt;.env&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;The returned object is an &lt;code&gt;OmegaConf.DictConfig&lt;/code&gt;, 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, &lt;code&gt;enum&lt;/code&gt;, and containers. &lt;code&gt;Union&lt;/code&gt;, &lt;code&gt;datetime&lt;/code&gt;, &lt;code&gt;IPv4Address&lt;/code&gt;, and &lt;code&gt;SecretStr&lt;/code&gt; are not. Validation is only type checking through OmegaConf:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# no value validation
&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;  &lt;span class="c1"&gt;# any int is accepted, even -1
&lt;/span&gt;

&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OmegaConf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;structured&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;SecretStr&lt;/code&gt; isn't among the supported types, you can't mark a field as sensitive in the schema, and a password stays an ordinary string.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Debugging and errors are in order with Hydra, a legacy of its ML nature. Source auditing is strong: &lt;code&gt;--cfg job&lt;/code&gt; prints the final config, and &lt;code&gt;--info&lt;/code&gt; 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 (&lt;code&gt;database.port&lt;/code&gt;) 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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  pydantic-settings
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseSettings&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseSettings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;.env&lt;/code&gt; file," it's the best choice in the ecosystem, and there's nothing to discuss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths others don't have.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The built-in CLI (&lt;code&gt;CliSettingsSource&lt;/code&gt;) 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).&lt;/li&gt;
&lt;li&gt;Reading from &lt;code&gt;pyproject.toml&lt;/code&gt; via &lt;code&gt;PyprojectTomlConfigSettingsSource&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;Nested secrets directories (&lt;code&gt;NestedSecretsSettingsSource&lt;/code&gt;) handle Docker-secrets-style files with arbitrary nesting.&lt;/li&gt;
&lt;li&gt;Tight integration with FastAPI, SQLModel, and LangChain: config can be passed as a dependency, Pydantic validators can be reused for request/response models, and &lt;code&gt;computed_field&lt;/code&gt; can be shared.&lt;/li&gt;
&lt;li&gt;Validators (&lt;code&gt;field_validator&lt;/code&gt;, &lt;code&gt;model_validator&lt;/code&gt; with &lt;code&gt;mode="before"&lt;/code&gt;/&lt;code&gt;mode="after"&lt;/code&gt;, &lt;code&gt;computed_field&lt;/code&gt;) cover practically any validation scenario you might need.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;field_validator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_validator&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseSettings&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseSettings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;use_ssl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;cert_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="nd"&gt;@field_validator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@classmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;port_in_range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;65535&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port must be between 1 and 65535&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;

    &lt;span class="nd"&gt;@model_validator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ssl_requires_cert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;use_ssl&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cert_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cert_path is required when use_ssl is True&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Limitations.&lt;/strong&gt; pydantic-settings v2 added built-in &lt;code&gt;JsonConfigSettingsSource&lt;/code&gt;, &lt;code&gt;YamlConfigSettingsSource&lt;/code&gt;, and &lt;code&gt;TomlConfigSettingsSource&lt;/code&gt;. But each has to be wired up explicitly through &lt;code&gt;settings_customise_sources&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseSettings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SettingsConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toml_file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config.toml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@classmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;settings_customise_sources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings_cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;init_settings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;env_settings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="nc"&gt;TomlConfigSettingsSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings_cls&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;INI, JSON5, YAML 1.1, and TOML 1.1 aren't supported. There's no &lt;code&gt;skip_if_broken&lt;/code&gt; 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 &lt;code&gt;field_groups&lt;/code&gt; for the case when a group of fields must come entirely from one source. There's no ENV expansion (&lt;code&gt;${VAR:-default}&lt;/code&gt;) in config files, so all substitutions have to happen before loading or on top of it.&lt;/li&gt;
&lt;li&gt;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 &lt;code&gt;allowed_hosts&lt;/code&gt; list, but replace the plain strings," that's not supported. Implementing any such scenario requires a custom &lt;code&gt;SettingsSource&lt;/code&gt; class overriding &lt;code&gt;_extract_field_info&lt;/code&gt; and &lt;code&gt;_field_is_complex&lt;/code&gt;. It works, but it's not an out-of-the-box scenario.&lt;/li&gt;
&lt;li&gt;Custom types require implementing &lt;code&gt;__get_pydantic_core_schema__&lt;/code&gt;. For most cases this is solved through &lt;code&gt;Annotated[YourType, BeforeValidator(parser)]&lt;/code&gt; 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 &lt;code&gt;__init_subclass__&lt;/code&gt; and does other intrusive things at runtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Performance: a myth for static config, a reality for dynamic config.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The picture changes in three scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cold start of short-lived processes.&lt;/strong&gt; 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, &lt;code&gt;import pydantic&lt;/code&gt; alone costs tens or hundreds of milliseconds depending on the Python and pydantic versions (&lt;a href="https://github.com/pydantic/pydantic/discussions/6748" rel="noopener noreferrer"&gt;import time discussion in pydantic&lt;/a&gt;). You can easily check the order of magnitude locally with &lt;code&gt;python -X importtime -c "import pydantic"&lt;/code&gt;, and it's already noticeable in an SLA. For a 20-field config, the difference between &lt;code&gt;BaseSettings&lt;/code&gt; and &lt;code&gt;@dataclass&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hot reload and dynamic updates.&lt;/strong&gt; 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 &lt;code&gt;__post_init__&lt;/code&gt;, and then we control it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret rotation through Vault and the like.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors.&lt;/strong&gt; Pydantic's messages are informative in content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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 "&lt;code&gt;valid integer, unable to parse 'abc'&lt;/code&gt; — but in which of the three files is it?", that's a signal you need a different tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  All the libraries through the lens of the seven requirements
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Many sources.&lt;/strong&gt; 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, &lt;code&gt;.env&lt;/code&gt; for local tweaks.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;Controlled merging.&lt;/strong&gt; 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.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;Type safety.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;pydantic-settings covers this fully: the model is described in code, the IDE suggests fields, mypy catches typos.&lt;/p&gt;

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

&lt;p&gt;Dynaconf and python-decouple provide no type safety by design: you access a field by string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Value validation.&lt;/strong&gt; Checking the type is necessary but not enough: &lt;code&gt;int&lt;/code&gt; will accept the port &lt;code&gt;-1&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;pydantic-settings is the leader: &lt;code&gt;field_validator&lt;/code&gt;, &lt;code&gt;model_validator&lt;/code&gt;, &lt;code&gt;Annotated&lt;/code&gt; validators, and a ready ecosystem of types (&lt;code&gt;PositiveInt&lt;/code&gt;, &lt;code&gt;HttpUrl&lt;/code&gt;, &lt;code&gt;EmailStr&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Dynaconf has a separate &lt;code&gt;Validator(...)&lt;/code&gt; 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 &lt;code&gt;cast=&lt;/code&gt; for type coercion and your own code for value checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clear errors.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Pydantic gives an informative message, &lt;code&gt;Input should be a valid integer&lt;/code&gt;; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets protection.&lt;/strong&gt; 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 &lt;code&gt;repr()&lt;/code&gt;, logs, or an error trace.&lt;/p&gt;

&lt;p&gt;The most advanced option today is &lt;code&gt;SecretStr&lt;/code&gt; 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 &lt;code&gt;SecretStr&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging.&lt;/strong&gt; 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 &lt;code&gt;port=9090&lt;/code&gt; came from: a file, an environment variable, or a default. Without such auditing you're left with a detective story of &lt;code&gt;print(config)&lt;/code&gt; and image rebuilds.&lt;/p&gt;

&lt;p&gt;This is the least covered of the seven. Dynaconf has &lt;code&gt;inspect_settings()&lt;/code&gt;, 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 &lt;code&gt;outputs/&lt;/code&gt;, which is useful for experiment reproducibility but not for answering "why is &lt;code&gt;port=9090&lt;/code&gt; in prod." pydantic-settings and python-decouple give no debugging information: you get an object, and then it's grep through the code.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;By genre of task, the layout is:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Motivation: why yet another library
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pain: per-field merging (and consistency of related fields).&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The first: a list (CORS origins, allowed hosts) should be &lt;em&gt;merged&lt;/em&gt; across sources, while simple fields (database URL, pool size) should be &lt;em&gt;replaced&lt;/em&gt; by priority. pydantic-settings gives a fixed priority, "everything is replaced, the last one wins." Dynaconf gives &lt;code&gt;dynaconf_merge&lt;/code&gt;, 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 &lt;code&gt;SettingsSource&lt;/code&gt;: dozens of lines of boilerplate for a single field.&lt;/p&gt;

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

&lt;p&gt;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 &lt;code&gt;model_validator&lt;/code&gt;, brittle and verbose, or catch it after the fact in production.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pain: "where did this even come from?"&lt;/strong&gt; 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: &lt;code&gt;defaults.yaml&lt;/code&gt;, &lt;code&gt;override.yaml&lt;/code&gt;, environment variables, secrets from Vault. And at some point I want to understand exactly where this &lt;code&gt;port=8080&lt;/code&gt; or this &lt;code&gt;ValidationError&lt;/code&gt; came from.&lt;/p&gt;

&lt;p&gt;There are two debugging scenarios here, and they differ slightly.&lt;/p&gt;

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

&lt;p&gt;On a parse failure, you get &lt;code&gt;ValidationError: port should be a valid integer&lt;/code&gt;, 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 &lt;code&gt;port:&lt;/code&gt;? If one of them is mounted from a &lt;code&gt;ConfigMap&lt;/code&gt;, that's five to ten minutes by hand for every failure in CI.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;(line, col)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pain: I want validators both in the type and outside it.&lt;/strong&gt; When the dataclass is mine, I want the validator right in the field annotation: &lt;code&gt;port: Annotated[int, V &amp;gt;= 1, V &amp;lt;= 65535]&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Validator("PORT", gte=1)&lt;/code&gt; 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.&lt;/p&gt;

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

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

&lt;p&gt;In reality, people forget regularly: a new hire adds a &lt;code&gt;database_password: str&lt;/code&gt; field by analogy with the neighboring &lt;code&gt;database_url: str&lt;/code&gt;, and at the first &lt;code&gt;print(config)&lt;/code&gt; or &lt;code&gt;logger.info(config)&lt;/code&gt; 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 (&lt;code&gt;password&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;api_key&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;), and randomly generated tokens statistically differ from ordinary strings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pain: "we need our own source."&lt;/strong&gt; 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 &lt;code&gt;SettingsSource&lt;/code&gt; overriding &lt;code&gt;_field_is_complex&lt;/code&gt; and &lt;code&gt;_extract_field_info&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;I want a tool that behaves like a function: call &lt;code&gt;load(...)&lt;/code&gt;, get a plain dataclass, done. No global effects, no swapped directories, no "our ecosystem you now live in." A stdlib &lt;code&gt;@dataclass&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Here's a summary table of the tools reviewed.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; Part 3 shows how my library, dature, grew out of these seven requirements and six pains.&lt;/p&gt;

&lt;p&gt;Which of these tools are you using today, and what made you pick it? Tell me in the comments.&lt;/p&gt;

</description>
      <category>python</category>
      <category>configuration</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Python configuration: how we got here, and why it still hurts</title>
      <dc:creator>Nikolai Vidov</dc:creator>
      <pubDate>Thu, 22 Oct 2026 14:43:16 +0000</pubDate>
      <link>https://dev.to/niccolum/1-python-configuration-how-we-got-here-and-why-it-still-hurts-3kpn</link>
      <guid>https://dev.to/niccolum/1-python-configuration-how-we-got-here-and-why-it-still-hurts-3kpn</guid>
      <description>&lt;p&gt;Configuration looks trivial: a &lt;code&gt;.env&lt;/code&gt; file and a &lt;code&gt;settings.py&lt;/code&gt;. Then the app grows. Secrets leak into logs, &lt;code&gt;port&lt;/code&gt; suddenly arrives as the string &lt;code&gt;"abc"&lt;/code&gt;, and settings get copy-pasted between environments. I went from hardcoded constants to typed schemas, and the main thing I learned is this: &lt;strong&gt;configuration is not throwaway glue code. It's part of your infrastructure, and it deserves the same design care as business logic.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I've been writing Python since 2017. One of the first projects I worked on was a 17-year-old Python 2.7 / Django 1.x monolith with settings hardcoded in &lt;code&gt;settings.py&lt;/code&gt;, and I assumed the whole problem was "get the values out of the code." Years later I know better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every generation of config tooling (INI, YAML, env vars, typed schemas) fixed one problem and created another.&lt;/li&gt;
&lt;li&gt;12-Factor's "everything in environment variables" no longer covers secret rotation, dynamic config, or multi-source merging.&lt;/li&gt;
&lt;li&gt;Seven requirements make a useful yardstick for any configuration tool.&lt;/li&gt;
&lt;li&gt;Five anti-patterns survive even in healthy codebases, and no library will cure them for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the first of three articles. Here I walk through the evolution of configuration approaches in Python: where we came from, where we're headed, what each generation fixed, and which pitfalls are still waiting for us. I won't compare specific libraries yet; that's Part 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evolution
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7n94pj46ub2v2kacabi1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7n94pj46ub2v2kacabi1.png" alt="Evolution of configuration approaches" width="474" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hardcode era (the 2000s).&lt;/strong&gt; I'm lucky enough to have missed it, but that 17-year-old project still carried its echoes. Some settings came through &lt;code&gt;os.environ.get&lt;/code&gt;; the rest were plain module constants in &lt;code&gt;settings.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DATABASE_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;DATABASE_PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
&lt;span class="n"&gt;DEBUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every change meant a full release. The most painful construct I found there was branching like &lt;code&gt;if env == "production":&lt;/code&gt; that swaps hardcoded values. To know what's active right now, you have to read the whole file. When there are dozens of such branches, debugging turns into detective work with a flashlight.&lt;/p&gt;

&lt;p&gt;From there, the story forks. The first branch keeps settings in Python but tidies them up: split &lt;code&gt;settings.py&lt;/code&gt; into &lt;code&gt;base.py&lt;/code&gt;, &lt;code&gt;prod.py&lt;/code&gt;, &lt;code&gt;dev.py&lt;/code&gt;, and use imports and inheritance. You get a config with types, autocomplete, and computed values. The Django community still lives this way. The downside: the config remains executable code. It's awkward for non-developers to edit, and you can't swap it without a rebuild or redeploy.&lt;/p&gt;

&lt;p&gt;The second branch moves values into a declarative file in a separate format, fully decoupled from code. Let's start there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config files: settings live apart from code
&lt;/h2&gt;

&lt;p&gt;The first logical step after hardcoding is to move values into a file. In the early 2000s that file was INI, and Python shipped &lt;code&gt;configparser&lt;/code&gt; in the standard library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[database]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;5432&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;configparser&lt;/span&gt;


&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configparser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ConfigParser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config.ini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;database&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;database&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;# always a string
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Config separated from code. Revolutionary! But the era hit a ceiling fast: everything you pull out of INI is a string. Numbers need manual casting, &lt;code&gt;bool&lt;/code&gt; needs a dedicated &lt;code&gt;getboolean()&lt;/code&gt;, and lists have to be invented on top of the format. Comma? Semicolon? What if the value contains the delimiter?&lt;/p&gt;

&lt;p&gt;Mistype a section name and you get a &lt;code&gt;KeyError&lt;/code&gt; somewhere in production. Forget a required key and you find out from the same place. And any attempt at hierarchy runs into the fact that INI knows two levels, section and key, so every project invents its own convention like &lt;code&gt;[database.primary]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;XML was popular in enterprise (especially in Java land) but never took root in Python: too verbose, hard to read, painful to edit by hand.&lt;/p&gt;

&lt;p&gt;JSON looked ideal: structured, typed, supported everywhere. But the standard has no comments, so you can't document a setting in the file itself. Hand-editing is risky: one missing comma breaks the whole file. And secrets stay in plain text, committed to git alongside the config.&lt;/p&gt;

&lt;h2&gt;
  
  
  YAML: closer to humans than to machines
&lt;/h2&gt;

&lt;p&gt;In the late 2000s and early 2010s, YAML pulled ahead on the back of the DevOps wave. Ansible and Kubernetes made it the de facto standard for configs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_user&lt;/span&gt;

&lt;span class="c1"&gt;# Comments work!&lt;/span&gt;
&lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INFO&lt;/span&gt;  &lt;span class="c1"&gt;# and this one can be switched to DEBUG&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;YAML delivered what JSON lacked: comments, readability, real hierarchy. In exchange it brought its own class of pitfalls. One extra space breaks the whole file; indentation sensitivity is merciless. Magic conversions regularly shoot you in the foot: an unquoted &lt;code&gt;NO&lt;/code&gt; becomes &lt;code&gt;False&lt;/code&gt;, &lt;code&gt;010&lt;/code&gt; parses as an octal number (in YAML 1.1), and &lt;code&gt;1.0&lt;/code&gt; is sometimes a string and sometimes a &lt;code&gt;float&lt;/code&gt;. A separate category is YAML bombs: parse an untrusted file without a safe loader and you can take the process down.&lt;/p&gt;

&lt;p&gt;Then there's the zoo of implementations. The spec leaves a lot of freedom at the edges, and every parser interprets it differently. For example, &lt;code&gt;PyYAML&lt;/code&gt; parses &lt;code&gt;2023-01-01&lt;/code&gt; as &lt;code&gt;datetime.date&lt;/code&gt; by default, while &lt;code&gt;ruamel.yaml&lt;/code&gt; gives you a string. An empty &lt;code&gt;key:&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt; in one parser and an empty string in another. Leading zeros (&lt;code&gt;port: 010&lt;/code&gt;) are octal for some parsers, decimal for others, a string for a third. If services in different languages read the same config, the divergences multiply. JSON has some of this too, but its spec is much tighter. In YAML it's a systemic problem.&lt;/p&gt;

&lt;p&gt;A separate genre of the same period: Python modules as config, especially popular in Django and Flask.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;DATABASE_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;DATABASE_PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
    &lt;span class="n"&gt;DEBUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DevelopmentConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;DEBUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductionConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;DATABASE_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_HOST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prod-db.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach has a lot going for it. It's native Python: the IDE completes fields, mypy catches typos before runtime, class inheritance naturally sorts environments. Values can be anything: read a secret from Vault at startup, assemble a DSN from parts, switch the host by machine name. It's a full-fledged config, just written in an executable language instead of a declarative one.&lt;/p&gt;

&lt;p&gt;"Executable" is the controversial part. Importing &lt;code&gt;settings.py&lt;/code&gt; runs arbitrary Python, with all that implies: side effects on load, hidden logic in an innocent-looking file. And to answer "where did this value in prod come from?" you sometimes have to walk the call stack.&lt;/p&gt;

&lt;p&gt;Overriding a setting without a rebuild is harder than with a declarative format. You can mount a YAML or TOML file into a container as a volume; &lt;code&gt;settings.py&lt;/code&gt; is part of the code, not the data (though running Ansible against it on the host is a workable option).&lt;/p&gt;

&lt;p&gt;Hot reload is formally possible via &lt;code&gt;importlib.reload()&lt;/code&gt;, but treacherous in practice: modules that already imported values by name keep the old references, and part of your app ends up in an inconsistent state. Re-reading a YAML file doesn't have this problem, because the values are data, not names in someone else's namespace.&lt;/p&gt;

&lt;p&gt;There's also an organizational flip side. Keeping settings in code works only while config is the sole responsibility of developers: one language, one repo, one set of rules. Once DevOps, SRE, or product start touching it, a Python file becomes a constant source of friction. Not everyone is willing to edit someone else's code to change a value.&lt;/p&gt;

&lt;p&gt;The approach is alive and far from fringe. Pulumi is built entirely on this idea: infrastructure and config as code in a real language, as opposed to Terraform's declarative HCL. The "executable vs. declarative config" debate has run for about fifteen years, and there will probably be no winner.&lt;/p&gt;

&lt;p&gt;Regardless of format, all files of that generation share the same problems. No typing: is &lt;code&gt;max_connections: "100"&lt;/code&gt; a string or a number? And if someone typos &lt;code&gt;"10O"&lt;/code&gt;, nobody will tell you. Secrets in plain text land in git with the whole commit history. And for different environments you end up breeding &lt;code&gt;config.dev.yaml&lt;/code&gt;, &lt;code&gt;config.staging.yaml&lt;/code&gt;, &lt;code&gt;config.production.yaml&lt;/code&gt;, with no standard way to merge them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment variables: one build for every environment
&lt;/h2&gt;

&lt;p&gt;In 2011, Adam Wiggins of Heroku published The Twelve-Factor App, a manifesto for modern web applications. Factor III said: &lt;em&gt;store config in the environment.&lt;/em&gt; It was a different way of thinking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;


&lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_HOST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_PORT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DEBUG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With environment variables, secrets stopped landing in the repository, and switching environments came down to swapping variables. Every cloud platform picked it up: Heroku, AWS, GCP, Docker (&lt;code&gt;docker run -e DATABASE_HOST=db&lt;/code&gt;), CI/CD systems.&lt;/p&gt;

&lt;p&gt;But again, everything is a string. &lt;code&gt;MAX_CONNECTIONS=100&lt;/code&gt; is &lt;code&gt;'100'&lt;/code&gt;, &lt;code&gt;DEBUG=true&lt;/code&gt; is &lt;code&gt;'true'&lt;/code&gt;, not &lt;code&gt;True&lt;/code&gt;. You rebuild types by hand at every access. There's no hierarchy, so you invent conventions like &lt;code&gt;DATABASE_PRIMARY_HOST&lt;/code&gt;. Environment variables are global to the process: one module writes &lt;code&gt;os.environ['TIMEOUT'] = '30'&lt;/code&gt;, another reads it, and when something goes wrong nobody knows where the value came from.&lt;/p&gt;

&lt;p&gt;Complex types are their own pain. There are several ways to pass a list of URLs through an env var, and all of them are crooked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# JSON in a string (ugly, but works)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ALLOWED_ORIGINS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'["https://app.example.com","https://admin.example.com"]'&lt;/span&gt;

&lt;span class="c"&gt;# Delimiters (what if a value contains a comma?)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ALLOWED_ORIGINS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://app.example.com,https://admin.example.com

&lt;span class="c"&gt;# Separate variables (variable explosion)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ALLOWED_ORIGIN_1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://app.example.com
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ALLOWED_ORIGIN_2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://admin.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no perfect option. And for local development it turned out that every developer has to set dozens of variables. So the &lt;code&gt;.env&lt;/code&gt; file appeared, which is, once again, a configuration file with all its problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  12-Factor today: what the manifesto is missing
&lt;/h2&gt;

&lt;p&gt;By the late 2010s it was clear the methodology had solved one problem and created another. Nobody disputes the principle of separating config from code. It was right then and it's right now. But the recommendation "everything through environment variables" started cracking at the seams in several scenarios at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clouds learned to store secrets themselves.&lt;/strong&gt; AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, and HashiCorp Vault provide managed storage with rotation, auditing, and granular access control. On the application side, env is still simpler: you get a ready string and move on, while an SDK means an extra dependency and network error handling.&lt;/p&gt;

&lt;p&gt;But once you need secret rotation without a restart, on-demand reads, or separate permissions per secret, env hits a ceiling: variables are read once at process start and live until it exits. In those scenarios, calling the secret store through an SDK stops being an unnecessary hop and becomes the only workable option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic configuration stopped being exotic.&lt;/strong&gt; Feature flags, pool sizes, rate limits: you want to turn these knobs without a restart, and the model "an environment variable is set at process start" physically can't do it. Dedicated feature-flag services appeared (Flagr, LaunchDarkly, Unleash) that the app polls at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Containers brought their own pattern.&lt;/strong&gt; Docker secrets are files mounted into the container and available via &lt;code&gt;/run/secrets/&amp;lt;name&amp;gt;&lt;/code&gt;. Kubernetes secrets are more flexible: they can be attached as files through a volume or as environment variables. The file variant is usually considered safer: the secret doesn't show up in the process &lt;code&gt;env&lt;/code&gt; or in environment dumps, and it's easier to rotate without restarting the pod. Either way, this is no longer the "everything through environment variables" model that 12-Factor proposed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merging didn't disappear. It moved.&lt;/strong&gt; Even where an app honestly follows 12-Factor and reads only &lt;code&gt;os.environ&lt;/code&gt;, merging multiple sources doesn't go away; it relocates from code into infrastructure. A Helm chart assembles values from &lt;code&gt;values.yaml&lt;/code&gt; and &lt;code&gt;values.production.yaml&lt;/code&gt;. A ConfigMap turns into pod environment variables, and so does a Secret. A CI/CD pipeline pulls values from Vault and passes them via &lt;code&gt;--env&lt;/code&gt;. Before the container starts, all of it is flattened into one set and lands in &lt;code&gt;os.environ&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The application sees the final result but not which layer overrode what. "Where did this value come from?" debugging has moved into Helm, Terraform, or CI secrets, and responsibility for merge correctness is smeared across several teams. The merge is still there. It's just implicit and invisible from the code.&lt;/p&gt;

&lt;p&gt;The community noticed. Critics fairly pointed out that 12-Factor reflected Heroku's product particulars more than universal engineering principles: Heroku sold a platform where environment variables were convenient, and the manifesto helped sell it. In November 2024 the methodology was open-sourced for the community to update, which is an admission that it needs refreshing for the modern landscape.&lt;/p&gt;

&lt;p&gt;Some teams go the opposite way: they pull merging back out of infrastructure and into the application, so that it's visible and verifiable. The app itself reads several sources and combines them in code: defaults from code, base structure from YAML or TOML, overrides from environment variables, secrets from a secret manager. Managing this zoo by hand is tedious.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;os.environ.get(...)&lt;/code&gt;, &lt;code&gt;yaml.safe_load(...)&lt;/code&gt;, a separate SDK per store: every source has its own API, its own typing, its own error handling. That's where the demand for aggregator tools came from: tools that read everything at once and fold it into one typed structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Structured formats today
&lt;/h2&gt;

&lt;p&gt;TOML became popular once Python adopted it for &lt;code&gt;pyproject.toml&lt;/code&gt; (with built-in support since Python 3.11 via &lt;code&gt;tomllib&lt;/code&gt;). It deservedly pushed YAML aside as the config format for Python tooling thanks to strict primitive types, readability, and fewer parsing ambiguities. JSON5 went another way, adding comments and trailing commas to JSON. That revived JSON for people who like it but are tired of chasing a parse error on line 47 caused by a stray comma.&lt;/p&gt;

&lt;p&gt;In parallel, a whole family of programmable configuration formats grew up, where the file is almost a language rather than a key-value table. A few interesting ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HCL (HashiCorp Configuration Language)&lt;/strong&gt; is the standard for Terraform, Vault, Consul, and Nomad. It reads better than JSON and supports blocks and interpolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HOCON (Human-Optimized Config Object Notation)&lt;/strong&gt; came from the Scala/Akka world. It supports composing multiple files via &lt;code&gt;include&lt;/code&gt;, reusing values through substitutions (&lt;code&gt;${...}&lt;/code&gt;), and referencing environment variables right in the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jsonnet&lt;/strong&gt; from Google is JSON with variables, conditionals, and functions. It's widely used in Grafana and Tanka.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CUE and Dhall&lt;/strong&gt; go further: they're fully typed languages with validation, where the config and its schema sit side by side. KDL positions itself as a modern TOML replacement with document-like structures. Apple's &lt;strong&gt;Pkl&lt;/strong&gt; (introduced in 2024) offers type-safe configuration with templating and validation in the syntax itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NestedText&lt;/strong&gt; lives at the opposite pole: a minimalist "everything is a string" format for people tired of YAML's magic conversions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Python projects, most of these remain exotic: parsers either don't exist or are poorly maintained. The standard is the same as ten years ago: YAML and environment variables for applications, TOML for tools, JSON for APIs. The rest shows up when needed, usually when a team moves off HCL-based infrastructure and wants to read the same files from the application.&lt;/p&gt;

&lt;p&gt;But whichever format you pick, it only solves parsing. The parser finishes the file, and the guarantees end there. &lt;code&gt;port: -1&lt;/code&gt; is perfectly valid, because a format checks syntax, not meaning. And if you want to override defaults with local settings, welcome to a manual &lt;code&gt;dict.update()&lt;/code&gt; that wipes out nested keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema in code: when formats stopped being enough
&lt;/h2&gt;

&lt;p&gt;By the end of the 2010s it was clear that choosing a format is only half the job. The parser returns a &lt;code&gt;dict&lt;/code&gt;, and then comes what no format solves: checking that &lt;code&gt;port&lt;/code&gt; is a number from 1 to 65535, that required fields are present, and that a typo in a key won't take the service down three days after release. That check gets written in code anyway; the only question is in what form.&lt;/p&gt;

&lt;p&gt;Python 3.7 brought dataclasses, and for the first time the config schema got a proper home, next to the types rather than in comments on a dictionary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DatabaseConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;pool_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dataclasses don't manage configuration on their own; they're just a way to describe the shape of data. Parsing the file, mapping keys to fields, coercing types, merging sources: all of that is still on the developer. But an important shift happens here: &lt;strong&gt;the schema separates from loading.&lt;/strong&gt; Previously the structure lived in the author's head and was checked with &lt;code&gt;if "host" in config["database"]:&lt;/code&gt;. Now it lives as types visible to the IDE, mypy, and any third-party library.&lt;/p&gt;

&lt;p&gt;That shift opened the road to the next generation of tools: Pydantic, attrs + cattrs, Dynaconf, and others. They take the schema described in code and build everything else around it: parsing, validation, source merging. From this point we stop discussing formats and start building infrastructure around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the last ten years
&lt;/h2&gt;

&lt;p&gt;The evolution of formats and the arrival of loaders is only half the story. On top of it came changes in the environment where applications live, and over the last decade nearly everything has changed. What a single &lt;code&gt;config.ini&lt;/code&gt; used to handle has become a full infrastructure problem, driven by several forces at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Microservices.&lt;/strong&gt; When the application is not a monolith but dozens of services, each has its own settings, but there are shared ones too: observability endpoints (Sentry, Prometheus, Jaeger), log levels, feature flags, neighboring services' addresses. How do you avoid duplication? How do you update a setting for everyone at once? The classic "file in the repository" approach stops working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Many environments.&lt;/strong&gt; A modern application doesn't live in two worlds (dev and prod) but in a whole ecosystem: local, dev, testing/QA, staging, production, DR (disaster recovery), and sometimes a preview environment per pull request. Each needs its own settings on a common base. Hence the need for config inheritance and for knowing exactly where every value came from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets.&lt;/strong&gt; The days when you could keep passwords in a &lt;code&gt;.env&lt;/code&gt; file are over (though many still do). The scale of the problem is striking: according to GitGuardian, more than 29 million leaked secrets were detected on public GitHub in 2025 alone. In 2024, JFrog researchers found a token on Docker Hub with admin access to the repositories of Python itself and PyPI, a potentially catastrophic leak that was fixed within 17 minutes. The cost of a mistake is growing faster than the maturity of our practices.&lt;/p&gt;

&lt;p&gt;In response, a separate class of tools appeared: HashiCorp Vault, cloud secret managers (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault), SOPS for encrypting secrets in git, Sealed Secrets for Kubernetes. Secrets now live in a specialized store rather than in &lt;code&gt;.env&lt;/code&gt;, and the application has to know how to work with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secret rotation.&lt;/strong&gt; The main shift of recent years: secrets must change automatically, without a developer's involvement. The old model, "generate a password for five years, drop it in &lt;code&gt;.env&lt;/code&gt;, and forget it," stopped working with modern security requirements and supply-chain attacks. Vault and AWS Secrets Manager support rotation out of the box. For a database it looks like this: every N hours the service creates a new password, updates it in the DB, and hands it to applications. The application has to pick up the new password on the fly, which adds a requirement for config: re-read secrets at runtime without restarting the process.&lt;/p&gt;

&lt;p&gt;Through all this, local development must not break. &lt;code&gt;.env&lt;/code&gt; is a poor place to store production secrets, but as a local-development tool it works fine: one file, everything at hand, no Vault, no network access to a secret manager. Production, meanwhile, should pull the same values from a protected store. A configuration tool should be able to reproduce both scenarios, and the transition between them should be painless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic configuration.&lt;/strong&gt; The classic approach: change the config, restart the app. But in a world where downtime is costly, you want to turn knobs live: a log level for debugging, a pool size, A/B experiment weights. The configuration system doesn't have to do much: notice the change and hand over the new value.&lt;/p&gt;

&lt;p&gt;The real difficulty starts on the application side. Re-reading &lt;code&gt;.env&lt;/code&gt; or hitting a store's API is five lines. Carefully reopening a DB pool, resubscribing to a queue, or changing a logger level without losing context is a task of a different order. When we talk about dynamic configuration, we're really talking about the five lines.&lt;/p&gt;

&lt;p&gt;All of these challenges moved the threshold. For some teams, config is still "read a file and parse it." For others, it's a full infrastructure layer.&lt;/p&gt;

&lt;p&gt;If you have one service, two environments, and secrets in &lt;code&gt;.env&lt;/code&gt;, python-dotenv covers you with room to spare, and you can stop reading. But once you add a secret manager, several environments with a shared base of settings, and a wish to understand where a specific value came from, built-in tools run out. That's where the conversation about tools begins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements for modern configuration
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzju1rnufd61zkucpbrci.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzju1rnufd61zkucpbrci.png" alt="Seven requirements" width="479" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before picking a tool, it's worth pinning down what a production application (a web service, a data pipeline, a microservice) expects from it. I end up with seven criteria, a ruler you can hold against any solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Many sources.&lt;/strong&gt; Defaults in YAML, overrides in TOML, secrets in environment variables or Docker secrets, local tweaks in &lt;code&gt;.env&lt;/code&gt;: you want the tool to read all of this out of the box, without plugins or custom adapters for each format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Controlled merging.&lt;/strong&gt; Layering a local config over defaults is the simplest case. But what if, for a list of servers, you need to &lt;em&gt;extend&lt;/em&gt; rather than replace? What if two sources setting different values for the same field is an error, not expected behavior? You need conflict-resolution strategies. When two sources set the same field, the tool must know whose value to keep. There are several options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Take the value from the last source: overrides win over defaults.&lt;/li&gt;
&lt;li&gt;Take it from the first source: base values are protected from being overwritten.&lt;/li&gt;
&lt;li&gt;Fail with an error, if a conflict means someone misconfigured something.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of them with the ability to set the rule not globally, but per field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type safety.&lt;/strong&gt; The schema should live in code, not in a developer's head. The IDE suggests fields, mypy catches typos, and the type in a signature documents expectations. Configuration is a contract, and it's best checked at process start, before connections come up and business logic starts running. Better to fail with a clear error in the first second than to get an &lt;code&gt;AttributeError&lt;/code&gt; in production an hour later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Value validation.&lt;/strong&gt; Types are necessary but not sufficient. A port must be between 1 and 65535. A URL must start with &lt;code&gt;http&lt;/code&gt;. A timeout can't be negative. Validation should be part of the schema, not a separate system you can forget to attach to a new field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clear errors.&lt;/strong&gt; When a config is invalid, the developer should see which field, which value, in which file, on which line. Not a stack trace from the depths of a parser, but a human-readable message that points at the problem, so you fix it in a minute instead of half an hour in the logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets at the schema level.&lt;/strong&gt; Passwords, tokens, and API keys should be marked right in the config model with a dedicated type (for example, &lt;code&gt;SecretStr&lt;/code&gt;). That's the first line of defense: in &lt;code&gt;repr()&lt;/code&gt;, in a config dump at startup, or in an error trace, the secret doesn't appear in plain text. Beyond that it's the job of the logger and the observability stack. All a configuration system owes you is a way to mark a field as sensitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging.&lt;/strong&gt; When production has a different value than you expected, you need to quickly understand where it came from. Which file? An environment variable? The defaults? Without source auditing it turns into a detective story: "let me &lt;code&gt;print(config)&lt;/code&gt; and rebuild the image."&lt;/p&gt;

&lt;p&gt;These seven are what a tool should solve out of the box, rather than every team solving again. When evaluating configuration solutions, start from this list: which of the seven are critical for your project, which are nice to have, which are optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five configuration anti-patterns I keep running into
&lt;/h2&gt;

&lt;p&gt;Seven criteria are the ideal a tool strives for. But even with a good tool, stubborn anti-patterns settle into the code: solutions that look fine in the short run and turn into bug factories in the long run. I run into these regularly in real projects, even where engineering culture is otherwise healthy. A configuration tool doesn't cure them, so it's important to understand them and consciously avoid them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A singleton with mutable state.&lt;/strong&gt; A global &lt;code&gt;config&lt;/code&gt; variable initialized once at startup and accessed from anywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config.py
&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_config&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# somewhere in a handler
&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;feature_flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new_payment_flow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The anti-pattern is that the object is mutable. Someone writes &lt;code&gt;config.feature_flags.new_payment_flow = True&lt;/code&gt; in the middle of a handler for debugging, forgets to remove it, and production starts doing things you can never reproduce on staging. Tests suffer first: one test changes a value, another fails, and nobody knows who's to blame. The cheapest insurance is &lt;code&gt;frozen=True&lt;/code&gt; on the dataclass: an attempted mutation raises &lt;code&gt;FrozenInstanceError&lt;/code&gt;. The more fundamental fix is dependency injection: pass config into a service's constructor instead of importing it globally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dictionary as config.&lt;/strong&gt; &lt;code&gt;config = yaml.safe_load(f)&lt;/code&gt;, and then accesses like &lt;code&gt;config["database"]["primary"]["host"]&lt;/code&gt; are scattered across the code. A dict is mutable, keys are strings, a typo is discovered only at runtime, and IDE and mypy are helpless. This is the same hardcode, hidden one level of abstraction deeper. Developers moved config out of code into a file, but got no guarantees from it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Config via monkeypatching in tests.&lt;/strong&gt; Tests replace config values with &lt;code&gt;mock.patch&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@mock.patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.config.DATABASE_TIMEOUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_timeout&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works until someone renames a field or changes the config structure. Then the patch silently stops applying, the test keeps passing, and nobody checks real behavior until a timeout blows up in production. DI helps here too: if a service takes config as an argument, the test passes what it needs, with no string-path mocks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole config as a JSON string in one environment variable.&lt;/strong&gt; This shows up when a team runs into env vars not supporting hierarchy and decides, "let's put the whole hierarchy into one variable." The drawbacks are obvious: changing a value means editing a long JSON string in a terminal, and a typo can't be caught before launch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;APP_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"database":{"host":"localhost","port":5432},"redis":{"url":"redis://..."}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern has a legitimate niche: configuration that is inherently tree-shaped while the transport is flat. For example, logging settings, where a logger hierarchy (&lt;code&gt;app.db.pool&lt;/code&gt;, &lt;code&gt;app.api.handlers&lt;/code&gt;) with levels and handlers decomposes poorly into dozens of &lt;code&gt;LOG_LEVEL_APP_DB_POOL=...&lt;/code&gt; variables. The result is worse than one JSON string with a clear structure. A similar story is Lambda with its hard deployment size limit and no way to mount a file.&lt;/p&gt;

&lt;p&gt;The rule is simple: if a configuration object is truly hierarchical and there's no other transport, JSON-in-env is acceptable, strictly for that object. If the entire application config is packed this way, it's a sign it's time to mount a file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Magic environment variables scattered across the code.&lt;/strong&gt; The logic drifts. One module compares to &lt;code&gt;"production"&lt;/code&gt;, another to &lt;code&gt;("staging", "production")&lt;/code&gt;, a third checks for inequality. Six months later nobody remembers which behavior is expected in which environment, and when you add a new &lt;code&gt;qa&lt;/code&gt; environment, the app behaves differently in different modules.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# somewhere
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ENV&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;use_real_payment_gateway&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;


&lt;span class="c1"&gt;# elsewhere, in another module
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ENV&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;enable_sentry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="c1"&gt;# in a third
&lt;/span&gt;&lt;span class="n"&gt;DEBUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ENV&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is centralization: one place loads the config and normalizes it into typed flags (&lt;code&gt;is_production: bool&lt;/code&gt;, &lt;code&gt;enable_payments: bool&lt;/code&gt;), which are then handed out via DI. Without that centralization, the pitfalls multiply in proportion to the number of modules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In 2017 I looked at that Django 1.x project with hardcoded values in &lt;code&gt;settings.py&lt;/code&gt; and thought the main problem was "get everything out of the code." Today I know the task has many layers: sources, merging, validation, secrets, rotation, debugging, testing. And I understand that the simplicity left behind in the Django era of one &lt;code&gt;settings.py&lt;/code&gt; was also a value, one we'll probably never get back.&lt;/p&gt;

&lt;p&gt;What hasn't changed is the principle of separating config from code. It was in the 2011 Heroku manifesto and it's still here in 2026. So is the idea that configuration is a contract between an application and its environment, and a contract has to be checked. And so is the understanding that a config mistake costs more than a code mistake: code fails in tests, config fails in production, at three in the morning, under load.&lt;/p&gt;

&lt;p&gt;What changes is the sources, which increasingly move to dedicated services: Vault, AWS Secrets Manager, etcd. The config lifecycle changes too: static config with restarts gives way to dynamic config with live pickup and automatic rotation. Merging of several sources migrated from the application into infrastructure, and from there we want to bring it back under the control of code.&lt;/p&gt;

&lt;p&gt;Expectations for error UX have gotten stricter: a stack trace from deep inside a parser is no longer acceptable; we need diagnostics with coordinates. Configuration now needs testing as well, so that config becomes as much an object of control as business logic. Cover the validation schema with unit tests so an invalid &lt;code&gt;port&lt;/code&gt; fails on load rather than at runtime; check that secrets don't leak into dumps and logs; build the config for every environment in CI so &lt;code&gt;staging&lt;/code&gt; doesn't ship to prod with someone else's values.&lt;/p&gt;

&lt;p&gt;I don't recommend choosing a tool by GitHub stars. Take the seven criteria above and decide what is critical in your project, what's nice, and what's superfluous. A microservice in Kubernetes needs one thing, a CLI utility another, an ML pipeline a third. There's no universal answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; &lt;a href="https://dev.to/niccolum/why-no-python-configuration-library-covers-everything-1phe"&gt;Part 2&lt;/a&gt; looks at how python-decouple, Dynaconf, Hydra, and pydantic-settings cover these seven requirements in 2026.&lt;/p&gt;

&lt;p&gt;Which of the seven hurts most in your project? I'd like to hear about it in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Useful links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://12factor.net/" rel="noopener noreferrer"&gt;The Twelve-Factor App manifesto&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://news.ycombinator.com/item?id=37858507" rel="noopener noreferrer"&gt;Criticism of the 12-Factor App manifesto&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.gitguardian.com/the-state-of-secrets-sprawl-2026/" rel="noopener noreferrer"&gt;GitGuardian data on leaked secrets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jfrog.com/blog/leaked-pypi-secret-token-revealed-in-binary-preventing-suppy-chain-attack/" rel="noopener noreferrer"&gt;The admin-level token for Python and PyPI repositories found on Docker Hub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>devops</category>
      <category>configuration</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
