# How to Manage Environment Variables in a Python Project


API keys and database credentials change between machines and environments; hardcoding them leaks secrets into version control. A `.env` file keeps these values outside the code, where each environment supplies its own.

## Prerequisites

- [uv installed](https://pydevtools.com/handbook/how-to/how-to-install-uv.md)
- A uv project (run `uv init` to create one)

## Create a `.env` file

Add a `.env` file to the project root with one `KEY=value` pair per line:

```text {filename=".env"}
DATABASE_URL=postgres://localhost:5432/mydb
SECRET_KEY=change-me-in-production
API_KEY=sk-test-abc123
```

## Add `.env` to `.gitignore`

> [!WARNING]
> Add this line **before the first commit** that contains the `.env` file. Removing a file after it has been pushed does not erase it from Git history.

Open `.gitignore` and add:

```text
.env
```

Projects created with `uv init` ship a `.gitignore`, but it does not include `.env` by default. For full `.gitignore` setup, see [how to put your Python project on GitHub](https://pydevtools.com/handbook/how-to/how-to-put-your-python-project-on-github.md).

## Load variables with `uv run --env-file`

[uv](https://pydevtools.com/handbook/reference/uv.md)'s `--env-file` flag injects variables into the process environment before Python starts. `uv run` executes the command inside the project's {{< term "virtual-environment" "virtual environment" >}}, ensuring all dependencies are installed first.

```python {filename="main.py"}
import os

database_url = os.environ["DATABASE_URL"]  # raises KeyError if missing
api_key = os.getenv("API_KEY", "fallback")  # returns "fallback" if missing
print(database_url)
```

```bash
uv run --env-file .env python main.py
```

```text
postgres://localhost:5432/mydb
```

Without `--env-file`, the script ends with `KeyError: 'DATABASE_URL'` because no environment variable is set. If the file path is wrong, uv stops before Python starts:

```text
error: No environment file found at: `.missing`
```

Pass `--env-file` multiple times to layer files (later files override earlier ones). Set `UV_ENV_FILE=.env` in the shell to avoid typing the flag on every invocation. If the shell and the `.env` file both define the same variable, the shell value wins. In CI, where the secrets come from the runner's environment, pass `--no-env-file` (or set `UV_NO_ENV_FILE=1`) so a stray local `.env` never overrides them.

Credentials for a private package index are read by uv itself, not by the script: set `UV_INDEX_<NAME>_USERNAME` and `UV_INDEX_<NAME>_PASSWORD` in the shell as described in [Authenticate with a private index](https://pydevtools.com/handbook/how-to/how-to-use-private-package-indexes-with-uv.md#authenticate-with-a-private-index).

## Load variables with python-dotenv

When the project needs variables available at import time without passing a CLI flag, install python-dotenv:

```bash
uv add python-dotenv
```

Call `load_dotenv()` at the top of the entry point:

```python {filename="main.py"}
from dotenv import load_dotenv
import os

load_dotenv()  # reads .env; does not override variables already set in the shell

database_url = os.environ["DATABASE_URL"]
```

## Document required variables with `.env.example`

Create a `.env.example` that lists every variable the project expects, with placeholder values:

```text {filename=".env.example"}
DATABASE_URL=postgres://user:password@localhost:5432/dbname
SECRET_KEY=change-me
API_KEY=your-api-key-here
```

Commit `.env.example` to version control. New contributors copy it to `.env` and fill in real values. Keep it updated when adding or removing variables.

## Keep secrets away from AI coding agents

AI coding assistants read project files by default, including `.env`. For Claude Code, add a `Read` deny rule to the project's `.claude/settings.json`:

```json {filename=".claude/settings.json"}
{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)"
    ]
  }
}
```

Claude Code applies the rule to its file tools and to `cat`, `head`, `tail`, and `sed` in Bash, but not to a script that opens the file itself. Cursor ignores `*.env*` by default and reads extra patterns from `.cursorignore`, but its docs state that the terminal and MCP tools bypass the ignore list. Treat both as guardrails, not boundaries.

Catch secrets that leak back into source with [Ruff's security rules](https://pydevtools.com/handbook/how-to/how-to-enable-ruff-security-rules.md): S105 flags hardcoded password strings before they are committed.
