This article focuses on FastAPI configuration management, showing how to move from hardcoded values to
.envfiles andpydantic-settingsto solve three common problems: secret leakage, chaotic multi-environment switching, and missing configuration validation. Keywords: FastAPI, pydantic-settings, .env.
| Item | Description |
|---|---|
| Language | Python |
| Framework | FastAPI |
| Configuration Method | Environment Variables / .env |
| Core Dependencies | pydantic-settings, python-dotenv |
| Applicable Environments | Development, Testing, Production |
| Article Focus | Practical pitfalls, configuration security, route integration |
Hardcoded configuration keeps increasing security and maintenance costs
Writing a database URL, Redis password, or third-party API_KEY directly into source code may feel convenient in the short term, but it will create problems over time. Once code enters a repository, container image, or log stream, sensitive information can be copied, cached, and widely exposed.
A more practical issue is that multi-environment switching often pushes you into writing large amounts of if-else logic. Once development, testing, and production settings get mixed together, configuration becomes uncontrollable and deployment turns into a high-risk operation.
A minimal counterexample shows why hardcoding is a bad practice
DATABASE_URL = "postgresql://user:pass123@localhost/db" # Bad example: sensitive information is hardcoded directly
SECRET_KEY = "super-secret-key" # Bad example: the secret is exposed in the code repository
This snippet demonstrates the most common configuration anti-pattern: freezing sensitive values into source code.
Using environment variables is the first step away from hardcoding
Environment variables separate configuration from code, which at least prevents sensitive values from appearing directly in the repository. You can read values with os.getenv() and let the deployment environment inject the configuration.
However, relying only on system environment variables is not perfect. In team collaboration, every developer must configure variables manually. After a service restart, container migration, or CI job switch, missing variables or inconsistent values can easily appear.
Start with os.getenv, then introduce .env
import os
DATABASE_URL = os.getenv("DATABASE_URL") # Read the database connection string from environment variables
DEBUG = os.getenv("DEBUG", "false").lower() == "true" # Manually convert a boolean value
This approach works for early-stage projects, but you must handle type conversion, default values, and missing-value validation yourself.
.env files make local development more stable, but you must use them with clear conventions
The value of .env files is that they make local development reproducible. After team members receive an .env.example file, they only need to fill in their own secrets and endpoints to run the project quickly.
That said, .env is only a storage format, not a governance solution. It answers “where should values live,” but not the more important questions: “are the values valid,” “should the app validate them at startup,” and “how should different environments override them?”
.gitignore and an example template must exist together
# .gitignore
.env
.env.*
!.env.example # Keep the template file so the team can share the field structure
This setup ensures that real secrets do not enter the repository while preserving a collaborative configuration template.
pydantic-settings gives configuration a type system and startup-time validation
The core advantage of pydantic_settings.BaseSettings is that it upgrades loose string-based configuration into a structured object with types, default values, and validation. The application can detect missing fields during startup instead of failing only after production traffic arrives.
For FastAPI, this approach is especially effective. The configuration object can be reused globally and also injected into routes and service layers through dependency injection, which reduces repeated reads and implicit type conversions.
Use a Settings class to manage configuration fields centrally
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "MyFastAPI" # Application name with a default value
database_url: str # Required field; startup fails if it is missing
secret_key: str # Required secret; prevents running with an empty value
debug: bool = False # Automatically converted to a boolean type
model_config = SettingsConfigDict(
env_file=".env", # Specify the default configuration file
env_file_encoding="utf-8"
)
settings = Settings() # Instantiate and validate at startup
This code defines configuration as a strongly typed object and automatically gains defaults, missing-field validation, and type conversion.
Explicitly setting the .env path avoids working-directory drift
In many cases where configuration works locally but fails in production, the root cause is not an incorrect field name. The real issue is that the relative .env path depends on the current working directory. When you start the app with uvicorn, Docker, Supervisor, or systemd, the working directory may change.
The safest approach is to build the path explicitly from __file__, so the configuration file is always resolved relative to config.py, not relative to the location where the startup command runs.
Use file to lock the configuration file location
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = os.path.dirname(__file__) # Use the current configuration file directory as the base
APP_ENV = os.getenv("APP_ENV", "dev") # Read the runtime environment
ENV_PATH = os.path.join(BASE_DIR, f".env.{APP_ENV}") # Build the path to the matching environment file
class Settings(BaseSettings):
database_url: str
secret_key: str
debug: bool = False
model_config = SettingsConfigDict(
env_file=ENV_PATH, # Explicitly set the configuration file to avoid CWD issues
env_file_encoding="utf-8"
)
This code ensures that configuration file lookup stays stable no matter where you start the service.
Configuration priority must be explicit, or override behavior will distort troubleshooting
In pydantic-settings, system environment variables have higher priority than .env, and .env has higher priority than class defaults. This rule is ideal for containerized deployment because you can override production secrets by injecting environment variables without changing the image itself.
If you do not understand this behavior, you may end up thinking, “I clearly changed .env, so why did the service not change?” In reality, the startup environment often already contains a variable with the same name, which overrides the local file value.
Design multi-environment settings with file defaults and environment-variable overrides
# Priority order
# 1. System environment variables
# 2. .env.dev / .env.test / .env.prod
# 3. Default values in the Settings class
This priority model fits scenarios where local development, CI testing, and production deployment must coexist.
Injecting Settings into FastAPI routes is safer than directly using global variables
Directly importing a settings instance is fast, but it is less flexible for test replacement, dependency overrides, and module decoupling. A better approach is to declare the configuration object as a dependency and let route handlers receive it explicitly.
The benefits are clear, testable, and overridable behavior. In unit tests especially, you can replace get_settings() without polluting the real configuration.
Use Depends to inject the configuration object
from fastapi import Depends, FastAPI
app = FastAPI()
def get_settings() -> Settings:
return settings # Return the validated configuration instance
@app.get("/info")
async def get_app_info(config: Settings = Depends(get_settings)):
return {
"app_name": config.app_name, # Safely return non-sensitive configuration
"debug": config.debug,
"db_scheme": config.database_url.split(":")[0] # Example: extract the scheme prefix
}
This example shows how to read configuration safely inside routes while preserving type hints and testability.
The blog avatar in the image serves only as author identification
![]()
The image is the author’s avatar and functions only as a brand or identity marker. It does not illustrate any technical architecture, so no additional visual analysis is needed.
The key to implementing this approach is security, portability, and testability
If your project still relies on hardcoded configuration, prioritize three actions. First, move sensitive settings into .env files or system environment variables. Second, build a unified configuration model with BaseSettings. Third, pass configuration through dependency injection in routes and service layers.
The result is not just cleaner code. It gives you a governable configuration lifecycle. You can switch environments more easily, audit risk more effectively, rotate secrets more safely, and catch errors during application startup instead of after deployment.
FAQ
Q1: Can I use only python-dotenv without pydantic-settings?
Yes, but it is better suited to simple scripts or projects with very few configuration fields. If you use only python-dotenv, you must write type conversion, default handling, and missing-value validation manually. As the number of settings grows, maintenance costs rise quickly.
Q2: Should the .env file live in the project root or the app directory?
Either location works, but the most important rule is to set the path explicitly. A practical recommendation is to keep .env.dev and .env.prod next to config.py, then build the path with __file__ to avoid load failures when the working directory changes.
Q3: Why is it a bad idea to return secret_key directly from a route?
Because readable configuration does not mean exposable configuration. secret_key, database passwords, and third-party tokens are all highly sensitive values. Once they appear in API responses, logs, or error pages, they can be captured, cached, or abused.
Core summary
This article systematically rebuilds a FastAPI configuration management strategy. It explains the risks of hardcoding, .env file loading, pydantic-settings type validation, configuration priority, path resolution, and route injection patterns, helping you manage secrets securely and support development, testing, and production environments cleanly.