skip to content

A loader module reads os.environ at import time; why does patching the variable in the test change nothing?

level: seniorimportance: should knowfreq 40%

answer

  1. The body runs once, not per test
  2. sys.modules hands back the same object
  3. A computed constant is no longer a lookup
  4. Read at call time, not at import time
  5. reload rebuilds classes and repeats side effects

basics

~20 s

The module body ran once, at first import, and froze the value into a module global; every later import returns that same cached module. A change to os.environ afterwards cannot travel back into an expression that already evaluated.

solid answer

~50 s

A module body executes exactly once per interpreter, the first time the name is imported; the module object goes into `sys.modules` and later imports hand it back without re-running anything. So `CUTOFF_HOURS = int(os.getenv("LAB_CUTOFF_HOURS", "24"))` is a value computed at import time — by the time the test patches the environment, the global holds a plain number with no link to the mapping. There are three ways out, in ascending order of quality. Patch the module global itself, with a restoring patch, and accept that you are testing around the design. Set the variable before the first import, which makes correctness depend on import order and is fragile inside a suite. Or change the code to read the environment inside the function that needs it, or take the configuration as a parameter. `importlib.reload` looks like a fix but re-runs every import-time side effect and builds new class objects, so existing instances stop matching `isinstance`.

code

python · 11 lines
python
import os

CUTOFF_HOURS = int(os.getenv("LAB_CUTOFF_HOURS", "24"))


def cutoff_hours():
    return int(os.getenv("LAB_CUTOFF_HOURS", "24"))


os.environ["LAB_CUTOFF_HOURS"] = "0"
print(CUTOFF_HOURS, cutoff_hours())

go deeper

for a junior

Remember that importing a module runs its top-level code once per process and the second import gets the cached module. That single fact explains most 'my patch did nothing' surprises you will hit.

for a middle

Explain the difference between a value computed at import time and one read at call time, and be able to show the one-line refactor that moves the environment read inside the function that needs it.

for a senior

Diagnose it in a running service: find the import-time work, weigh patching the module global against restructuring the read, and say plainly why reload leaves the suite worse than it found it.

for a principal

Own the convention that module bodies hold definitions only and configuration is read and validated once at a startup boundary, then passed inward — plus the migration path for a codebase that already does the opposite.

### The scenario A clinical-lab result loader has a module that begins: ```python import os CUTOFF_HOURS = int(os.getenv("LAB_CUTOFF_HOURS", "24")) ``` A bug report says a 6,800-row batch was rejected wholesale because a few analyser timestamps arrived ahead of the server clock — a clock-skew artefact. You write a test that sets `LAB_CUTOFF_HOURS` to `0` to reproduce the strict-cutoff behaviour, and the loader behaves exactly as it always did. The patch is correct; it simply cannot reach the value. ### Why the patch cannot reach it Importing a module the first time runs its body top to bottom in a fresh namespace and then stores the module object in `sys.modules`. Every subsequent `import` of that name — from any module, in any test, in any order — finds it in that mapping and returns the same object without executing a single line of the body again. The module body is therefore a one-shot event whose timing you do not control: it happens at whichever import comes first, quite possibly during collection, before any test has run. `int(os.getenv(...))` is just an expression. Once it has evaluated, `CUTOFF_HOURS` is the number `24`. It is not a view onto the environment, not a lazy proxy, not a descriptor. Patching `os.environ` afterwards changes a mapping that nothing is going to read again. The same reasoning covers everything else people put in module bodies: a client object built from a URL, a connection pool, a compiled pattern that depends on a locale, a thread started at import, a handler registered into a global registry. Anything computed at import is frozen at import. ### The four responses, worst to best **Reload the module.** `importlib.reload(mod)` re-executes the body in the *existing* module namespace. It appears to work and quietly damages the suite. Every import-time side effect happens a second time — a second thread, a second registry entry, a second connection. Class statements build brand-new class objects, so instances created earlier keep the old ones: `isinstance(old_instance, mod.Record)` is now False, and `except mod.LoadError` stops catching the exception another module raises. Any module that did a from-import still holds the objects from the first execution, so half the process sees the old module and half the new one. Reload has legitimate uses in a REPL; in a suite it converts one order-dependent problem into several. **Set the variable before the first import.** This genuinely works, and it is what a runner-level startup file does. Inside a suite it is fragile: correctness now depends on which test imported the module first, which changes when tests are renamed, reordered, sharded or run in parallel. Treat it as a stopgap with a ticket attached. **Patch the module global.** `patch.object(lab_loader, "CUTOFF_HOURS", 0)` is honest, restores itself, and is order-safe. Its weakness is fidelity: you are asserting on a value you injected rather than on the code path that reads configuration, so the parsing, validation and defaulting logic in the module body is never exercised by any test. It is the right pragmatic answer for third-party code you cannot change. **Move the read to call time.** Make the module body definitions only, and read the environment inside the function that needs the value — or better, parse the environment once at a startup boundary into a small configuration object and pass it in as a parameter. Now the value is looked up when the call happens, `patch.dict` on `os.environ` works as expected for the boundary tests, and the tests below the boundary just pass a number. ### Diagnosing it in the wild The symptom is distinctive: a patch that provably applies (you can print the mapping inside the test) yet changes nothing. Check whether the name you are patching is read at call time or was evaluated during import. A quick scan of the module body for anything that is not a `def`, `class`, `import` or literal constant usually finds it in seconds. In a long-lived service, the same import-time computation also explains configuration that ignores a runtime change, and behaviour that depends on which entry point started the process. ### The rule worth stating Import should be cheap and side-effect free: definitions, and constants that depend on nothing outside the file. Everything that reads the world — environment, filesystem, network, clock — belongs behind a function the caller invokes, at a moment the caller chooses. That single convention removes this entire class of untestable module, and it also makes start-up time and import order stop mattering.

  • Why is importlib.reload a poor fix for this inside a test suite?
    It re-executes the module body in the existing namespace, so every import-time side effect runs again — threads started, registries appended, connections opened a second time — and each class statement creates a new class object. Instances built before the reload keep the old classes, so `isinstance` against the reloaded class is False and exception handlers stop matching. Any module that did a from-import still holds the original objects, leaving the process half-updated.
  • What kinds of import-time work make a module hard to test at all?
    Anything with an effect: reading configuration, opening a file or connection, starting a thread or an event loop, registering into a global registry, or calling out over the network. The import system gives you no seam — the work happens the moment any test imports the module, before a fixture can intervene, and it cannot be repeated or undone. Keep module bodies to definitions and cheap constants.
  • Setting the environment variable before the first import does work. When is that acceptable?
    Only at the process edge — an entry point or runner startup file that sets the variable and then imports. Inside a suite it makes the result depend on which test imported the module first, an order dependency that survives quietly until someone shards or shuffles the tests. Use it as a stopgap, and record the code change that removes the import-time read.

saying these in an interview costs you the question

  • Thinks each test re-executes the imported module body
  • Expects a module constant to track os.environ live
  • Reaches for importlib.reload as the standard fix
  • Believes deleting the sys.modules entry is free
  • Blames the patching tool rather than import-time evaluation
  • Treats connections and threads started at import as harmless

context