Config & Pydantic Settings

RoadmapsPython Backend

Scenario

You accidentally commit a file named `config.py` with `DB_PASSWORD = 'supersecret'` to GitHub. A bot scrapes it 2 minutes later, and by morning your AWS bill is $50,000 because someone spun up mining rigs using your compromised credentials.

How do you provide configuration securely so your code is completely decoupled from the sensitive values it needs to run?

Mental model

Config is the fuel, the app is the engine. You don't build the fuel into the engine; you pour it in from the outside right before you turn the key.

Your code should run identically on your laptop, in a CI pipeline, and in production—only the environment variables change. `pydantic-settings` is a bouncer at the door of your app: it reads the environment variables, validates their types (is the port an int? is the URL valid?), and hands your app a strongly-typed config object. If a required secret is missing, the app crashes immediately at startup rather than failing randomly three hours later.

Deep dive

Hardcoding configuration—like database URLs, API keys, or toggle switches—is a massive security and maintenance risk. Following the 12-Factor App methodology, configuration that changes between environments (development, staging, production) must be stored in environment variables, completely outside the source code.

Never commit secrets. Store configuration in environment variables.

Python's built-in `os.getenv('DATABASE_URL')` works, but it returns strings and fails silently (returning `None`) if the variable is missing. This pushes validation deep into your app, causing confusing errors (e.g., trying to connect to a `None` URL) far away from the source.

`os.getenv` provides no type safety and fails late.

**Reading environment variables** — `pydantic-settings` extends Pydantic's data validation to configuration. You define a `BaseSettings` class with your required config fields and types. When instantiated, it automatically reads the environment variables (or a `.env` file) for matching names, parses strings into integers or URLs, and crashes instantly if anything is invalid. It can even read from YAML files using `YamlConfigSettingsSource`.

`pydantic-settings` gives you type-checked, fail-fast configuration at app startup.

In FastAPI, you typically instantiate your settings once and inject them using `Depends(get_settings)`. This allows you to easily mock or override the settings during testing, which is much harder to do if you have a global `settings = Settings()` object imported everywhere.

Inject settings as a dependency to keep tests isolated and flexible.

Code examples

The naive way (fails late, weakly typed)

import os

# Fails late if not set. Also, PORT is a string here, not an int.
API_KEY = os.getenv('API_KEY')
PORT = os.getenv('PORT', '8000')

def connect():
    if not API_KEY:
        raise ValueError('Oops, API_KEY was missing!')
    # ...

Relying purely on `os.getenv` scatters string-based config throughout your codebase. If you forget to set `API_KEY` in prod, the app might start fine but crash when `connect()` is eventually called.

The production way: Pydantic Settings

from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import SecretStr, AnyUrl

class Settings(BaseSettings):
    app_name: str = 'My API'
    # Required. Crashes on startup if missing.
    database_url: AnyUrl  
    # SecretStr prevents the value from being accidentally printed/logged.
    api_key: SecretStr
    
    # In pydantic v2, we use model_config instead of class Config
    model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8')

# Instantiating reads the environment variables instantly
settings = Settings()

The `Settings` class declares exactly what the app needs. If `DATABASE_URL` is missing or isn't a valid URL, the app won't even start. `SettingsConfigDict(env_file='.env')` allows local development using a `.env` file.

Injecting settings in FastAPI

from functools import lru_cache
from fastapi import FastAPI, Depends

@lru_cache
def get_settings() -> Settings:
    # lru_cache ensures we only read the .env file and instantiate this once
    return Settings()

app = FastAPI()

@app.get('/info')
def info(settings: Settings = Depends(get_settings)):
    # Safely access the typed config
    return {'app_name': settings.app_name}

Using `Depends(get_settings)` combined with `@lru_cache` means the app uses a single settings instance. In tests, you can use `app.dependency_overrides[get_settings]` to swap out the config without polluting environment variables.

Key points

Common mistakes

Glossary

12-Factor App
A methodology for building robust SaaS apps, dictating that configuration (especially secrets) must be stored in the environment, not in code.
environment variables
Dynamic values that exist outside the application code, provided by the OS or the host environment.

Recall questions

Questions & answers

Your FastAPI service crashed at 3 AM because a newly deployed feature called an external API, but the `STRIPE_API_KEY` was missing from the production environment. How would you prevent this class of errors?

I would use `pydantic-settings` to define a `Settings` class that requires the `STRIPE_API_KEY`. This forces the application to fail fast during the startup phase if the environment variable is missing, preventing the app from even accepting traffic until the configuration is correct.

Approach: Mention 'fail fast' and validation at startup. Explain that config validation prevents runtime surprises.

When writing tests for your FastAPI endpoints, how do you change the database URL to point to a test database instead of the production one?

I would inject the settings via a FastAPI dependency (e.g., `Depends(get_settings)`). In my pytest suite, I can use `app.dependency_overrides[get_settings]` to return a mock `Settings` object with the test database URL, leaving the environment variables completely untouched.

Approach: Avoid `os.environ` patching. Use FastAPI's native dependency injection and overriding mechanism for clean, isolated tests.

Continue learning

Previous: Pydantic Models and Validators

Next: Env Vars and Secrets

Return to Python Backend Roadmap