Isolation & Contracts

Patching Environment Variables with monkeypatch.setenv

A test sets os.environ["APP_REGION"] = "eu-west-1", passes, and leaves the variable set for every test that follows. Another test reads a variable the developer's shell happens to define and passes locally but fails in CI. Both are the same bug: the environment is process-global state, and pytest gives you exactly one tool that treats it as such.

Prerequisites

Solution

monkeypatch.setenv and monkeypatch.delenv record the prior state and restore it at teardown, including when the test raises:

Python
def test_client_uses_the_configured_region(monkeypatch):
    monkeypatch.setenv("APP_REGION", "eu-west-1")
    monkeypatch.setenv("APP_TIMEOUT", "5")
    monkeypatch.delenv("APP_DEBUG", raising=False)     # absent is a valid state

    client = build_client()

    assert client.region == "eu-west-1"
    assert client.timeout == 5

raising=False matters more than it looks: a test that must run with a variable absent cannot assume it was present, and the default raising=True turns a clean environment into a KeyError.

For a set of variables shared by many tests, an autouse fixture establishes a known baseline so no test inherits the developer's shell:

Python
import pytest

BASELINE = {
    "APP_REGION": "eu-west-1",
    "APP_TIMEOUT": "5",
    "TZ": "UTC",
}

@pytest.fixture(autouse=True)
def _env_baseline(monkeypatch):
    for name in ("APP_DEBUG", "APP_PROFILE", "AWS_PROFILE"):
        monkeypatch.delenv(name, raising=False)        # nothing inherited
    for name, value in BASELINE.items():
        monkeypatch.setenv(name, value)

The delete list is the half people omit, and it is the half that makes a suite reproducible on a machine that is not yours.

What monkeypatch records and restores A left-to-right sequence: monkeypatch records the previous value or its absence, applies the new value, the test body runs, and teardown restores exactly the previous state whether the test passed or raised. What monkeypatch records and restores record prior state value or absence apply the change setenv or delenv run the test may raise restore always, in teardown A plain os.environ assignment has no step one and no step four.
The recording step is why monkeypatch is safe where a bare assignment is not: absence is a state it can restore to.

Why this works

monkeypatch maintains an undo stack. Each setenv pushes the previous value — or a sentinel meaning "was not set" — and the fixture's finalizer pops the stack in reverse order during teardown, which pytest runs even when the test fails. Because the stack is per-test and the fixture is function-scoped, isolation is automatic: no test can observe another's changes, and no ordering assumption is required. The same mechanism backs setattr, delattr, chdir and syspath_prepend, which is why those should be preferred over their manual equivalents for exactly the same reason.

The monkeypatch methods for process state A table of four monkeypatch methods - setenv, delenv, chdir and syspath_prepend - stating what each changes and what it restores at teardown. The monkeypatch methods for process state Criterion Changes Restores setenv(name, value) one variable the prior value or absence delenv(name, raising=False) removes a variable the prior value chdir(path) the working directory the original directory syspath_prepend(path) sys.path[0] the original sys.path
All four share one undo stack, so a single fixture can change several kinds of process state and rely on complete restoration.

When the variable is read at import

The most common report — "setenv does nothing" — is not about setenv at all. It is about when the value was read.

Python
# config.py — read once, at import
import os
REGION = os.environ.get("APP_REGION", "us-east-1")     # frozen at import time

# The test sets the variable, but REGION was bound before the test ran.
def test_region(monkeypatch):
    monkeypatch.setenv("APP_REGION", "eu-west-1")
    assert config.REGION == "eu-west-1"                # FAILS: still us-east-1

Three fixes, in order of preference. Read the variable inside a function so every call sees the current environment; cache it behind an accessor that tests can clear; or, as a last resort, patch the already-bound constant with monkeypatch.setattr("config.REGION", "eu-west-1").

Python
# config.py — read on use, cached
from functools import cache
import os

@cache
def region() -> str:
    return os.environ.get("APP_REGION", "us-east-1")

# The test clears the cache, then the accessor sees the patched environment.
def test_region(monkeypatch):
    monkeypatch.setenv("APP_REGION", "eu-west-1")
    config.region.cache_clear()
    assert config.region() == "eu-west-1"

This is the environment-variable instance of the general problem covered in replacing singletons and module globals in tests: a value captured at import cannot be changed by patching its source afterwards.

Subprocesses, workers and inheritance

os.environ is copied into a child at spawn time, so a subprocess started after setenv sees the patched value and one started before does not. That makes the ordering inside a test significant when the code under test manages a long-lived child.

Under pytest-xdist each worker is a separate process with its own copy of the environment, inherited from the controller at startup. A variable set by a test in one worker is invisible to the others, which is usually what you want — but it also means a variable exported by a session-scoped fixture must be set in every worker, not once in the controller.

Python
import os, subprocess

def test_child_sees_the_patched_value(monkeypatch):
    monkeypatch.setenv("APP_REGION", "eu-west-1")
    out = subprocess.run(
        ["python", "-c", "import os; print(os.environ['APP_REGION'])"],
        capture_output=True, text=True, check=True,
    )
    assert out.stdout.strip() == "eu-west-1"      # inherited at spawn time

For variables that must reach a child but should not be visible to the parent's own code, pass an explicit env= mapping to subprocess.run instead of mutating the environment at all — a smaller blast radius and a clearer test.

Verification

A teardown assertion turns any bypass of monkeypatch into an immediate, attributable failure:

Python
import os
import pytest

@pytest.fixture(autouse=True)
def _no_env_leak(request):
    before = dict(os.environ)
    yield
    changed = {k for k in set(before) | set(os.environ)
               if before.get(k) != os.environ.get(k)}
    assert not changed, f"{request.node.nodeid} leaked env vars: {sorted(changed)}"

Run the suite in a randomised order once the assertion is in place; order-dependence caused by environment leakage surfaces immediately rather than in the pull request that happens to add a test in the middle.

Making configuration testable rather than patchable

Every technique on this page is a workaround for configuration that reads the environment at a point the test cannot reach. The durable fix is a settings object constructed once, from an explicit mapping, and passed to whatever needs it.

Python
from dataclasses import dataclass
import os

@dataclass(frozen=True)
class Settings:
    region: str
    timeout: float
    debug: bool

    @classmethod
    def from_env(cls, env: dict[str, str] | None = None) -> "Settings":
        env = os.environ if env is None else env      # the seam: injectable
        return cls(
            region=env.get("APP_REGION", "us-east-1"),
            timeout=float(env.get("APP_TIMEOUT", "5")),
            debug=env.get("APP_DEBUG") == "1",
        )

A test then builds settings directly, with no patching at all:

Python
def test_client_timeout_is_configurable():
    settings = Settings.from_env({"APP_TIMEOUT": "0.5"})
    assert build_client(settings).timeout == 0.5

Three properties make this better than monkeypatch for anything beyond a one-off. The dependency is visible in the signature rather than ambient. The parsing — string to float, string to bool — is tested directly, which matters because environment values are always strings and the conversion is where the bugs are. And the object is frozen, so a test cannot accidentally mutate configuration that another test then observes.

monkeypatch.setenv remains the right tool for code you do not own, for the process-level variables (TZ, LC_ALL, PYTHONHASHSEED) that libraries read directly, and for integration tests that exercise the real startup path. For your own application configuration, injecting a settings object removes the question entirely.

From ambient environment to injected settings A left-to-right progression: code reading os.environ directly at call sites, then a settings object built from the environment, then a settings object built from an injected mapping, and finally settings passed to every consumer. From ambient environment to injected settings os.environ inline ambient everywhere settings from env one read point injectable mapping tests pass a dict passed to consumers no patching at all
Each step reduces the number of places the environment is read, and the last one removes the need for monkeypatch in application tests entirely.

Edge cases and failure modes

  • monkeypatch is function-scoped. A session-scoped fixture cannot request it; use os.environ with an explicit try/finally, or the monkeypatch session workaround via MonkeyPatch.context().
  • Values must be strings. setenv("PORT", 8080) raises TypeError; pass str(8080), or use prepend= for path-like variables.
  • C-level caches read some variables once. TZ needs time.tzset() after the change on POSIX, and locale settings need an explicit locale.setlocale call.
  • .env files loaded by a library at import reintroduce the import-time problem, and often override what the test set; disable the loader in the test environment.
  • Deleting PATH or HOME breaks subprocesses in ways that look unrelated — a git call failing with a cryptic error is usually a missing HOME.
  • Secrets set in a test can leak into logs. pytest captures stdout, and a failing test prints it; keep real credentials out of the test environment entirely. One more variable class deserves explicit handling: proxy settings. HTTP_PROXY, HTTPS_PROXY and NO_PROXY are read by requests, httpx and urllib directly from the environment, so a developer running behind a corporate proxy gets different network behaviour from CI without either environment mentioning it in configuration. Clearing all three in the baseline fixture removes a category of 'works for me' that is otherwise very hard to attribute, because the symptom is a connection error rather than anything mentioning proxies.
Where an environment value can come from A left-to-right chain of environment value sources: the developer shell or CI runner, a dotenv file loaded at import, the baseline test fixture, and finally a per-test override. Where an environment value can come from shell / runner uncontrolled .env loader import time baseline fixture pinned per suite per-test setenv the value under test Disable the dotenv loader in tests, or the second stage silently wins.
Each stage overrides the previous one, so a value that appears wrong is usually being set by a stage nobody remembered.

Frequently Asked Questions

Why is my environment variable ignored by the code under test? Almost always because the value was read at import time into a module-level constant. Setting the variable afterwards changes os.environ but not the constant, so the code keeps using the value captured when the module was first imported.

Does monkeypatch.setenv affect subprocesses? Yes. It mutates os.environ in the current process, and a subprocess started afterwards inherits that copy. A subprocess started before the change keeps the old environment.

How do I remove a variable that may not be set?monkeypatch.delenv("NAME", raising=False) deletes it if present and does nothing otherwise. Without raising=False the call raises KeyError when the variable is absent, which is rarely what a test wants. Can I set environment variables for the whole session? Yes, with MonkeyPatch.context() inside a session-scoped fixture, which gives the same undo semantics at a wider scope. Use it for values that are genuinely constant for the run — a test-mode flag, a fixed timezone — and keep per-test values in the function-scoped fixture, or the session value will mask what an individual test tried to set.

← Back to Faking the Filesystem and Environment