Settings and Environment Configuration
FastAPI settings are the small layer that turns deployment-specific values into typed Python objects the rest of the application can trust. The goal is not merely to read os.environ. The goal is to make configuration explicit, validate it before the app starts serving traffic, avoid committing secrets, and provide settings to route handlers and dependencies without hiding mutable global state throughout the codebase.
In this course section, dependencies and configuration work together. A dependency can provide a database session, a client for another service, or a feature flag decision. Settings decide how those providers are constructed: which URL to connect to, whether debug behavior is enabled, how many connections are allowed, and which external integrations should be active in this deployment.
Purpose and Outcome
A well-designed FastAPI configuration layer gives you one obvious place to answer operational questions: what environment is this process running in, what required values must exist, how are strings from the environment converted into booleans, integers, lists, URLs, and secrets, and how do tests replace those values deterministically?
The outcome you want is simple. Local development can use a convenient .env file. CI can inject short-lived test values. Staging and production can inject secrets through the platform. The application should fail early with a clear validation error if a required value is missing or malformed. After startup, request handlers should read a stable settings object rather than repeatedly parsing environment variables during every request.
How FastAPI Settings Work Internally
FastAPI does not own environment parsing. The usual mechanism is Pydantic’s settings support, commonly imported from pydantic_settings. A settings class subclasses BaseSettings. Each annotated attribute becomes a configuration field. When you instantiate the class, the settings loader gathers values from configured sources, maps environment variable names to fields, parses raw strings into the annotated Python types, and then runs Pydantic validation.
Environment variables are strings at the operating-system boundary. A field annotated as bool must turn values such as true or 0 into a boolean. A field annotated as int must reject five. A field annotated as list[str] usually expects JSON-like input such as ["https://admin.example.com"], unless you add custom parsing. This conversion step is why a typed settings object is safer than manually reading strings in route handlers.
Settings sources have precedence. Values passed directly to the settings constructor can override environment variables. Environment variables commonly override values loaded from a .env file. Defaults in the class are used only when no higher-priority source provides a value. Required fields are fields without defaults; instantiating the settings class raises a validation error if any required field is absent.
FastAPI then enters the picture through dependency injection and application setup. You can construct settings once during app creation, or expose a get_settings dependency that returns a cached settings object. Caching matters because settings parsing may read files and validate many fields. It also avoids inconsistent behavior where one request sees a changed environment variable and another request sees an older value.
Configuration Anatomy
The main pieces are the settings class, field annotations, defaults, field constraints, and model configuration. The class name is usually Settings. Field names should describe application concepts, such as database_url or allowed_origins, not platform details. The environment variable name can be derived with an env_prefix, so database_url becomes ORDERS_DATABASE_URL.
Use Field constraints when an invalid value should stop the process. A pool size of zero, an upload limit of ten million megabytes, or an unknown environment name are configuration bugs. They should be rejected during startup rather than discovered after a customer request reaches the endpoint.
Example 1: A Typed Settings Class
This first example defines required and optional settings for a small orders service. database_url is required because the service cannot function without storage. debug defaults to false because enabling debug behavior accidentally is riskier than disabling it accidentally. The environment field accepts only known names.
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Orders API"
environment: str = Field(default="local", pattern="^(local|test|staging|production)$")
database_url: str
debug: bool = False
allowed_origins: list[str] = []
model_config = SettingsConfigDict(
env_prefix="ORDERS_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
settings = Settings()
print(settings.app_name)
print(settings.environment)
print(settings.debug)
If the process has ORDERS_DATABASE_URL=postgresql://localhost/orders and no other variables, the deterministic output is Orders API, then local, then False. If ORDERS_ENVIRONMENT=prod is set, construction fails because prod does not match the allowed values. That failure is useful: your application has found a deployment mistake before it accepts traffic.
Example 2: Inject Settings into FastAPI
The next step is to make settings available to route handlers without constructing them inside each handler. A cached dependency keeps the code easy to override in tests and gives FastAPI the same dependency shape used for databases, authentication, and service clients.
from functools import lru_cache
from fastapi import Depends, FastAPI
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
environment: str = Field(default="local", pattern="^(local|test|staging|production)$")
database_url: str
feature_checkout: bool = False
model_config = SettingsConfigDict(env_prefix="ORDERS_", env_file=".env")
@lru_cache
def get_settings() -> Settings:
return Settings()
app = FastAPI()
@app.get("/health/config")
def config_health(settings: Settings = Depends(get_settings)):
return {
"environment": settings.environment,
"checkout_enabled": settings.feature_checkout,
}
With ORDERS_DATABASE_URL=postgresql://localhost/orders and ORDERS_FEATURE_CHECKOUT=true, a request to GET /health/config returns {"environment":"local","checkout_enabled":true}. This endpoint deliberately exposes only non-secret configuration. It is reasonable for a health or diagnostics endpoint to show the environment name and whether a feature is active. It should never return database URLs, API keys, tokens, private DSNs, or raw settings dumps.
Example 3: Testing Environment Overrides
Tests often need to set environment variables temporarily. The important detail is clearing the settings cache after changing the environment. Otherwise the test may keep using values parsed before the override.
import os
from contextlib import contextmanager
from functools import lru_cache
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str
api_key: SecretStr
pool_size: int = Field(default=5, ge=1, le=50)
model_config = SettingsConfigDict(env_prefix="ORDERS_")
@lru_cache
def get_settings() -> Settings:
return Settings()
@contextmanager
def patched_env(values: dict[str, str]):
old = {key: os.environ.get(key) for key in values}
os.environ.update(values)
get_settings.cache_clear()
try:
yield
finally:
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
get_settings.cache_clear()
with patched_env({
"ORDERS_DATABASE_URL": "postgresql://example/test",
"ORDERS_API_KEY": "dev-secret",
"ORDERS_POOL_SIZE": "10",
}):
settings = get_settings()
print(settings.pool_size)
print(settings.api_key.get_secret_value() == "dev-secret")
This script prints 10 and then True. The secret field uses SecretStr so accidental representations are masked. The code still shows that application code can retrieve the real value when constructing a client. In tests, the context manager restores the previous environment and clears the cache again, preventing one test from contaminating the next.
Example 4: App Factory Configuration
For larger applications, an app factory is often cleaner than a module-level app = FastAPI() that immediately reads the environment. The factory loads settings once, wires dependencies around that instance, and lets tests pass a prebuilt settings object.
from fastapi import Depends, FastAPI
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
environment: str = Field(default="local", pattern="^(local|test|staging|production)$")
database_url: str
sentry_dsn: str | None = None
max_upload_mb: int = Field(default=10, ge=1, le=100)
model_config = SettingsConfigDict(env_prefix="ORDERS_", env_file=".env")
def load_settings() -> Settings:
return Settings()
def create_app(settings: Settings | None = None) -> FastAPI:
loaded = settings or load_settings()
app = FastAPI(title="Orders API", debug=loaded.environment != "production")
def current_settings() -> Settings:
return loaded
@app.get("/limits")
def limits(config: Settings = Depends(current_settings)):
return {"max_upload_mb": config.max_upload_mb}
return app
app = create_app()
This pattern makes startup behavior explicit. Production imports app, which calls create_app() and validates the environment. Tests can call create_app(Settings(...)) with controlled values. The route still receives settings through dependency injection, but the object comes from the application factory rather than a fresh environment parse.
Design Choices and Trade-offs
The first design choice is when to load settings. Loading at import time is simple, but it can make tests awkward and can fail during tooling operations that import modules without a complete deployment environment. Loading inside an app factory is more explicit and easier to test. Loading inside every request is almost always the wrong trade-off because it repeats parsing work and can produce inconsistent behavior.
The second choice is how strict validation should be. Strict validation creates earlier failures and clearer deployments. The cost is that every deployment path must provide complete and correctly formatted values. For service configuration, that cost is usually worth paying. A process that silently runs with an empty API key or a default in-memory database is harder to diagnose and more dangerous.
The third choice is how many settings classes to create. One large class is easy to locate but can become noisy. Several focused classes, such as DatabaseSettings, SecuritySettings, and EmailSettings, make ownership clearer. Keep the public dependency simple either way. Route handlers should not need to know how many underlying classes are involved.
Failure Modes and Troubleshooting
Symptom: the server exits during startup with a validation error for a missing field. Cause: a required setting such as ORDERS_DATABASE_URL was not present in the process environment or loaded .env file. Diagnose: print the names of required fields from the settings class, inspect the deployment’s configured variables, and confirm the prefix is correct. Correction: add the missing variable in the deployment system or provide a safe default only if the application can truly operate without it.
Symptom: a boolean feature flag appears enabled when you expected it to be disabled. Cause: the raw environment string was not the value you thought, or a higher-priority source overrode the .env file. Diagnose: check the actual process environment, not just the local file, and inspect source precedence. Correction: remove stale platform variables and use explicit values such as true or false.
Symptom: one test passes alone but fails when the whole suite runs. Cause: cached settings were created before the test changed environment variables. Diagnose: search for get_settings() calls at import time and add logging or assertions around cache clearing. Correction: clear the cache before and after environment patches, or pass settings directly through an app factory.
Symptom: a health endpoint leaks a token or database password. Cause: the endpoint returned a full settings model or stringified a secret. Diagnose: call diagnostics endpoints in a local environment containing fake secret-looking values. Correction: return an allowlist of non-sensitive fields and use secret types for credentials.
Security, Performance, and Reliability
Settings are security-sensitive because they often contain credentials and URLs that reveal internal infrastructure. Keep .env files out of source control when they contain real secrets. Prefer deployment secret stores for production. Avoid logging the full settings object. If a value must appear in diagnostics, expose a derived status such as configured: true instead of the secret itself.
Performance concerns are small but real. Parsing settings once is cheap; parsing them on every request is unnecessary. Caching a settings dependency with lru_cache is a common FastAPI pattern because it preserves dependency override ergonomics while avoiding repeated I/O and validation.
Reliability improves when required settings fail fast. A missing queue URL should stop a worker before it silently drops jobs. A malformed CORS origin should fail before browsers start rejecting requests. The configuration layer is a startup gate that protects the rest of the application from undefined deployment state.
Hands-on Lab: Configure a FastAPI App
Prerequisites: a FastAPI project, Pydantic settings support installed, and a way to run the app locally with environment variables. Use harmless local values; do not use production credentials in this lab.
- Create a
Settingsclass withenvironment,database_url,api_key, andmax_upload_mb. Makedatabase_urlandapi_keyrequired. - Add
env_prefix="ORDERS_"and, for local development, configureenv_file=".env". - Create a cached
get_settingsdependency or an app factory that loads settings once. - Add a diagnostic route that returns only
environmentandmax_upload_mb. - Run the app with
ORDERS_DATABASE_URLandORDERS_API_KEYset. Verify that the route returns the expected non-secret values. - Unset
ORDERS_DATABASE_URLand restart. Verify that startup fails with a validation error instead of serving requests. - Cleanup by removing temporary environment variables and deleting any local
.envfile that contains throwaway secrets.
Rollback is straightforward: remove the new dependency from routes, restore the previous app construction path, and keep the settings class in a branch until deployment variables are ready. In a real service, coordinate this change with deployment configuration so the code and environment arrive together.
Assessment Exercises
- A teammate proposes reading
os.environ["DATABASE_URL"]inside every repository method. Explain the behavioral and testing problems this creates, and propose a FastAPI-friendly alternative. - Your staging deployment has both
.envvalues baked into the image and platform-level environment variables. A feature flag behaves differently than the file suggests. What source-precedence checks would you perform? - Design a settings model for an email integration with a required API key, optional sandbox mode, and a send timeout between one and thirty seconds. Which fields should be exposed by diagnostics?
- A test changes
ORDERS_POOL_SIZEfrom5to20, but the app still reports5. Identify the likely cause and the smallest correction. - Explain why strict startup failure for malformed configuration is usually preferable to falling back to broad defaults in a FastAPI service.
Summary
FastAPI settings are a typed boundary between deployment systems and application code. Define a settings class, let Pydantic parse and validate environment values, load the object once, and provide it through dependency injection or an app factory. The practical payoff is clear startup failures, safer secret handling, deterministic tests, and route handlers that depend on explicit configuration rather than scattered environment reads.
