skip to content

How does a virtual environment reshape sys.path when activation only edits PATH?

level: middleimportance: must knowfreq 62%

answer

  1. Activation is not what does the work
  2. A small config file beside the interpreter
  3. Two prefixes, one of them unchanged
  4. The default excludes more than you think
  5. pyvenv.cfg, sys.prefix versus sys.base_prefix

basics

~20 s

The interpreter does it, not the shell. Finding a pyvenv.cfg beside the executable, startup points sys.prefix at the environment root while sys.base_prefix keeps the base installation, so the site module builds site-packages under the environment instead.

solid answer

~40 s

Activation is cosmetic: it prepends the environment's `bin` (or `Scripts`) directory to `PATH` so that typing `python` reaches that executable. The real work happens inside the interpreter. At startup it looks for a `pyvenv.cfg` one directory above the executable; when it finds one it sets `sys.prefix` and `sys.exec_prefix` to the environment root, keeps the base installation in `sys.base_prefix` and `sys.base_exec_prefix`, and reads the file's `home` key to locate the standard library. The `site` module then builds site-packages from the *new* prefix. Because `include-system-site-packages` defaults to `false`, the base installation's site-packages is left out and `site.ENABLE_USER_SITE` is forced to `False`, so the per-user directory disappears too. Running the environment's interpreter by absolute path behaves identically to activating — the check for an environment is `sys.prefix != sys.base_prefix`.

code

console · 3 lines
console
python -m venv /tmp/demo-env
cat /tmp/demo-env/pyvenv.cfg
/tmp/demo-env/bin/python -c "import sys; print(sys.prefix); print(sys.base_prefix)"

go deeper

for a junior

Know that running the environment's own interpreter is what matters, not the activate command, and that packages installed while activated land in that environment's site-packages rather than the system one.

for a middle

Explain the mechanism end to end: pyvenv.cfg beside the executable, sys.prefix moved, sys.base_prefix retained for the standard library, and the site module building site-packages from the new prefix.

for a senior

Show how you debug a resolution difference between a shell and a deployed process: compare sys.executable, the two prefixes and the assembled site directories rather than re-activating and hoping. Know that the default also disables the per-user site directory.

for a principal

Own the reproducibility position: environments are built from a lockfile on the target rather than copied between hosts, and services invoke an absolute interpreter path so no shell state is load-bearing in production.

### The shell does almost nothing The most common misconception about virtual environments is that activation is what makes them work. It is not. `activate` is a shell script that prepends the environment's `bin` directory (`Scripts` on Windows) to `PATH`, sets a `VIRTUAL_ENV` variable and adjusts the prompt. That is the whole contract. It never touches `sys.path`, and it never has to: running `/path/to/env/bin/python script.py` with no activation at all produces exactly the same search list. Activation is a convenience for humans typing `python`; the environment is a property of the *interpreter you run*. ### What the interpreter does instead During startup, CPython resolves the executable's location and looks for a file named `pyvenv.cfg` in the directory above it. When that file exists, three things follow: 1. `sys.prefix` and `sys.exec_prefix` are set to the environment root — the directory containing `pyvenv.cfg`. 2. `sys.base_prefix` and `sys.base_exec_prefix` continue to hold the *base* installation's prefixes, so the standard library is still found in the original installation. That is why an environment is small: it contains no copy of the standard library, only a link or a small launcher plus its own `site-packages`. 3. The file's keys are read. `home` points at the base installation's `bin` directory. `include-system-site-packages` (default `false`) decides whether the base installation's own site-packages joins the search. A typical file is three or four lines: ``` home = /usr/local/bin include-system-site-packages = false version = 3.14.0 ``` Then `site` runs as it always does — but on the prefixes it now sees. `<env>/lib/python3.14/site-packages` (or `<env>\Lib\site-packages`) is what gets appended to `sys.path`, so an installer writing into the environment produces importable code, and the base installation's third-party code does not interfere. ### The two exclusions people forget With the default `include-system-site-packages = false`, `site` does two things, and the second surprises people. It restricts the prefixes it will build site directories from to the environment alone — so the base installation's site-packages is *not* on the path — and it sets `site.ENABLE_USER_SITE` to `False`, so the per-user site directory is not added either. An environment created with defaults is therefore isolated from both the system-wide and the user-level third-party trees. Flip `include-system-site-packages` to `true` and both come back: the base prefixes are appended after the environment's, and the per-user directory is enabled again. This is exactly why "it works outside the environment but not inside" is such a common report — the code being imported outside lives in one of the two directories the environment deliberately dropped. ### Detecting and debugging The reliable runtime check is `sys.prefix != sys.base_prefix`. Do not test for a `VIRTUAL_ENV` environment variable: it is set by activation only, so it is absent when a supervisor, a cron entry or a container `ENTRYPOINT` invokes the environment's interpreter by absolute path — which is the normal way production runs. Do not test for the string `venv` in a path either. When an import resolves differently than expected, the three-step check is mechanical: which executable is running (`sys.executable`), what prefixes it computed (`sys.prefix`, `sys.base_prefix`), and what `site` therefore appended (`site.getsitepackages()`, `site.ENABLE_USER_SITE`). `python -m site` prints most of it at once. Nine times out of ten the answer is that the process is running a different interpreter than the person believes — an unactivated shell, a `PATH` that resolves elsewhere, or a service unit that never sourced anything. ### Why this design is worth understanding Because the environment is encoded in a file beside the executable rather than in shell state, environments compose badly with anything that copies or moves them: `home` and the recorded `version` still point at the original base installation, so a relocated environment breaks in a way that has nothing to do with your code. Rebuilding the environment from a lockfile is the fix; patching `pyvenv.cfg` by hand is not. The same property is what makes environments reliable inside containers, where the interpreter path is fixed and no shell profile is ever involved. This mechanism has been the definition of a virtual environment since `venv` entered the standard library in 3.3, and it is unchanged on 3.14. ### One more consequence worth naming Because the environment is decided by the executable you run, tooling that shells out to `python` inherits whatever `PATH` resolves to at that moment — a subprocess launched from an activated shell is inside the environment, the same subprocess launched from a service manager may not be. When a child process must use the same environment as its parent, launch it with `sys.executable` rather than the bare name; that is the only spelling guaranteed to select the interpreter you are already running under.

  • Why does the environment's interpreter still find the standard library?
    Because only the prefixes used for site-packages move. `sys.base_prefix` and `sys.base_exec_prefix` still point at the base installation, and `pyvenv.cfg`'s `home` key records where it lives, so the standard library is imported from there. That is why an environment is a few megabytes rather than a full copy of Python.
  • What changes when include-system-site-packages is set to true?
    The base installation's site-packages is appended after the environment's own, so system-wide third-party code becomes importable, and `site.ENABLE_USER_SITE` is no longer forced off, so the per-user directory returns as well. You get less isolation and a search list that depends on whatever the host machine happens to have installed.
  • Why is checking the VIRTUAL_ENV environment variable a poor test?
    It is set by the activation script only. A service manager, a cron entry or a container entrypoint that runs `/path/to/env/bin/python` directly is fully inside the environment yet has no `VIRTUAL_ENV` set — the common production case. Compare `sys.prefix` with `sys.base_prefix` instead; it reflects what the interpreter actually computed.

The environment is a forwarding address left next to the interpreter; activation merely tells your shell which post office to walk into.

saying these in an interview costs you the question

  • Says the activate script edits sys.path
  • Believes an environment contains its own standard library
  • Detects an environment by the VIRTUAL_ENV variable
  • Thinks system site-packages is visible by default
  • Assumes the per-user site directory still applies inside one
  • Claims an environment can be moved or copied safely

context