KAHIBARO
Discord Login Register

5.13. Configuration

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:

If you hard code these values:

A well configured Python backend:

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.

LevelExamplesTypical Format
Environment variablesDATABASE_URL, DEBUG, SECRET_KEYShell environment
Configuration filesconfig.toml, settings.yaml, .envText files
Command line arguments--port 8080, --log-level=debugCLI flags
Code defaultsDEFAULT_PORT = 8000, fallback valuesPython constants

A common priority order is:

  1. Command line arguments
  2. Environment variables
  3. Configuration file
  4. 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:

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.

python
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.

python
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:

text
DATABASE_URL=postgresql://user:pass@localhost:5432/app
DEBUG=true
SECRET_KEY=super-secret-key

You can load it with the python-dotenv package:

bash
pip install python-dotenv
python
from 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:

yaml
app:
  debug: false
  port: 8000
database:
  url: postgresql://user:pass@localhost:5432/app

You can read it using pyyaml:

bash
pip install pyyaml
python
import 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:

python
# 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:

python
# 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.

python
# 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:

python
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:

EnvironmentPurposeTypical values
developmentLocal development on your machineDebug on, local sqlite, verbose logs
testAutomated testsIn memory DB, fake external services
stagingPre production, almost like productionReal DB, debug off, test API keys
productionReal usersDebug off, secure settings, real keys

You can represent the environment with a variable like APP_ENV or ENVIRONMENT.

Simple branching by environment

python
# 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:

python
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:

You can tell python-dotenv which file to load depending on APP_ENV.

python
# 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 .env

Per environment config classes

Another pattern is to subclass a base config:

python
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:

You should validate it early and loudly, ideally on startup.

Manual validation

You can write small helper functions to parse and validate.

python
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.

bash
pip install pydantic

Example:

python
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:

Access values like this:

python
print(settings.database_url)
print(settings.debug)

You can add prefixes so that environment variables are grouped.

python
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:

You must handle them with special care.

Do not commit secrets

Never store real secrets in:

Safer options:

Example .gitignore entry:

text
.env
.env.*
secrets.yaml

Mark secrets clearly

In code, separate secrets from non secret configuration.

python
# 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.

python
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:

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:

python
# 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 False

Using Pydantic based settings, you can create instances directly for tests.

python
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 == 9000

Using a separate test environment

You can set APP_ENV=test for your test runs and load special settings that:

Example pytest configuration in pytest.ini:

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

  1. Keep configuration out of code. Use environment variables, config files, or both.
  2. Use a single place for configuration. A settings.py or similar module.
  3. Validate configuration at startup. Fail fast on missing or invalid values.
  4. Support environments. Use an APP_ENV variable to adjust behavior.
  5. Never commit secrets. Use environment variables or a secrets manager.
  6. Log configuration carefully. Never log secrets or passwords.
  7. 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:

python
# 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:

text
APP_ENVIRONMENT=production
APP_DEBUG=false
APP_PORT=8080
APP_DATABASE_URL=postgresql://user:pass@db:5432/app
APP_JWT_SECRET_KEY=super-secret-key

Anywhere in your code:

python
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:

You now have the main tools you need to configure Python backend applications safely and flexibly.

Views: 7

Comments

Please login to add a comment.

Don't have an account? Register now!