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
- pytest 7.0+ for the
monkeypatchfixture. - The broader isolation picture in faking the filesystem and environment.
Solution
monkeypatch.setenv and monkeypatch.delenv record the prior state and restore it at teardown, including when the test raises:
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:
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.
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.
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.
# 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").
# 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.
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:
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.
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:
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.
Edge cases and failure modes
monkeypatchis function-scoped. A session-scoped fixture cannot request it; useos.environwith an explicittry/finally, or themonkeypatchsession workaround viaMonkeyPatch.context().- Values must be strings.
setenv("PORT", 8080)raisesTypeError; passstr(8080), or useprepend=for path-like variables. - C-level caches read some variables once.
TZneedstime.tzset()after the change on POSIX, and locale settings need an explicitlocale.setlocalecall. .envfiles 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
PATHorHOMEbreaks subprocesses in ways that look unrelated — agitcall failing with a cryptic error is usually a missingHOME. - 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_PROXYandNO_PROXYare read byrequests,httpxandurllibdirectly 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.
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.
Related
- Faking the filesystem and environment — the parent guide covering paths, cwd and locale.
- Using tmp_path instead of tempfile in tests — the filesystem half of the same isolation problem.
- Replacing singletons and module globals in tests — why a value read at import cannot be patched later.
- Controlling time and randomness in tests —
TZand seeding, the other environment-driven flakiness.
← Back to Faking the Filesystem and Environment