Skip to main content

FastAPI Settings & Configuration: Environment Guide

Configuration management separates your code from its environment. A single FastAPI codebase should run identically in development, testing, and production with only configuration changing. The pydantic-settings library lets you define configuration as a Pydantic model, automatically loading from environment variables, .env files, or defaults. This guide shows you how to structure multi-environment configuration so your API runs reliably everywhere without changing code.

I've seen production incidents caused by misconfigured database URLs or API keys. This article teaches you patterns that prevent configuration errors and make deployments predictable.

Setting Up pydantic-settings

First, install pydantic-settings:

pip install pydantic-settings

Then define your configuration as a model:

from pydantic_settings import BaseSettings
from typing import Optional

class Settings(BaseSettings):
"""Application configuration loaded from environment."""

# Database
database_url: str = "sqlite:///./test.db"
database_pool_size: int = 5

# Server
api_title: str = "My API"
api_version: str = "1.0.0"
debug: bool = False

# Security
secret_key: str
access_token_expire_minutes: int = 30

# External services
external_api_url: str
external_api_key: str

class Config:
env_file = ".env" # Load from .env file
env_file_encoding = "utf-8"
case_sensitive = False

settings = Settings()

Create a .env file in your project root:

DATABASE_URL=postgresql+asyncpg://user:password@localhost/mydb
SECRET_KEY=your-secret-key-here
EXTERNAL_API_URL=https://api.example.com
EXTERNAL_API_KEY=external-key
DEBUG=false

Now settings.database_url is automatically populated from the environment variable. If the variable isn't set, it falls back to the class default (if provided).

Environment-Specific Configuration

Use a base Settings class and subclass it per environment:

from pydantic_settings import BaseSettings
from typing import Literal

class BaseSettings(BaseSettings):
"""Shared configuration across all environments."""

api_title: str = "My API"
api_version: str = "1.0.0"

class Config:
env_file = ".env"
case_sensitive = False

class DevelopmentSettings(BaseSettings):
"""Development environment configuration."""
environment: Literal["development"] = "development"
debug: bool = True
database_url: str = "sqlite:///./dev.db"
log_level: str = "DEBUG"

class Config:
env_file = ".env.development"

class TestingSettings(BaseSettings):
"""Testing environment configuration."""
environment: Literal["testing"] = "testing"
debug: bool = True
database_url: str = "sqlite:///:memory:"
log_level: str = "DEBUG"

class Config:
env_file = ".env.testing"

class ProductionSettings(BaseSettings):
"""Production environment configuration."""
environment: Literal["production"] = "production"
debug: bool = False
database_url: str # Required; no default
secret_key: str # Required; must be set
log_level: str = "INFO"

class Config:
env_file = ".env.production"

def get_settings() -> BaseSettings:
"""Return settings based on ENVIRONMENT variable."""
env = os.getenv("ENVIRONMENT", "development")

if env == "testing":
return TestingSettings()
elif env == "production":
return ProductionSettings()
else:
return DevelopmentSettings()

settings = get_settings()

Now set the ENVIRONMENT variable to control which settings class is used:

# Development
export ENVIRONMENT=development
python -m uvicorn main:app --reload

# Production
export ENVIRONMENT=production
python -m uvicorn main:app

Validating Configuration at Startup

Validate critical settings when the app starts:

from pydantic import field_validator, HttpUrl

class ProductionSettings(BaseSettings):
environment: Literal["production"] = "production"
database_url: str
secret_key: str
external_api_url: HttpUrl

@field_validator("secret_key")
@classmethod
def validate_secret_key(cls, v: str) -> str:
"""Ensure secret key is long enough."""
if len(v) < 32:
raise ValueError("secret_key must be at least 32 characters")
return v

@field_validator("database_url")
@classmethod
def validate_database_url(cls, v: str) -> str:
"""Ensure database URL is not SQLite in production."""
if "sqlite" in v.lower():
raise ValueError("SQLite is not allowed in production")
return v

settings = ProductionSettings() # Raises ValueError if validation fails

Validation at startup catches misconfigurations immediately, preventing silent failures.

Loading Secrets from External Services

For production, load secrets from a secrets manager instead of .env:

import boto3
import json
from pydantic_settings import BaseSettings

class ProductionSettings(BaseSettings):
database_url: str
secret_key: str

@classmethod
def settings_customizer(cls, settings):
"""Load secrets from AWS Secrets Manager."""

client = boto3.client("secretsmanager", region_name="us-east-1")

try:
response = client.get_secret_value(SecretId="myapp/prod")
secret = json.loads(response["SecretString"])

settings.database_url = secret["database_url"]
settings.secret_key = secret["secret_key"]
except Exception as e:
raise ValueError(f"Failed to load secrets: {e}")

return settings

class Config:
# Load from environment first, then customize
env_file = ".env"

settings = ProductionSettings().settings_customizer()

Or use environment variables directly (the recommended production pattern):

# In your deployment (K8s, Heroku, etc.), set environment variables
export DATABASE_URL=postgresql://...
export SECRET_KEY=...
export EXTERNAL_API_KEY=...

# Your FastAPI app reads them automatically

Using Settings in Routes and Lifespan

Inject settings into routes via dependencies:

from fastapi import FastAPI, Depends

app = FastAPI(
title=settings.api_title,
version=settings.api_version,
debug=settings.debug
)

def get_settings() -> Settings:
"""Dependency to inject settings."""
return settings

@app.get("/config")
async def get_config(settings: Settings = Depends(get_settings)):
"""Return non-sensitive configuration."""
return {
"api_version": settings.api_version,
"environment": settings.environment
}

Or access settings from app context in lifespan:

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
# Initialize database with settings
app.db_url = settings.database_url
app.db_pool = await create_pool(app.db_url)

yield

await app.db_pool.close()

app = FastAPI(lifespan=lifespan)

Logging Configuration

Use settings to control logging levels per environment:

import logging.config

class Settings(BaseSettings):
log_level: str = "INFO"

def configure_logging(self):
"""Set up logging based on settings."""
logging.basicConfig(
level=self.log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)

# Suppress noisy loggers in production
if self.environment == "production":
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)

settings = Settings()
settings.configure_logging()

Documenting Required Settings

Create a .env.example file documenting all settings:

# Database configuration
DATABASE_URL=postgresql+asyncpg://user:password@localhost/mydb
DATABASE_POOL_SIZE=10

# Security
SECRET_KEY=generate-a-random-32-character-key-here
ACCESS_TOKEN_EXPIRE_MINUTES=30

# External services
EXTERNAL_API_URL=https://api.example.com
EXTERNAL_API_KEY=your-api-key

# Application
ENVIRONMENT=development
DEBUG=true
LOG_LEVEL=DEBUG

Include instructions on how to generate secrets:

# Generate SECRET_KEY
python -c "import secrets; print(secrets.token_urlsafe(32))"

Key Takeaways

  • Use pydantic-settings to define configuration as a Pydantic model.
  • Load settings from .env files, environment variables, and defaults.
  • Create environment-specific settings classes (development, testing, production).
  • Validate critical settings at startup to catch misconfigurations immediately.
  • Use secrets managers (AWS Secrets, Vault) in production, not .env files.
  • Document all settings in .env.example.

Frequently Asked Questions

Should I commit .env files to version control?

No. Add .env* to .gitignore. Only commit .env.example with placeholders and documentation.

How do I override a single setting for testing?

Use dependency overrides. Define get_settings() as a dependency and override it in tests:

app.dependency_overrides[get_settings] = lambda: Settings(database_url="sqlite:///:memory:")

Can I use environment variables that aren't defined?

If a setting has no default and the environment variable is missing, Pydantic raises a ValidationError at startup. This prevents silent failures.

How do I handle list or dictionary settings?

Use Field with default factories:

from pydantic import Field

class Settings(BaseSettings):
allowed_hosts: list[str] = Field(default_factory=lambda: ["localhost"])
# Or load from JSON: ALLOWED_HOSTS='["example.com", "api.example.com"]'

What's the difference between BaseSettings and BaseModel?

BaseSettings reads from environment variables and .env files; BaseModel does not. Use BaseSettings for app configuration.

Further Reading