FastAPI Settings with Dependency Injection
FastAPI’s dependency system is the reason its configuration pattern looks different from every other framework’s: instead of a global object, you inject a callable’s result into each route that needs it. Done well, that gives you a single validated model, per-test substitution with no monkeypatching, and no per-request cost. Done carelessly, it rebuilds the entire settings model on every request — re-reading files and re-running validators thousands of times a minute for a value that cannot change. This page shows the correct shape and the two mistakes that cause the difference, extending the framework integration patterns.
Problem 1: a dependency that rebuilds settings per request
# main.py — ANTI-PATTERN
def get_settings() -> Settings:
return Settings() # a brand new model on every single request
@app.get("/items")
def list_items(config: Settings = Depends(get_settings)):
...
FastAPI resolves dependencies per request, so this constructs the model once per call. That means re-reading the .env file from disk, re-running every field validator, and re-parsing every typed field — all to produce an object identical to the last one. Under any real traffic it shows up as syscall-heavy profiles and a latency floor that nothing in your handler explains.
The correctness problem is more interesting than the performance one. Because each construction re-reads its sources, two requests can observe different configuration if anything underneath changes — a rewritten .env file, a refreshed secrets mount. Configuration that varies between requests within one process is a genuinely hard class of bug to reason about, and the fix removes the possibility entirely rather than making it unlikely.
Problem 2: patching module globals in tests
# tests/test_api.py — ANTI-PATTERN
import main
main.settings.feature_x_enabled = True # mutating a shared object
response = client.get("/items")
Mutating a global settings object leaks into every subsequent test in the process, and because the mutation is not reversed by any fixture teardown, the failure appears somewhere else entirely. It also does not survive a model configured as immutable, which is the sensible default for configuration, so teams end up removing immutability to make tests work — trading a real safety property for a test convenience.
FastAPI already provides the correct mechanism. app.dependency_overrides maps a dependency callable to a replacement for the lifetime of the test, scoped to that app instance, and clearing the dictionary restores the original. Nothing global is mutated, the model stays immutable, and the override is visible in the test that uses it rather than hidden in a fixture that ran three files earlier.
Secure implementation
# config.py — one model, one cached factory
from functools import lru_cache
from pydantic import SecretStr, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="forbid", frozen=True)
environment: str = "local"
database_url: PostgresDsn
api_token: SecretStr
page_size: int = 50
@lru_cache # 1. one instance per process, built on first call
def get_settings() -> Settings:
return Settings()
settings = get_settings() # 2. built at import: bad configuration fails before the port binds
# main.py — inject the cached model, never construct it in a route
from typing import Annotated
from fastapi import Depends, FastAPI
from config import Settings, get_settings
app = FastAPI()
# 3. one alias reused everywhere, so the dependency is declared in a single place
SettingsDep = Annotated[Settings, Depends(get_settings)]
@app.get("/items")
def list_items(config: SettingsDep) -> dict[str, int]:
# 4. resolving this dependency is a cache hit, not a construction
return {"page_size": config.page_size}
@app.get("/healthz")
def healthz(config: SettingsDep) -> dict[str, str]:
# 5. safe to expose: an environment name, never a credential
return {"environment": config.environment}
# tests/conftest.py — substitution without touching globals
import pytest
from fastapi.testclient import TestClient
from config import Settings, get_settings
from main import app
@pytest.fixture
def client():
def override() -> Settings:
# 6. a complete, valid configuration built from explicit values
return Settings(
environment="test",
database_url="postgresql://u:p@db:5432/test",
api_token="test-token",
page_size=5,
)
app.dependency_overrides[get_settings] = override
yield TestClient(app)
app.dependency_overrides.clear() # 7. scoped teardown, nothing leaks
The Annotated alias in step 3 is worth adopting even in a small application. Declaring SettingsDep once means the dependency callable is named in exactly one place, so changing how settings are produced — adding a secret-store source, splitting per-role subclasses — touches one line rather than every route signature. It also keeps handler signatures readable, which matters more than it sounds when several dependencies stack up.
There is a related decision about what to inject that is worth making early. It is tempting to inject narrow slices — a DatabaseSettings for repository code, a FeatureFlags for handlers — and for a large application that decomposition is genuinely useful, because it documents what each layer is allowed to see. The mistake is to build those slices by constructing separate models from the environment, which reintroduces multiple sources of truth. Derive them from the one validated model instead: a small dependency that takes SettingsDep and returns a sub-object keeps a single construction while still narrowing what each layer receives.
Async code adds one wrinkle worth stating explicitly. A cached synchronous factory is safe to call from an async route because it does no I/O after the first call — the construction already happened at import. What is not safe is doing network I/O inside a synchronous dependency, such as fetching a secret from a remote store on first use: that blocks the event loop for the duration. If configuration genuinely needs a remote fetch, do it before the loop exists (at import) or in an async lifespan handler that awaits properly, and keep the request-path dependency free of anything that can block.
Gotchas and version-specific behaviour
lru_cacheon the factory is what makesDependscheap; without it FastAPI’s per-request resolution constructs a model every time.frozen=Trueon the model config makes the settings object immutable, which pairs with dependency overrides — you substitute a different object rather than mutating a shared one.- FastAPI caches a dependency’s result within a single request by default, so several routes sharing
SettingsDepin one request tree resolve it once; that is not a substitute for the process-level cache. - Use
Annotated[Settings, Depends(get_settings)]rather than the older default-argument form; the annotation style is the current idiom and composes better with type checkers. - Do not construct settings inside a
lifespanhandler and stash them onapp.stateas the primary mechanism — the model should already be valid before the event loop starts.lifespanis for pools and background tasks that need the loop. - Sub-applications mounted with
app.mounthave their owndependency_overridesdictionary; overriding on the parent does not affect a mounted child. TestClienttriggers lifespan events on context entry, so overrides must be registered before entering the client’s context if a startup handler reads settings.
Two of these bullets interact in a way that surprises people the first time. Because TestClient runs the application’s lifespan when its context is entered, and because a startup handler may read configuration, an override registered after entering the client’s context arrives too late for anything the lifespan already did. The reliable ordering is to register overrides, then enter the client, then make requests — which is what the fixture above does by assigning to dependency_overrides before constructing TestClient. If a test needs to change configuration mid-run, it needs a fresh client rather than a mutated override.
Production parity checklist
- The settings factory is cached, and a profile of a hot endpoint shows no file reads.
- The model is constructed at import so a misconfigured container fails before binding its port.
- Tests substitute configuration through
dependency_overridesand clear it in teardown. /healthzreports the environment name and never a credential.- The same image runs locally with a
.envfile and in production with injected variables — no code path differs. - CI imports the application module against the target environment’s variables as a pre-deploy gate, so a missing key fails the build rather than the container.
- No dependency on the request path performs network I/O, so nothing can block the event loop while resolving configuration.
Frequently asked questions
Why not just import a module-level settings object in FastAPI?
You can, and for a small service it is fine — the module-level settings in the implementation above is exactly that, and it is what makes import-time validation work. The dependency exists so tests and sub-applications can substitute a different configuration without patching module globals, which is the part that gets painful as the codebase grows. Using both is not redundant: the module-level object gives you the boot-time gate, and the injected dependency gives you the substitution seam. Routes should take the dependency, and anything that runs before the app exists can use the module-level object directly.
Does Depends run the settings factory on every request?
Yes, unless the factory is cached. FastAPI calls the dependency callable per request; an lru_cache turns that call into a dictionary lookup returning the instance built at startup. Within a single request FastAPI also caches the result across all dependencies that require it, so a request touching five dependencies that each need settings resolves the factory once — but that intra-request cache does nothing for the request after it. The process-level cache is the one that matters, and it is one decorator.
How do I override settings in a test?
Use app.dependency_overrides to map the settings dependency to a callable returning your test configuration. It is scoped to the app instance and reversed by clearing the dictionary, so no environment patching is involved and no global object is mutated. Build the override’s Settings with explicit keyword arguments rather than by reading the environment, so the test states its own configuration completely. Remember that a mounted sub-application keeps its own overrides dictionary, so a test exercising a mounted route registers the override on that child app.
Can I inject a narrower settings object into some routes?
Yes, and it is good practice in a large application. Handing a repository layer a DatabaseSettings rather than the whole configuration documents what that layer is allowed to see and makes an accidental dependency on an unrelated value impossible. The important constraint is how the narrow object is produced: derive it from the one validated model with a small dependency that takes SettingsDep and returns a sub-object, rather than constructing a second BaseSettings from the environment. The second construction would re-read sources, could disagree with the first, and would need its own validation story — all of which the derived approach avoids for two lines of code.
Should the settings model be validated in the lifespan handler?
Construct it at import so a bad configuration fails before the server binds a port. Use lifespan for things that need the running event loop — connection pools, background refresh tasks, warming a cache — not for the initial validation. The distinction matters operationally: an import-time failure means the process exits immediately and an orchestrator sees a crash loop, while a lifespan failure happens after some startup work has already run and produces a messier shutdown. Validate early, then let lifespan build the things that need a loop, using the already-validated values.
Key takeaways
The pattern also scales down gracefully, which is worth knowing before adopting it. A single-file service with three settings does not need an injected dependency at all — the module-level object is enough, and adding Depends to every route signature for its own sake is ceremony. Adopt the dependency when you first need substitution: the first test that wants different configuration, or the first mounted sub-application with its own requirements. Because the model and the cached factory are already in place, that upgrade is additive rather than a rewrite.
Two decisions carry this pattern: cache the settings factory so Depends costs nothing, and construct the model at import so a broken configuration fails before the port is bound. With those in place, injecting settings into routes is free, every request in a process observes the same configuration, and the dependency exists purely as a substitution seam rather than as a performance liability.
The seam pays for itself in tests. dependency_overrides replaces configuration for one app instance with no environment patching and no mutation of a shared object, which lets the model stay immutable and keeps test failures local to the test that caused them. For how the same single-model discipline applies to Django, Flask and worker processes, see the framework integration topic.