Settings and Environments
20 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)A real app runs in several places, each with its own database address and secret. These values belong in the environment, not in the code. A small settings object reads them once and checks them.
Settings change with the place the app runs
Environment variable is a named text value set outside the program, by the operating system or the hosting service. A program reads it from os.environ.
The same code runs on your laptop, in tests and on a server. Some values differ: the database address, the secret key, a debug switch. Keep these out of the code. The usual place is an environment variable: a named text value that the operating system gives to a program.
import os
env = os.environ.get("APP_ENV", "development")port = int(os.environ.get("APP_PORT", "8000"))Example 1 · Reading environment variables with defaults
import osfrom unittest.mock import patch
print(os.environ.get("APP_ENV", "development"))
with patch.dict(os.environ, {"APP_ENV": "production", "APP_PORT": "9000"}): env = os.environ.get("APP_ENV", "development") port = int(os.environ.get("APP_PORT", "8000")) print(env, port, type(port).__name__) print(os.environ.get("APP_DEBUG", "false") == "true")
print(os.environ.get("APP_ENV", "development"))development production 9000 int False development
What this code does
patch.dictsets variables only inside thewithblock, so the demo changes nothing for good.- The value
9000arrives as text and is converted withint.
os.environ behaves like a dict of text. get takes a default for when the variable is not set. Every value is text, so convert numbers yourself.
One frozen settings object
Settings object is one object that holds every configuration value of the app. It is built once from the environment and then only read.
Reading os.environ all over the code is hard to follow and hard to test. Read it once, in one function, and put the values into one object. A frozen dataclass cannot be changed after it is created, so no code can change a setting by accident.
from dataclasses import dataclass
@dataclass(frozen=True)class Settings: app_env: str = "development" database_url: str = "sqlite://" secret_key: str = "dev-secret" debug: bool = FalseExample 2 · A frozen settings dataclass
from dataclasses import FrozenInstanceError, dataclass
@dataclass(frozen=True)class Settings: app_env: str = "development" database_url: str = "sqlite://" debug: bool = False
default = Settings()prod = Settings(app_env="production", database_url="postgresql://db.example/app")print(default)print(prod.app_env, prod.database_url)
try: prod.debug = Trueexcept FrozenInstanceError: print("settings are read-only")Settings(app_env='development', database_url='sqlite://', debug=False) production postgresql://db.example/app settings are read-only
What this code does
- A frozen dataclass raises an error on any assignment.
- Create a new object when you need different settings, for example in a test.
The defaults make the app run on a laptop with no setup. A new field with a default does not break old code.
Convert and check the values
Environment values are text, so "false" and "0" are both truthy strings. A loader must convert them: numbers with int, switches by comparing text. A Pydantic model does this for you and raises an error for a bad value. The library pydantic-settings adds a BaseSettings class that reads the environment by itself. It is not installed in this course, so we write the small loader ourselves.
from pydantic import BaseModel
class Settings(BaseModel): app_env: str = "development" port: int = 8000 debug: bool = False
def load(env): return Settings( app_env=env.get("APP_ENV", "development"), port=env.get("APP_PORT", 8000), debug=env.get("APP_DEBUG", "false"), )Example 3 · A Pydantic settings model with a loader
import osfrom unittest.mock import patchfrom pydantic import BaseModel, ValidationError
class Settings(BaseModel, frozen=True): app_env: str = "development" port: int = 8000 debug: bool = False
def load(env): values = {} if "APP_ENV" in env: values["app_env"] = env["APP_ENV"] if "APP_PORT" in env: values["port"] = env["APP_PORT"] if "APP_DEBUG" in env: values["debug"] = env["APP_DEBUG"] return Settings(**values)
print(load({}))print(load({"APP_PORT": "9000", "APP_DEBUG": "true"}))try: load({"APP_PORT": "abc"})except ValidationError as err: print(err.errors()[0]["loc"], err.errors()[0]["type"])
with patch.dict(os.environ, {"APP_ENV": "staging"}): print(load(os.environ).app_env)app_env='development' port=8000 debug=False
app_env='development' port=9000 debug=True
('port',) int_parsing
stagingWhat this code does
- The loader takes any mapping, so tests can pass a plain dict and production passes
os.environ. - A bad port fails at once, not later in the middle of a request.
Pydantic turns "9000" into 9000 and "true" into True. A value such as "abc" for the port stops the app at start-up with a clear error.
Settings in routes, tests and production
Give routes the settings through a dependency. Wrap the loader in lru_cache so it is built once. In a test, override the dependency with test settings. In production, refuse to start with a default secret.
from functools import lru_cache
@lru_cachedef get_settings(): return load(os.environ)
@app.get("/info")def info(settings: Settings = Depends(get_settings)): return {"env": settings.app_env}Example 4 · Settings as a dependency, cached and overridden
from fastapi import FastAPIfrom fastapi.testclient import TestClient
app = FastAPI()import osfrom functools import lru_cachefrom unittest.mock import patchfrom fastapi import Dependsfrom pydantic import BaseModel
class Settings(BaseModel, frozen=True): app_env: str = "development" page_size: int = 10
@lru_cachedef get_settings(): return Settings( app_env=os.environ.get("APP_ENV", "development"), page_size=os.environ.get("APP_PAGE_SIZE", 10), )
@app.get("/info")def info(settings: Settings = Depends(get_settings)): return {"env": settings.app_env, "page_size": settings.page_size}
c = TestClient(app)print(c.get("/info").json())
with patch.dict(os.environ, {"APP_ENV": "staging"}): print(c.get("/info").json()) get_settings.cache_clear() print(c.get("/info").json())
app.dependency_overrides[get_settings] = lambda: Settings(app_env="test", page_size=2)print(c.get("/info").json()){'env': 'development', 'page_size': 10}
{'env': 'development', 'page_size': 10}
{'env': 'staging', 'page_size': 10}
{'env': 'test', 'page_size': 2}What this code does
- The second call still shows
developmentbecauselru_cachekept the first result. cache_clear()reloads the settings. In tests, the override is simpler and safer.
Example 5 · Refuse a default secret in production
import osfrom unittest.mock import patchfrom dataclasses import dataclass
DEFAULT_SECRET = "dev-secret"
@dataclass(frozen=True)class Settings: app_env: str secret_key: str
def load(env): settings = Settings( app_env=env.get("APP_ENV", "development"), secret_key=env.get("APP_SECRET_KEY", DEFAULT_SECRET), ) if settings.app_env == "production" and settings.secret_key == DEFAULT_SECRET: raise RuntimeError("APP_SECRET_KEY must be set in production") return settings
print(load({}).app_env)print(load({"APP_ENV": "production", "APP_SECRET_KEY": "long-random-value"}).app_env)try: load({"APP_ENV": "production"})except RuntimeError as err: print(err)development production APP_SECRET_KEY must be set in production
What this code does
- A default is fine on a laptop. In production, a missing secret must stop the app.
- The check runs once at start-up, before any request is served.
lru_cache remembers the first result. A test that changes the environment must call get_settings.cache_clear() or use the override instead.
Next step
Check your understanding
12 quick questions