5.13. Configuration
Table of Contents
Why Configuration Matters
Configuration is everything in your app that can change between environments or over time, without changing the code.
Examples of things that should be configuration, not hard coded:
- Database connection URLs
- API keys and secrets
- Debug / log level
- Third party service URLs
- Feature flags (turn features on or off)
If you hard code these values:
- You must edit code to deploy to another environment.
- You risk committing secrets to Git.
- It becomes harder to debug and reproduce issues.
A well configured Python backend:
- Keeps code and configuration separate.
- Loads configuration from clear, predictable places.
- Validates configuration at startup.
- Makes it easy to override values for tests.
In the rest of this chapter you will see practical ways to do this in Python.
Important rule: Never hard code secrets or environment specific values in your source code. Always read them from configuration.
Types of Configuration
A backend usually uses several layers of configuration. You can think of them from most global to most specific.
| Level | Examples | Typical Format |
|---|---|---|
| Environment variables | DATABASE_URL, DEBUG, SECRET_KEY | Shell environment |
| Configuration files | config.toml, settings.yaml, .env | Text files |
| Command line arguments | --port 8080, --log-level=debug | CLI flags |
| Code defaults | DEFAULT_PORT = 8000, fallback values | Python constants |
A common priority order is:
- Command line arguments
- Environment variables
- Configuration file
- Code defaults
So a value from a CLI flag overrides one from an environment variable, which overrides a config file, which overrides code defaults.
You do not always need all of these. For many web APIs:
- Environment variables + code defaults are enough.
- A
.envfile is handy during development. - CLI flags are optional.
Configuration Sources in Python
Reading environment variables
You already learned about environment variables in another chapter. In Python you access them with os.environ or os.getenv.
import os
# Raises KeyError if not set
database_url = os.environ["DATABASE_URL"]
# Returns default if not set
debug = os.getenv("DEBUG", "false")If a variable is required, it is better to fail early.
import os
import sys
def get_required_env(name: str) -> str:
value = os.getenv(name)
if value is None or value == "":
print(f"Missing required environment variable: {name}", file=sys.stderr)
sys.exit(1)
return value
DATABASE_URL = get_required_env("DATABASE_URL")Using a `.env` file
A .env file is a text file with KEY=value lines, usually not committed to Git.
Example .env:
DATABASE_URL=postgresql://user:pass@localhost:5432/app
DEBUG=true
SECRET_KEY=super-secret-key
You can load it with the python-dotenv package:
pip install python-dotenvfrom dotenv import load_dotenv
import os
# Loads variables from .env into the environment
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
This is useful for local development. In production you typically set real environment variables instead of using .env.
Configuration files (YAML, TOML, JSON)
Sometimes it is convenient to have a structured config file.
Example config.yaml:
app:
debug: false
port: 8000
database:
url: postgresql://user:pass@localhost:5432/app
You can read it using pyyaml:
pip install pyyamlimport yaml
from pathlib import Path
config_path = Path("config.yaml")
with config_path.open() as f:
config = yaml.safe_load(f)
DEBUG = config["app"]["debug"]
PORT = config["app"]["port"]
DATABASE_URL = config["database"]["url"]The same idea works with JSON or TOML. Pick what fits your project and team.
Organizing Configuration in a Python Project
A central settings module
You want one clear place to read and validate configuration, so the rest of your code can just import it.
Create a settings.py module:
# app/settings.py
import os
from dotenv import load_dotenv
load_dotenv() # load .env if present
def _get_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.lower() in {"1", "true", "yes", "on"}
def _get_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except ValueError:
raise ValueError(f"Environment variable {name} must be an integer")
APP_NAME = os.getenv("APP_NAME", "example-app")
DEBUG = _get_bool("DEBUG", default=True)
PORT = _get_int("PORT", default=8000)
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./dev.db")Then everywhere else:
# app/main.py
from app import settings
def run():
print(f"Starting {settings.APP_NAME} on port {settings.PORT}")
The rest of the code does not need to know if values come from .env, real env vars, or defaults.
Using classes to group settings
You can also group settings logically using classes or dataclasses.
# app/settings.py
import os
from dataclasses import dataclass
from dotenv import load_dotenv
load_dotenv()
@dataclass
class AppSettings:
name: str
debug: bool
port: int
@dataclass
class DatabaseSettings:
url: str
pool_size: int
@dataclass
class Settings:
app: AppSettings
db: DatabaseSettings
def _get_bool(env: str, default: bool) -> bool:
value = os.getenv(env)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def _get_int(env: str, default: int) -> int:
value = os.getenv(env)
if value is None:
return default
return int(value)
def load_settings() -> Settings:
return Settings(
app=AppSettings(
name=os.getenv("APP_NAME", "my-app"),
debug=_get_bool("DEBUG", True),
port=_get_int("PORT", 8000),
),
db=DatabaseSettings(
url=os.getenv("DATABASE_URL", "sqlite:///./dev.db"),
pool_size=_get_int("DB_POOL_SIZE", 5),
),
)
settings = load_settings()Then:
from app.settings import settings
print(settings.app.name)
print(settings.db.url)This keeps configuration tidy as your application grows.
Environment Specific Configuration
Your application usually runs in several environments:
| Environment | Purpose | Typical values |
|---|---|---|
development | Local development on your machine | Debug on, local sqlite, verbose logs |
test | Automated tests | In memory DB, fake external services |
staging | Pre production, almost like production | Real DB, debug off, test API keys |
production | Real users | Debug off, secure settings, real keys |
You can represent the environment with a variable like APP_ENV or ENVIRONMENT.
Simple branching by environment
# app/settings.py
import os
from dataclasses import dataclass
ENV = os.getenv("APP_ENV", "development")
@dataclass
class Settings:
debug: bool
database_url: str
def get_settings() -> Settings:
if ENV == "development":
return Settings(
debug=True,
database_url="sqlite:///./dev.db",
)
elif ENV == "test":
return Settings(
debug=False,
database_url="sqlite:///:memory:",
)
elif ENV == "production":
return Settings(
debug=False,
database_url=os.environ["DATABASE_URL"], # must be set
)
else:
raise ValueError(f"Unknown APP_ENV: {ENV}")
settings = get_settings()Usage:
from app.settings import settings
print(settings.debug)
print(settings.database_url)
Each environment can have its own .env file or real environment variables.
Example:
.env.development.env.test.env.production
You can tell python-dotenv which file to load depending on APP_ENV.
# app/settings.py
from dotenv import load_dotenv
import os
from pathlib import Path
ENV = os.getenv("APP_ENV", "development")
env_file = Path(f".env.{ENV}")
if env_file.exists():
load_dotenv(env_file)
else:
load_dotenv() # fallback to .envPer environment config classes
Another pattern is to subclass a base config:
class BaseSettings:
DEBUG = False
DATABASE_URL = "sqlite:///./base.db"
class DevelopmentSettings(BaseSettings):
DEBUG = True
DATABASE_URL = "sqlite:///./dev.db"
class TestSettings(BaseSettings):
DEBUG = False
DATABASE_URL = "sqlite:///:memory:"
class ProductionSettings(BaseSettings):
DEBUG = False
DATABASE_URL = os.environ["DATABASE_URL"]
def get_settings():
env = os.getenv("APP_ENV", "development")
if env == "development":
return DevelopmentSettings()
if env == "test":
return TestSettings()
if env == "production":
return ProductionSettings()
raise ValueError(f"Unknown APP_ENV {env}")
settings = get_settings()This is easy to extend as you add more settings.
Validating and Parsing Configuration
Configuration is external input. Treat it like user input:
- It can be missing.
- It can have the wrong type.
- It can have invalid values.
You should validate it early and loudly, ideally on startup.
Manual validation
You can write small helper functions to parse and validate.
import os
def env_int(name: str, default: int | None = None) -> int:
value = os.getenv(name)
if value is None:
if default is not None:
return default
raise RuntimeError(f"Missing env var {name}")
try:
return int(value)
except ValueError:
raise RuntimeError(f"Env var {name} must be an integer, got {value!r}")
def env_choice(name: str, choices: list[str], default: str | None = None) -> str:
value = os.getenv(name, default)
if value is None:
raise RuntimeError(f"Missing env var {name}")
if value not in choices:
raise RuntimeError(f"{name} must be one of {choices}, got {value!r}")
return value
PORT = env_int("PORT", default=8000)
LOG_LEVEL = env_choice("LOG_LEVEL", ["DEBUG", "INFO", "WARNING", "ERROR"], default="INFO")If something is wrong, the app stops with a clear error instead of failing later in a confusing way.
Using Pydantic for configuration
Pydantic is a data validation library. It is very popular with FastAPI, but you can use it in any Python backend.
pip install pydanticExample:
from pydantic import BaseSettings, AnyUrl, Field
class Settings(BaseSettings):
app_name: str = "my-app"
debug: bool = True
port: int = 8000
database_url: AnyUrl
log_level: str = Field("INFO", regex="^(DEBUG|INFO|WARNING|ERROR)$")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()Pydantic will:
- Load values from environment variables, for example
DATABASE_URL. - Use defaults when variables are absent.
- Coerce types, for example string
"true"tobool. - Validate formats, for example
AnyUrlmust be a valid URL. - Raise a clear error if something is invalid.
Access values like this:
print(settings.database_url)
print(settings.debug)You can add prefixes so that environment variables are grouped.
class ApiSettings(BaseSettings):
host: str = "0.0.0.0"
port: int = 8000
class Config:
env_prefix = "API_"
api_settings = ApiSettings()
Then Pydantic will look for API_HOST and API_PORT.
Important rule: Validate configuration at application startup. Fail fast if required settings are missing or invalid.
Handling Secrets and Sensitive Configuration
Secrets include:
- Database passwords and URLs
- API keys
- JWT signing keys
- OAuth client secrets
- Encryption keys
You must handle them with special care.
Do not commit secrets
Never store real secrets in:
- Git tracked files, for example
config.py,settings.py,config.yaml. - Docker images as plain text.
- Documentation or logs.
Safer options:
- Environment variables set on the server.
.envfiles that are excluded from Git using.gitignore.- Secrets managers, for example HashiCorp Vault, AWS Secrets Manager, etc.
Example .gitignore entry:
.env
.env.*
secrets.yamlMark secrets clearly
In code, separate secrets from non secret configuration.
# app/settings.py
from pydantic import BaseSettings, SecretStr
class Settings(BaseSettings):
debug: bool = False
database_url: SecretStr
jwt_secret_key: SecretStr
class Config:
env_file = ".env"
settings = Settings()
print(settings.debug)
# Avoid printing secrets:
# print(settings.database_url) # prints "SecretStr('**********')"
Even if you accidentally log settings.database_url, Pydantic will hide the actual value.
Do not log secrets
Be careful when logging configuration. Only log non sensitive information.
from app.settings import settings
import logging
logger = logging.getLogger(__name__)
logger.info("App starting",
extra={
"debug": settings.debug,
"database_url": "hidden", # never log real URL with password
})Secret rotation
Sometimes a secret must be changed, for example when it leaks. Your configuration approach should make it easy to:
- Set a new value in the environment or secrets manager.
- Restart the application to pick up the new secret.
- Not rebuild or redeploy the code just to change a secret.
This is another reason not to hard code secrets.
Configuration and Testing
Good configuration design makes tests easier and more reliable.
Overriding configuration in tests
You can override environment variables inside a test to simulate different settings.
With pytest:
# test_settings.py
import os
from app import settings
def test_debug_default_is_true(monkeypatch):
monkeypatch.delenv("DEBUG", raising=False)
from importlib import reload
reload(settings) # reload module to re-evaluate env
assert settings.DEBUG is True
def test_debug_can_be_false(monkeypatch):
monkeypatch.setenv("DEBUG", "false")
from importlib import reload
reload(settings)
assert settings.DEBUG is FalseUsing Pydantic based settings, you can create instances directly for tests.
from app.settings import Settings
def test_custom_settings():
s = Settings(
app_name="test-app",
debug=False,
port=9000,
database_url="sqlite:///:memory:",
)
assert s.debug is False
assert s.port == 9000Using a separate test environment
You can set APP_ENV=test for your test runs and load special settings that:
- Use an in memory or temporary database.
- Use fake external services.
- Disable background jobs.
Example pytest configuration in pytest.ini:
[pytest]
env =
APP_ENV=test
Then your settings.py can check APP_ENV and return test friendly values, as shown earlier.
Configuration Best Practices
Here is a summary of practical rules you can apply now:
Configuration rules
- Keep configuration out of code. Use environment variables, config files, or both.
- Use a single place for configuration. A
settings.pyor similar module. - Validate configuration at startup. Fail fast on missing or invalid values.
- Support environments. Use an
APP_ENVvariable to adjust behavior. - Never commit secrets. Use environment variables or a secrets manager.
- Log configuration carefully. Never log secrets or passwords.
- Make tests configurable. Allow settings overrides for tests.
Example: small FastAPI style settings (without explaining FastAPI itself)
Even if you are not using FastAPI yet, this shows a compact pattern you can reuse:
# app/config.py
from pydantic import BaseSettings, AnyUrl, SecretStr
class Settings(BaseSettings):
app_name: str = "example-api"
environment: str = "development"
debug: bool = True
port: int = 8000
database_url: AnyUrl = "sqlite:///./dev.db"
jwt_secret_key: SecretStr
class Config:
env_file = ".env"
env_prefix = "APP_"
settings = Settings()
.env example:
APP_ENVIRONMENT=production
APP_DEBUG=false
APP_PORT=8080
APP_DATABASE_URL=postgresql://user:pass@db:5432/app
APP_JWT_SECRET_KEY=super-secret-keyAnywhere in your code:
from app.config import settings
if settings.debug:
print("Debug mode enabled")
db_url = str(settings.database_url) # normal string
jwt_secret = settings.jwt_secret_key.get_secret_value()This pattern gives you:
- Clear default values.
- Strong typing and validation.
- Easy overriding via environment variables.
- Separation between secrets and non secrets.
You now have the main tools you need to configure Python backend applications safely and flexibly.
Views: 7
KAHIBARO