An invoice-PDF renderer's .venv was copied from a laptop into the release bundle and the service dies at startup. Why does copying a venv break it, and what should ship instead?
answer
- It is a build output, not source
- Everything inside points at absolute paths
- The interpreter starts but the scripts do not
- home in pyvenv.cfg, and the shebangs
- Rebuild from pins on the target host
basics
~20 sA virtual environment is full of absolute paths pinned to the machine that built it: the home key in pyvenv.cfg, console-script shebangs, and the interpreter symlink. Ship pinned requirements and build the environment on the target instead.
solid answer
~50 sA virtual environment is a build artefact, not source, and it is not relocatable. `pyvenv.cfg` records the base interpreter by absolute path in its `home` key; `.venv/bin/python` is a symlink to an interpreter binary that must exist at that exact path on the target; and every console script installed into `.venv/bin` carries an absolute shebang naming the interpreter path *as it was at install time*. Move or copy the directory and those scripts fail with "bad interpreter", even though `.venv/bin/python -m ...` may still work — which is why the first traceback often looks like an application problem, and teams chase a phantom circular import instead of reading the shebang. Compiled extension wheels add a second failure mode: they are built for one interpreter version, platform and libc. The release should carry pinned requirements and create the environment on the target as a build step.
code
console · 4 linespython3 -m venv .venv
head -1 .venv/bin/pip
grep -E '^(home|version)' .venv/pyvenv.cfg
.venv/bin/python -c "import sys; print(sys.executable); print(sys.base_prefix)"go deeper
Take away the rule: never copy or commit an environment directory. Recreate it from the project's pinned requirements on each machine, and treat it as disposable rather than as part of the project.
Be able to point at the absolute paths that make an environment non-portable — the home key, the interpreter symlink and the shebang on each installed console script — and explain why the interpreter can still start while a script fails.
Demonstrate the triage order: prove the interpreter and its prefixes are sane before reading the application's imports, and recognise a startup failure that moves with the directory rather than with the code.
Own the deployment contract: what the release bundle contains, where the environment is built, and how the interpreter version is pinned and verified — so that no release depends on which machine happened to produce the artefact.
### A virtual environment is a build output The single sentence that resolves this class of incident is: an environment directory is not source and not a portable bundle — it is a build artefact tied to the machine, the path and the interpreter that produced it. Treating it as something you can zip up and hand around produces failures that look nothing like their cause. ### The four absolute anchors inside an environment **1. `pyvenv.cfg`'s `home` key.** It records the base interpreter's directory as an absolute path. If the target host has no interpreter there, or has a different minor version there, the environment's standard library lookup is aimed at nothing. **2. The interpreter entry point.** `.venv/bin/python` is normally a symlink chain ending at the base binary. Copy the tree with a tool that follows symlinks and you get a real binary that may not match the target's shared libraries; copy it preserving symlinks and it dangles unless the base installation exists at the same path. **3. Console-script shebangs.** Every entry point installed into `.venv/bin` gets an absolute shebang naming the interpreter path at install time. Rename the project directory and the very same command that worked yesterday fails with `bad interpreter: ... no such file or directory`, while `.venv/bin/python -m ...` keeps working — an asymmetry that is confusing precisely because it is partial. **4. Installed compiled extensions.** A wheel with C extensions is built for one interpreter version, one platform and one C library. A laptop-built environment lands on a server with a different libc or CPU family and the import fails at load time, not at install time — so the error surfaces during startup, deep inside the application's import graph. ### Why the traceback misleads Startup failures caused by a broken environment surface as import errors, and import errors are read as application bugs. A renderer that fails while pulling in its font and layout dependencies looks exactly like a module-graph problem; teams reach for the familiar explanation — a circular import introduced by the last refactor — and spend an afternoon rearranging imports that were never wrong. The tell is that the same commit works on the machine that built the environment, and that the failure moves when the *directory* moves, not when the code changes. ### The thirty-second triage 1. `head -1 .venv/bin/<script>` — does that shebang path exist on this host? 2. `.venv/bin/python -c "import sys; print(sys.executable, sys.prefix, sys.base_prefix)"` — does the interpreter start at all, and does it point where you expect? 3. `cat .venv/pyvenv.cfg` — is `home` a path that exists here, and does `version` match the interpreter the host actually has? 4. Only if all three are clean should you start reading the application's import graph. ### What to ship instead Ship the *inputs*, not the environment. The release bundle carries the source and a pinned requirement set; the deployment step runs `python -m venv --clear /srv/app/.venv` on the target and installs into it. This gives you an environment built by the target's own interpreter, with wheels chosen for the target's platform, and it makes the interpreter version an explicit, checked part of the deployment rather than an accident of whose laptop cut the release. Two related moves are worth knowing. `python -m venv --upgrade .venv` re-points an existing environment at the interpreter running the command, which covers an in-place upgrade of the base installation — but it does not rewrite the shebangs of already-installed console scripts, so it is a repair, not a rebuild. And invoking tools as `.venv/bin/python -m <module>` instead of through their console script removes one of the four anchors entirely, which is a cheap habit that survives a directory rename. ### The judgement to demonstrate An environment is cheap to rebuild and expensive to move. Version-control the requirement pins and the command that builds the environment; never version-control or copy the environment itself. When a service fails at startup after a deployment that did not change any code, suspect the environment before the import graph. ### What to cache instead, in CI The instinct that produced the copied environment usually reappears as "let us cache the environment directory between builds". It has the same defect the moment the runner image or the interpreter changes underneath it. Cache the things that are genuinely position-independent instead — the downloaded distributions and the locally built wheels — and let each build create the environment fresh from pins. Creating an empty environment costs a fraction of a second, installing from a warm wheel cache costs little more, and in return the build's result does not depend on what a previous build happened to leave behind.
- The base interpreter was upgraded in place on the host. When does that break existing environments, and what does `python -m venv --upgrade` fix?A patch-level upgrade that keeps the same minor version and the same install path is usually survivable, because `home`, the site-packages directory name and the extension ABI are all unchanged. A minor-version change or a moved installation breaks it: the symlink dangles and the environment's site-packages path no longer matches. `--upgrade` re-points an existing environment at the interpreter running the command, but it does not reinstall packages or rewrite installed console-script shebangs, so a rebuild from pins is usually the honest fix.
- How would you decide in under a minute whether the failure is the environment or the application?Ask whether the interpreter itself is healthy before reading any application code. Start `.venv/bin/python` on its own and print `sys.executable`, `sys.prefix` and `sys.base_prefix`; check the first line of a failing console script against the filesystem; check that `home` in `pyvenv.cfg` exists and that `version` matches the host's interpreter. If those are all consistent and a plain interpreter starts cleanly, only then is the import graph a suspect.
- Is committing the .venv directory to version control ever defensible?No. It is large, platform-specific, full of absolute paths, and it makes review meaningless — a dependency change appears as thousands of binary diffs. It also hides the actual dependency decision, which is what reviewers need to see. Commit the pinned requirement set and the command that builds the environment; recent CPython even writes a source-control ignore file into the environment directory for you.
Copying an environment is like mailing someone the shortcuts from your desktop: they still name folders that only exist on your machine.
saying these in an interview costs you the question
- Treats a .venv directory as source to commit and copy
- Blames application code when the interpreter itself will not start
- Thinks --copies makes an environment relocatable
- Assumes wheels with compiled extensions work on any host
- Renames a project directory and expects console scripts to keep working
- Copies site-packages between machines instead of reinstalling