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:
database: sqlite:///test.db
port : 8000 int
debug : False bool
-> debug mode is off
port + 1 = 8001Now the same thing with the variables — the way it starts on a server:
PORT=8080 DEBUG=false DATABASE_URL=postgresql://prod/db python3 app.pydatabase: postgresql://prod/db
port : '8080' str
debug : 'false' str
-> debug mode is ON
port + 1 = TypeError: can only concatenate str (not "int") to strOne 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:
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:
database: postgresql://prod/db
port : 8080 int
debug : False bool
port+1 : 8081DEBUG=false became False, as any common sense expects. And if a value
turns out to be junk:
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
.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.
DATABASE_URL=postgresql://user:password@localhost/dbname
API_KEY=
PORT=8000
DEBUG=falseAnd 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
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.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.
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.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=falsewithout 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.
.envinto.gitignore,.env.exampleinto 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.