# Best Practices for Python Configuration, Logging, and Secrets Management
URL: https://madhudadi.in/blog/posts/python-configuration-logging-and-secrets-best-practices
Published: 2026-06-26
Tags: python, Best Practices, Logging
Read time: 40 min
Difficulty: intermediate
> Learn Python best practices for environment variables, .env.example, settings classes, logging setup, log levels, secret handling, config validation, and safe project defaults.# Python Configuration, Logging, and Secrets Best Practices

## Quick Answer

Keep configuration outside code, keep secrets out of Git, and use logging instead of print debugging.

Minimum project files:

```text
.env.example
src/
  my_app/
    config.py
    logging_config.py
```

Commit `.env.example`. Do not commit `.env`.

Last verified: June 26, 2026.

## Official Links

- Python `os.environ`: https://docs.python.org/3/library/os.html#os.environ
- Python logging: https://docs.python.org/3/library/logging.html
- Python dataclasses: https://docs.python.org/3/library/dataclasses.html
- Twelve-Factor App config principle: https://12factor.net/config

## What You Will Learn

By the end, you should be able to:

- separate config from code
- use environment variables safely
- create `.env.example`
- avoid committing secrets
- centralize settings
- validate required config
- configure logging once
- choose log levels
- avoid leaking secrets in logs

## 1. Configuration vs Code

Code should describe behavior.

Configuration should describe environment-specific values.

Examples of configuration:

- database URL
- API base URL
- log level
- feature flags
- file paths
- timeout values
- credentials

Do not hard-code production values into Python files.

## 2. Use Environment Variables

Read environment variables with `os.environ`.

```python
import os

database_url = os.environ.get("DATABASE_URL", "sqlite:///local.db")
```

Use required variables when the app cannot run without them:

```python
api_key = os.environ["API_KEY"]
```

This raises `KeyError` if missing.

For user-friendly errors, validate settings in one place.

## 3. Commit `.env.example`

Local `.env`:

```text
DATABASE_URL=sqlite:///local.db
API_KEY=replace-with-local-secret
LOG_LEVEL=INFO
```

Do not commit it.

Commit `.env.example`:

```text
DATABASE_URL=sqlite:///local.db
API_KEY=
LOG_LEVEL=INFO
```

This documents what must be configured.

## 4. Add `.env` To `.gitignore`

```gitignore
.env
.env.local
.env.*.local
```

If a secret was committed, removing it from the next commit is not enough. Rotate the secret.

## 5. Centralize Settings

Avoid reading environment variables in many files.

Create `config.py`:

```python
from dataclasses import dataclass
import os

@dataclass(frozen=True)
class Settings:
    database_url: str
    api_base_url: str
    log_level: str = "INFO"
    request_timeout_seconds: int = 30

def load_settings() -> Settings:
    return Settings(
        database_url=os.environ.get("DATABASE_URL", "sqlite:///local.db"),
        api_base_url=os.environ.get("API_BASE_URL", "http://localhost:8000"),
        log_level=os.environ.get("LOG_LEVEL", "INFO"),
        request_timeout_seconds=int(os.environ.get("REQUEST_TIMEOUT_SECONDS", "30")),
    )
```

Use it:

```python
settings = load_settings()
```

## 6. Validate Required Secrets

Some settings should not have fake defaults.

```python
def require_env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise RuntimeError(f"Missing required environment variable: {name}")
    return value
```

Use:

```python
api_key = require_env("API_KEY")
```

Fail early when required secrets are missing.

## 7. Avoid Global Configuration Surprises

Avoid code that reads environment variables at import time in many modules.

Weak:

```python
# service.py
API_KEY = os.environ["API_KEY"]
```

This can break tests just by importing the module.

Better:

```python
def create_service(settings: Settings):
    return Service(api_key=settings.api_key)
```

Pass config into the code that needs it.

## 8. Logging Basics

Use Python's logging module.

```python
import logging

logger = logging.getLogger(__name__)

def sync_orders():
    logger.info("Starting order sync")
```

Configure logging once at the application entry point:

```python
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
```

Do not call `basicConfig` in every module.

## 9. Log Levels

| Level | Use For |
|---|---|
| `DEBUG` | detailed developer-only diagnostics |
| `INFO` | normal lifecycle events |
| `WARNING` | recoverable problems |
| `ERROR` | failed operations |
| `CRITICAL` | process-level failures |

Example:

```python
logger.debug("Parsed %s rows", row_count)
logger.info("Import completed")
logger.warning("Skipped row with missing email")
logger.error("Could not write report")
```

## 10. Use `logger.exception`

Inside an exception handler:

```python
try:
    import_orders()
except ValueError:
    logger.exception("Order import failed")
    raise
```

`logger.exception` includes the traceback.

Use it only inside `except` blocks.

## 11. Do Not Log Secrets

Bad:

```python
logger.info("Using API key %s", api_key)
```

Better:

```python
logger.info("API key configured: %s", bool(api_key))
```

Never log:

- API keys
- passwords
- access tokens
- refresh tokens
- private cookies
- full database URLs with passwords

## 12. Mask Sensitive Values

```python
def mask_secret(value: str, visible: int = 4) -> str:
    if not value:
        return ""
    if len(value) <= visible:
        return "*" * len(value)
    return "*" * (len(value) - visible) + value[-visible:]
```

Use:

```python
logger.info("Token suffix: %s", mask_secret(token))
```

Only log masked values when it helps operations.

## 13. Structure Logs For Apps

For larger apps, JSON logs are useful.

Simple text logs are fine for beginner projects.

The important part is consistency:

- timestamp
- level
- module name
- message
- request ID or job ID when available

Example:

```python
logger.info("Processed file", extra={"file_name": file_name})
```

## 14. Use Safe Defaults

Good defaults:

- local SQLite database
- local API URL
- `INFO` log level
- conservative timeout

Bad defaults:

- production database URL
- real secret key
- debug mode in production
- no timeout for network calls

Defaults should be safe for local development.

## 15. Add Timeouts To Network Config

Do not let network calls hang forever.

```python
timeout = settings.request_timeout_seconds
```

Then pass it to your HTTP client.

Timeouts are configuration because local, CI, and production environments may need different values.

## 16. Test Configuration

Example tests:

```python
def test_load_settings_defaults(monkeypatch):
    monkeypatch.delenv("DATABASE_URL", raising=False)
    settings = load_settings()
    assert settings.database_url == "sqlite:///local.db"
```

```python
import pytest

def test_require_env_fails_when_missing(monkeypatch):
    monkeypatch.delenv("API_KEY", raising=False)
    with pytest.raises(RuntimeError, match="API_KEY"):
        require_env("API_KEY")
```

Configuration bugs should be caught before deployment.

## 17. Configuration Checklist

Before sharing a Python project, confirm:

- `.env` is ignored
- `.env.example` is committed
- required settings are documented
- secrets are not hard-coded
- settings are centralized
- missing required config fails clearly
- logging is configured once
- logs do not expose secrets
- network timeouts are configured
- config has tests

## 18. FAQ

### Should I commit `.env`?

No. Commit `.env.example` instead.

### Where should I read environment variables?

Read them in one configuration module, then pass settings into the code that needs them.

### Should beginners use Pydantic Settings?

For small projects, a dataclass is enough. For larger apps, Pydantic Settings is useful for validation and type conversion.

### Is print okay?

Print is fine for tiny scripts. Use logging for apps, CLIs, background jobs, and anything you may need to debug later.

### What if I already committed a secret?

Rotate the secret immediately. Deleting it from a later commit does not make the old secret safe.
