Skip to content
FrameworkPython

FastAPI environment variables with pydantic-settings

FastAPI reads settings through pydantic-settings, where environment variables beat .env files. Types, caching, secret files and running FastAPI with Crunu.

FastAPIAt a glance

Env file that wins
.env

The only .env file it reads. See the load order

Public prefix
None

No names are copied into browser code by a prefix.

Values applied
At runtime

Values are read from the environment when the app runs.

Built-in secret store
None: pydantic-settings reads the environment, a .env file and a secrets directory

fastapi.tiangolo.com/advanced/settings

Crunu command
crunu run --env api/development -- fastapi dev main.pyRecipeCrunu has no dedicated integration here: this is the general setup.

Common pitfalls

  1. FastAPI itself reads no .env file. pydantic-settings does, and only with env_file configured and python-dotenv installed.
  2. Real environment variables always beat the .env file in pydantic-settings, so a value exported in your shell wins without a warning.
  3. Settings() reads the environment and the .env file every time it is created. Cache it, or each request re-reads the file.
  4. Variable names are case-insensitive by default, so APP_NAME and app_name map to the same field.
  5. A required field without a value fails validation at startup, which is good, unless it happens inside an image build.
On this page

FastAPI has no environment variable loader of its own. The FastAPI documentation recommends pydantic-settings: you write a Settings class that inherits from BaseSettings, and creating it reads each field from the environment, matching names case-insensitively and converting strings to the declared types. A .env file is read only if you set env_file in the model config and install python-dotenv, and a real environment variable always beats the same name in that file. Settings are usually created once at startup, so they behave as runtime values that change on restart. Checked October 30, 2026, with pydantic-settings 2.16.

What pydantic-settings reads, and in what order

A settings class like this one declares the shape:

Text
class Settings(BaseSettings):
    app_name: str = "Orders API"
    database_url: str
    items_per_user: int = 50

    model_config = SettingsConfigDict(env_file=".env")

When Settings() runs, each field is filled from the first source that has it. The Pydantic settings documentation gives the default order:

PrioritySource
1arguments passed to Settings(...)
2environment variables
3the dotenv file or files from env_file
4files in secrets_dir
5field defaults

The documentation states it plainly: even when using a dotenv file, environment variables will always take priority over values loaded from it. If env_file lists several files, they load in order and later files override earlier ones.

Types, names and nesting

Environment variables are text. pydantic-settings converts them to the declared types and validates them, so items_per_user arrives as an int, and ITEMS_PER_USER=lots stops the app with a validation error instead of failing later. A field without a default is required. If no source provides it, startup fails, which is the behavior you want on a server.

Names are matched case-insensitively by default, so DATABASE_URL fills database_url. Two settings help larger projects:

  • env_prefix adds a prefix to every name, for example ORDERS_DATABASE_URL. It applies to dotenv files and secret files too, not only to the environment. The default is empty.
  • env_nested_delimiter maps names like DB__HOST onto nested models. Nested variables take precedence over a top-level JSON value for the same model.

Wrap secrets in SecretStr. The value is still available through get_secret_value(), but it prints as asterisks in logs and in repr(), which saves you from the most common accidental leak: logging the whole settings object at startup.

Create settings once

The FastAPI docs show settings provided through a dependency decorated with @lru_cache:

Text
@lru_cache
def get_settings():
    return Settings()

Without the cache, every request that depends on settings would create a new object and read the .env file from disk again. With it, the object is created on first use and reused. The dependency form also makes testing easy: app.dependency_overrides[get_settings] can return a Settings built with test values, and nothing in the environment needs to change.

A consequence of caching: a running worker never sees a changed variable. Restarting the process is the way to apply new values.

Secret files

secrets_dir reads one file per field from a directory. With secrets_dir="/run/secrets", which is where Docker mounts secrets by default, a file named database_url fills database_url. A missing directory only produces a warning. Since files rank below environment variables and the dotenv file, a stray variable with the same name silently wins over the mounted secret, which is worth knowing when you debug.

A short review of your Settings class

The settings class is the single place where your API meets its configuration, which makes it worth a review whenever someone adds a field. A few questions catch most problems before they reach production:

  • Does any secret have a default? A default password or token in code ends up in Git and in every fork. Secrets should be required fields with no default, so a missing value stops the app.
  • Is every secret typed as SecretStr? Plain str fields print their values the first time someone logs the settings object or an exception includes it.
  • Does anything else read os.environ directly? Scattered lookups bypass validation and type conversion, and make it hard to answer which variables the service needs. Route them through the settings class.
  • Does .env.example list every field? It's the document a new teammate reads first. Keep it in the repository with harmless placeholders, and keep the real .env out of it.
  • Is the same class used by background workers? A Celery or arq worker that builds its own configuration can drift from the API's. Share the class, and give the worker its own environment if it needs fewer secrets.

None of this depends on where the values come from, but it makes moving them, to Crunu or anywhere else, a change of source rather than a rewrite.

Run FastAPI with values from Crunu

crunu run decrypts a Crunu environment in memory and starts your command with the variables in its process environment. That is source number two in the table, above every file, so the Crunu values fill your Settings without python-dotenv and without a .env file on disk. The FastAPI CLI, Uvicorn and Gunicorn all inherit the variables from crunu run.

  1. Store each stage's values in a Crunu environment, for example api/development and api/production, using the same names your Settings fields expect.
  2. Develop with crunu run --env api/development -- fastapi dev main.py from your activated virtual environment.
  3. Run tests with crunu run --env api/test -- pytest, or skip Crunu there and use dependency_overrides for fully local test values.
  4. On a Linux server with the agent, start the app with crunu run --env api/production --restart on-change -- fastapi run main.py.

With --restart on-change, crunu sends SIGTERM after a value changes and starts the server again, so the cached Settings object is rebuilt with the new values. That also covers a rotated database password, as long as the old one stays valid until the restart completes.

Things to know about the clean environment that crunu run creates: it passes PATH, HOME and a few locale and terminal variables from your shell, plus the Crunu variables. PATH is enough for an activated virtual environment to be found. PYTHONPATH is not passed, so add it to the Crunu environment or use --inherit-env if your project depends on it. If your Settings uses an env_prefix, the names in Crunu need the prefix too.

Where FastAPI apps usually run

In containers, start the server under crunu run with a service token, or mount secret files and use secrets_dir, but not both for the same values, since the environment wins over the files. Whoever stores a service token for a container can decrypt its environment, so give each token one environment and read the limits on the Fly.io page. The Docker Compose page shows both patterns. On hosting platforms like Render, the platform's own variables reach pydantic-settings the same way; Crunu doesn't sync into them.

Frequently asked questions

Something else? Write to support@crunu.com

If a variable is set in the environment and in .env, which one does FastAPI use?

The environment variable. pydantic-settings reads both, and environment variables always take priority over values loaded from a dotenv file.

Can pydantic-settings read Docker secrets?

Yes. Set secrets_dir to /run/secrets in the settings model config and each file there becomes a value for the field of the same name. Environment variables and the .env file still take priority over those files.

Why use a dependency with lru_cache for settings?

Creating the Settings object reads the environment and the .env file. Caching the dependency means that happens once, and tests can still override it through dependency_overrides.

Do I need python-dotenv when I use crunu run?

No. crunu run puts the values into the process environment, which pydantic-settings reads without any extra package. Keep env_file only if you want a non-secret defaults file.

Sources

Every source this page relies on, with the date we last checked it.

  1. FastAPI: Settings and environment variables · checked October 30, 2026
  2. Pydantic: Settings management · checked October 30, 2026
  3. Pydantic: Types, SecretStr · checked October 30, 2026

Keep your .env values in one place

Free during early access. Values are encrypted on your devices, each environment has its own key, and crunu run hands them to your app without writing a file.