Programming with AI
ENRU
13.4

Configs and environments

three disasters from one small file

What it is

An environment is the set of settings the same code is started with. There are usually three: your machine, a test server, a production server. They differ not in code but in values: the database address, API keys, whether debugging is on.

Settings are moved out of the code for two reasons: so secrets aren't kept in the repository (article 5.7), and so one program can work in different places. The mechanism is the same everywhere — environment variables — and they hold exactly one subtlety that breaks projects: everything that comes from the environment is a string.

What it looks like in code

A settings file that looks fine

import os

DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///test.db")
PORT = os.environ.get("PORT", 8000)
DEBUG = os.environ.get("DEBUG", False)

Three lines, three disasters. And on your machine, where no variables are set, everything is flawless:

measured
database: sqlite:///test.db
port    : 8000 int
debug   : False bool
  -> debug mode is off
port + 1 = 8001

Now the same thing with the variables — the way it starts on a server:

PORT=8080 DEBUG=false DATABASE_URL=postgresql://prod/db python3 app.py
measured
database: postgresql://prod/db
port    : '8080' str
debug   : 'false' str
  -> debug mode is ON
port + 1 = TypeError: can only concatenate str (not "int") to str

One at a time.

1. DEBUG=false turned debugging on. The string 'false' is non-empty, and therefore truthy. The if DEBUG: check fires. On a production server that means detailed error pages, tracebacks facing outwards, and everything normally seen only by developers. A one-line mistake; the consequence is a leak.

2. The port became a string. The TypeError is actually the lucky case: at least it is loud. Worse is when the string passes on silently.

3. The type depends on whether the variable is set. On your machine PORT is a number, on the server a string. The same code, different types. You cannot check that locally at all.

And separately, the quietest of the three: DATABASE_URL with the default sqlite:///test.db. The variable wasn't set on the server — the application didn't crash, didn't complain, and started writing to a local file. You find out when the data is looked for and isn't there.

How it should be

from pydantic import Field
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    database_url: str
    api_key: str
    port: int = 8000
    debug: bool = False
    timeout: float = Field(default=5.0, gt=0)


settings = Settings()

The whole difference is in the types. port: int means the string '8080' becomes a number at startup or the program doesn't start. debug: bool means false becomes a real False. database_url and api_key have no defaults — which means without them there is no startup.

Running it with no variables:

measured
pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings
database_url
  Field required [type=missing, input_value={}, input_type=dict]
api_key
  Field required [type=missing, input_value={}, input_type=dict]

Both missing variables are named at once, at startup. Not one at a time, not mid-run, not an hour after the deploy.

With the variables:

measured
database: postgresql://prod/db
port    : 8080 int
debug   : False bool
port+1  : 8081

DEBUG=false became False, as any common sense expects. And if a value turns out to be junk:

measured
port
  Input should be a valid integer, unable to parse string as an integer
  [type=int_parsing, input_value='eight', input_type=str]

The setting is named, the value is named, the reason is named. That is the difference between "the program crashed" and "the program explained".

What goes into the repository and what doesn't

measured
.env           ← the real values. In .gitignore. Never committed.
.env.example   ← the same names, empty or sample values. Committed.

.env.example is a small file with a big payoff: it is the list of what has to be set for the project to start. Without it a new person (or you yourself in six months on another machine) learns about a missing variable by trial and error.

measured
DATABASE_URL=postgresql://user:password@localhost/dbname
API_KEY=
PORT=8000
DEBUG=false

And a reminder from article 12.4: in project templates the .env line may not be in .gitignore. That is checked once, with git ls-files, and preferably on day one.

Three environments, one codebase

The rule is simple: the code doesn't know where it is running. There are no if environment == "production" scattered across the project; there are settings that differ from place to place.

The one justified exception is a single variable such as ENV=production and a few decisions based on it in one place: the log level, whether to show detailed errors, whether to switch the API documentation on.

What to tell the AI

Prompt
Make a settings module with pydantic-settings.
I need: database_url (string, required),
api_key (string, required), port (int, default 8000),
debug (bool, default false).
Don't put defaults on the required ones.
Alongside it, a .env.example with all the names.
Prompt
Find every use of os.environ and os.getenv in the project.
For each, say: the value's type, whether there's a default,
and what happens if the variable isn't set.
As a table. Don't change the code yet.

That request is worth running on any project more than a month old: environment lookups spread unnoticed.

Prompt
Check this config for three mistakes: a string where a
number belongs, the string 'false' where a boolean belongs,
a default where there shouldn't be one. List what you find.
Prompt
The project should fail at startup if the required settings
aren't set. At the moment it fails later. Add a check at
startup, with a clear message listing everything missing at
once.

What to look for

os.environ.get with a default for a database address or a key. The quietest of this article's mistakes.

A setting used as a number or a flag without conversion. What comes from the environment is a string, always.

DEBUG=true as a string. Any non-empty string is truthy, 'false' and '0' included.

Settings read in ten places. One settings module; the rest of the code asks it.

.env in the repository. Checked with git ls-files.

No .env.example. The project can't be started without guessing the list of variables.

Keys written into the code "temporarily". The temporary lasts longest, and git remembers everything (article 5.7).

if production: all over the project. The code knows where it is running — which means the test server isn't testing what goes into production.

Cheat sheet

  • Everything that comes from the environment is a string. Always.

  • DEBUG=false without conversion turns debugging on.

  • A default for a required setting hides an error rather than helping.

  • Typed settings name every missing variable at once.

  • Settings are checked at startup, not at the moment of use.

  • .env into .gitignore, .env.example into the repository.

  • One settings module; the rest of the code reads it, not the environment.

  • The code shouldn't know which server it is running on.