Skip to main content

xtr-dotenv

Layered .env files loaded into the environment, and the same layers behind a typed settings model.

python 3.11+ typed license MIT

Why?

Real applications keep several .env files layered so a developer laptop, a CI runner and a production host each end up with the settings that suit, without any one file knowing about the other. This library is the loader every layer runs through, plus a pydantic-settings source that feeds a typed model from the same cascade — without ever touching os.environ behind your back.

  • 📚 The layered cascade — .env → .env.local → .env.{env} → .env.{env}.local, with a .env.dist read instead of a missing .env, and real environment variables always winning.
  • 🧪 Testable end-to-end — hand the loader a plain dict and no other test in the same process can see anything it did.
  • 🧩 Pydantic-settings integration — DotenvSettings reads the same cascade from a sandboxed copy of os.environ, so a model gets the values without side effects.
  • 🌐 Variable expansion — $VAR, ${VAR}, ${VAR:-default}, ${VAR:=default}, \$ as a literal. Single-quoted values are literal too. $(command) is kept as text, never run.
  • ⚡ A dumped fast path — dotenv:dump writes the compiled cascade to .env.local.json; boot_env reads that one file instead of the cascade.

Install

uv add xtr-dotenv                 # the loader and the settings source
uv add "xtr-dotenv[di]"           # + a DotenvBundle for xtr-dependency-injection
uv add "xtr-dotenv[console]"      # + the dotenv:dump and debug:dotenv commands

Requires Python 3.11+.

Quick start

Call Dotenv().boot_env(...) at the very top of your entry point — before anything reads a settings model, and before you build a kernel:

from pathlib import Path

from xtr_dotenv import Dotenv

Dotenv().boot_env(str(Path(__file__).parent / ".env"))

# ... only now import the kernel, settings, etc.
from app.kernel import kernel

raise SystemExit(kernel.run(...))

The loader is stateless: hand it a MutableMapping[str, str] as environ= and no other code sees a thing.

sandbox: dict[str, str] = {}
Dotenv(environ=sandbox).load_env("/etc/app/.env")

The file cascade

load_env(path) reads, in order:

Order File When it applies
1 path Always, or path.dist when path is missing
2 (reads env key from environ; defaults to default_env)
3 path.local Only outside test_envs — a developer's per-machine overrides
4 (re-reads env key) The .local file may have set a different environment
5 path.{env} When env is not "local"
6 path.{env}.local Same — a per-machine override for that environment

Later files override earlier ones. A real environment variable always wins over any file unless override_existing_vars=True is passed. XTR_DOTENV_VARS tracks which names came from a file so a later file may replace them; XTR_DOTENV_PATH records the base path so tooling can find it.

Variable expansion

Written Means
$VAR, ${VAR} the value of VAR, or empty when unset
${VAR:-default} default when VAR is unset or empty
${VAR:=default} the same, and record VAR=default for later lookups
\$ a literal dollar sign

Single-quoted values are never expanded — that is the escape hatch for a literal $ inside a password. $(command) is kept as text and never run: a settings loader should do no I/O beyond reading files. A default is plain text — a quote, a brace or a $ in one (${A:-${B}}), or a ${ never closed, is a FormatError naming the line. A value spanning lines must be quoted.

Expansion runs once every file of the cascade is read, so a value in .env may reference one only .env.local defines; a real environment variable wins inside an expansion as it does everywhere else. A self-referencing A=${A:-x} reads the value A has now (or the default) — it does not cycle. Anything still unresolved after five passes is a real cycle and raises VariableCircularReferenceError.

Typed settings from the cascade

DotenvSettings is a BaseSettings base whose subclass reads the layered cascade from a sandboxed copy of os.environ:

from typing import ClassVar

from xtr_dotenv import DotenvSettings


class AppSettings(DotenvSettings):
    _dotenv_path: ClassVar[str] = "/etc/app/.env"
    _dotenv_env_key: ClassVar[str] = "APP_ENV"
    _dotenv_default_env: ClassVar[str] = "prod"

    database_url: str
    log_level: str = "INFO"

Source priority is: init keyword arguments > real environment variables > the dotenv cascade > secret files > field defaults. No file the source reads writes into os.environ.

Errors

Everything the library raises derives from DotenvError, and carries the data as typed attributes.

Error Raised when
FormatError A line cannot be parsed (bad binding, key without =); carries .path, .line, .reason
PathError A file cannot be read; carries .path
VariableCircularReferenceError Variables reference each other and never resolve; carries .names

FormatError and VariableCircularReferenceError are also ValueErrors; PathError is also OSError. Existing except blocks keep working.

Kernel / bundle

An application using xtr-dependency-injection lists DotenvBundle in its app/bundles.py. The bundle does not load .env files — Dotenv().boot_env(...) runs before the kernel is built. What the bundle contributes is the two commands that need the kernel to know its project directory:

uv add "xtr-dotenv[di,console]"
# app/bundles.py
from xtr_dotenv.bundle import DotenvBundle

BUNDLES = {DotenvBundle: {"all": True}}
# app/config/dotenv.py
from xtr_dependency_injection import configure
from xtr_dotenv.bundle import DotenvConfig


@configure
def dotenv() -> DotenvConfig:
    return DotenvConfig(path="%kernel.project_dir%/.env", env_key="APP_ENV")
DotenvConfig field Meaning
path The base .env path; default %kernel.project_dir%/.env — parameter references are resolved by the kernel, and a relative path is taken from the project directory
env_key The variable naming the active environment
debug_key The variable naming debug mode
test_envs Environments where the .local overlay is skipped
prod_envs Environments considered production, used for debug defaulting

Commands

Command What it does
dotenv:dump [env] Compile the cascade for env (default: the kernel's env) into <path>.local.json. Runs on a fresh environ with only the env key, so real secrets never land in the dump
debug:dotenv [name] List the files that apply in cascade order (loaded / missing) and each variable's value per file, filtered by name when given

The commands are only registered when the console bundle is active; on a headless application the bundle is still valid and boots at zero config.

Layout

xtr_dotenv/
├── dotenv.py                       the loader: parse, load, overload, populate, load_env, boot_env
├── dotenv_settings_source.py       a pydantic-settings source that runs the cascade in a sandbox
├── dotenv_settings.py              BaseSettings base declaring the cascade behind the real env
├── exception/                      DotenvError + FormatError, PathError, VariableCircularReferenceError
├── command/                        dotenv:dump and debug:dotenv (xtr-console commands)
└── bundle/                         DotenvBundle for xtr-dependency-injection

Development

Developed in the python-xtr monorepo, under packages/xtr-dotenv; run the commands below from there. The python-xtr-dotenv repository is a read-only copy, so send issues and pull requests to the monorepo.

uv sync --all-extras
uv run ruff check
uv run ruff format --check
uv run basedpyright
uv run ty check
uv run pytest

License

MIT — see LICENSE.

Release files for xtr-dotenv 1.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xtr-dotenv 1.3.0
File Size Uploaded
xtr_dotenv-1.3.0.tar.gz 18.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for xtr-dotenv 1.3.0
File Interpreter ABI Platform
xtr_dotenv-1.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 42.6 kB

Release files / xtr_dotenv-1.3.0.tar.gz

Download URL xtr_dotenv-1.3.0.tar.gz
Size 18.4 kB
Tags Source
SHA-256 checksum
How to use checksums
8cf0cfb6ede90678d4dfce6edc381275b3f5804a29e9b99438949b7c28453e7f
BLAKE2b-256 checksum
How to use checksums
36511e0b07e2d2102a31e5fc57d9579c36931f630c52cb221bddfb3a4b9ba81b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release files / xtr_dotenv-1.3.0-py3-none-any.whl

Download URL xtr_dotenv-1.3.0-py3-none-any.whl
Size 24.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c22b6f921d67a72c0a67f88fdaf1373992252b692495719942a39ed62949a8ca
BLAKE2b-256 checksum
How to use checksums
1305ddbcfcd39ded67b0a841810dd30a463fd3e44c94d5618491a297ac75b199
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.3.0 This release

2 release files

1.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page