Skip to content

Environment variables silently fail to override YAML config values #19

Description

@craigmcchesney

Environment variables do not override values set in the YAML configuration file. This inverts levels 2 and 3 of the documented configuration priority, and it fails silently — no warning, no error.

Found while writing the configuration recipe for #15.

Behavior

The rule is per-key: a key absent from the YAML file is overridable by its MLDP_* env var; a key present in the file is not.

Given a mldp-config.yaml containing only ingestion: {host: localhost}:

Env var Present in YAML? Resolves to Correct?
MLDP_INGESTION_HOST=prod.example.com yes localhost ❌ env ignored
MLDP_INGESTION_PORT=443 no 443

Reproduction

mldp-config.yaml:

ingestion:
  host: localhost
import os
from dp_python_lib.config import MldpConfig

cfg = MldpConfig.from_yaml("mldp-config.yaml")
print(cfg.ingestion.host)   # 'localhost'  <- MLDP_INGESTION_HOST ignored
print(cfg.ingestion.port)   # 443          <- MLDP_INGESTION_PORT applied
MLDP_INGESTION_HOST=prod.example.com MLDP_INGESTION_PORT=443 python repro.py

Reproduced on dp-python-lib 1.15.0 with pydantic-settings 2.14.2.

Cause

MldpConfig.from_yaml() (src/dp_python_lib/config/config.py:82-109) flattens the YAML into flat_data and passes it as constructor keyword arguments:

return cls(**flat_data)          # config.py:109

In pydantic-settings, init kwargs are the highest-priority source — above environment variables. So every key the YAML file happens to define becomes un-overridable.

This is on the path MldpClient() actually takes: load_config() (loader.py:105) calls from_yaml() whenever a config file is discovered, and mldp-config.yaml in the working directory is discovered automatically.

Why it wasn't caught

tests/unit/test_config.py::test_environment_variable_override (line 87) constructs MldpConfig() directly, where env vars are the highest-priority source. It passes, and correctly so.

No test covers from_yaml() or load_config() with a YAML file present — which is the real-world path. The bug sits exactly in that gap.

Impact

Breaks the deployment pattern env vars exist for: one built image, per-environment overrides. A deployment that ships a mldp-config.yaml and expects MLDP_* to retarget it will silently connect to the wrong host.

The silence is the worst part — the client connects successfully, just not where intended.

Suggested fix

Route YAML through a settings source ranked below env vars, rather than through init kwargs, via settings_customise_sources:

@classmethod
def settings_customise_sources(cls, settings_cls, init_settings, env_settings,
                               dotenv_settings, file_secret_settings):
    # documented priority: init > env > yaml > defaults
    return (init_settings, env_settings, YamlSource(settings_cls), file_secret_settings)

I verified this approach restores the documented order on the current pydantic-settings version:

  • key in YAML + env var set → env wins
  • key in YAML, no env var → YAML wins (over the built-in default) ✅
  • key absent from YAML → default, or env var if set ✅

Whatever the fix, it should come with tests covering load_config() with a YAML file present, since that is the untested path where this hid.

Docs

Documented as a known bug with a ⚠️ callout in doc/cookbook/connecting.md (#15), including the verified workaround: keep the setting out of the YAML, or repoint the whole file with MLDP_CONFIG_FILE (that variable is read before the file is loaded, so it behaves as documented).

Remove that callout when this is fixed. README.md and CLAUDE.md both currently document the intended priority as though it works.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions