Posted on ::

The environment is not your .env file

Centralizing config with pydantic-settings, and the two errors I hit getting there


TLDR: Two config() calls in two modules drew a review comment about centralizing config. Moving them into a pydantic-settings class took two wrong turns: first a Settings class that wasn't reading my .env, and then one that rejected a key for existing.


Two keys, two files

I was a few weeks into an Agentic AI cohort, building an expense classifier that calls an LLM through the OpenAI SDK and converts amounts through a rate API. Two services, two API keys, read with python-decouple at module scope:

# llms/openai.py
OPENAI_API_KEY = config("OPENAI_API_KEY")

# utils/currency.py
EXCHANGE_RATE_API_KEY = config("EXCHANGE_RATE_API_KEY")

A reviewer left a comment on the first one:

Later on I would centralize all config processing in one place.

He suggested I could keep it for now, but the project was small and already had two secrets living in two modules, each read at import time. It felt like the right moment to refactor.

One field at a time

The suggested direction was pydantic-settings. It fits a project already full of Pydantic models, and it avoids the module-level globals. A BaseSettings subclass reads its fields from the environment when you construct it.

I started with the OpenAI side:

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    openai_api_key: str

The key was already in .env from the decouple setup, so I expected this to just work. It did not:

pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
openai_api_key
  Field required [type=missing, input_value={}, input_type=dict]

To check whether the value was really coming from where I thought, I commented the key out of .env and ran it again.

Nothing changed.

That was the first gotcha, and it cost me a while: BaseSettings reads the environment, and my .env file is not the environment. It is a file that needs instructions to be loaded. decouple had been doing that quietly. Pydantic will too, but only once you point it at the file:

model_config = SettingsConfigDict(env_file=".env")

The field that wasn't there

Now that the file was being read, I ran it again and got a different error:

pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
exchange_rate_api_key
  Extra inputs are not permitted [type=extra_forbidden, input_value='...', input_type=str]

I had written exactly one field, openai_api_key, and here was Pydantic complaining about a second one by name.

The name in the error was the clue I skipped past twice. .env still held EXCHANGE_RATE_API_KEY from the decouple configuration, because currency.py was still using it. Now that Settings was reading the file, it saw a key it had no field for, and rejected it. It also reported the name in Python field form, lowercase, rather than the uppercase form sitting in my .env, which is why it didn't look like something from that file at all.

The fix was the next step of the migration anyway:

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    openai_api_key: str
    exchange_rate_api_key: str

    model_config = SettingsConfigDict(env_file=".env")

Which is the version that got committed. The two errors above live nowhere in the git history, only in the twenty minutes between writing the class and getting it green.

Where the object gets built

OpenAIAssistant takes an optional api_key, so the class has an obvious place to fall back to config:

if api_key:
    self.api_key = api_key
else:
    settings = Settings.model_validate({})
    self.api_key = settings.openai_api_key

model_validate({}) instead of Settings() because ty, the type checker, flags a bare Settings() for missing required arguments. It can't see that the environment is going to fill them in. Validating from an empty dict keeps ty quiet while the env sources still load and still raise at runtime.

Currency was the harder half. convert_currency is a plain function with no constructor to hide the lookup in, and I didn't want a module-level Settings() because that reintroduces the exact import-time read I was removing. It ended up inside the function, after the early return:

if from_currency == to_currency:
    return decimal_amount
settings = Settings.model_validate({})

Converting EUR to EUR needs no API key, so the construction sits after the short circuit.

The old guard came out at the same time:

if not EXCHANGE_RATE_API_KEY:
    raise ValueError("EXCHANGE_RATE_API_KEY is not set")

A required field on Settings already does this. Missing key, ValidationError, at the point of use.

With both modules migrated, grep -rn decouple src/ came back empty, and uv remove python-decouple took it out of pyproject.toml and the lockfile together.

What it actually bought

The payoff didn't show up that week. It showed up in the commits after it.

The CLI needed a database path, so database_url went on Settings. The Telegram bot needed a token: telegram_bot_token. Error reporting needed somewhere to send tracebacks: developer_chat_id. Three new settings across three weeks, each one a single line added to a single class, validated at construction like the rest.

The strictness that had confused me turned out to be the feature. A key in .env that no field claims is an error, so the class can't drift out of sync with the file. It answers the question I couldn't answer when the keys lived in two modules: what does this app actually need to run?


Config stopped being something I remembered and started being something the program checks.