DEV Community

Aniketh Deshpande
Aniketh Deshpande

Posted on

Your Python venv Is (Mostly) a Symlink — Here's Why That Matters

TL;DR — On Linux and macOS, python3 -m venv .venv does not create a copy of Python.
The python inside your venv is a symlink that points back to the system interpreter.
The standard library isn't copied either. Only site-packages (your installed packages) really belongs to the venv.
So if the system Python gets upgraded, moved or deleted, your venv can break outright,
or keep running against a different Python without telling you.


Table of Contents

  1. The claim
  2. Part 1 — See it with your own eyes (GCP VM / EC2 lab)
  3. Part 2 — What does this mean? Pros, cons, and real-world impact
  4. Part 3 — Building a venv that doesn't betray you
  5. Cheat sheet

The claim

Most of us picture a virtual environment like this:

   What we THINK a venv is
   ───────────────────────

   .venv/
   ├── 🐍 a full private copy of Python
   ├── 📚 a full private copy of the standard library
   └── 📦 my packages
Enter fullscreen mode Exit fullscreen mode

Here's what you actually get:

   What a venv ACTUALLY is (Linux/macOS default)
   ─────────────────────────────────────────────

   .venv/
   ├── bin/
   │   ├── python   ──symlink──►  python3
   │   ├── python3  ──symlink──►  /usr/bin/python3   ◄── the SYSTEM interpreter
   │   ├── pip                    (small script, absolute shebang)
   │   └── activate               (shell script)
   ├── lib/python3.12/site-packages/   ◄── the ONLY truly private part
   └── pyvenv.cfg                      ◄── "home = /usr/bin"  (a pointer back)

   Standard library (os, json, asyncio, ssl, ...)?  → still read from /usr/lib/python3.12
Enter fullscreen mode Exit fullscreen mode

A venv is really an isolated site-packages folder, plus a pointer back to an interpreter that lives somewhere else.
I'll prove it.


Part 1 — See it with your own eyes

You can run this on any Linux box. A throwaway cloud VM is ideal because it's clean and you can wreck it without worrying.

Step 0: Get a VM

Option A — Google Cloud (GCE)

gcloud compute instances create venv-lab \
  --zone=us-central1-a \
  --machine-type=e2-micro \
  --image-family=ubuntu-2404-lts-amd64 \
  --image-project=ubuntu-os-cloud

gcloud compute ssh venv-lab --zone=us-central1-a
Enter fullscreen mode Exit fullscreen mode

Option B — AWS (EC2)

aws ec2 run-instances \
  --image-id resolve:ssm:/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id \
  --instance-type t3.micro \
  --key-name <your-key-pair> \
  --security-group-ids <sg-allowing-ssh> \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=venv-lab}]'

ssh -i <your-key>.pem ubuntu@<public-ip>
Enter fullscreen mode Exit fullscreen mode

Once you're in, install the venv module (Ubuntu ships it as a separate package):

sudo apt update && sudo apt install -y python3-venv
python3 --version          # Python 3.12.x on Ubuntu 24.04
Enter fullscreen mode Exit fullscreen mode

💡 The output below comes from Ubuntu 24.04 / Python 3.12. Other distros and versions show the same pattern, with different version numbers.


Experiment 1: Look inside bin/

cd ~
python3 -m venv demo
ls -la demo/bin/ | grep python
Enter fullscreen mode Exit fullscreen mode
lrwxrwxrwx 1 ubuntu ubuntu   7 ... python -> python3
lrwxrwxrwx 1 ubuntu ubuntu  16 ... python3 -> /usr/bin/python3
lrwxrwxrwx 1 ubuntu ubuntu   7 ... python3.12 -> python3
Enter fullscreen mode Exit fullscreen mode

The l at the start of each permission string and the -> arrows mean these are symlinks. None of them is a real binary.

Experiment 2: Follow the chain

readlink -f demo/bin/python
readlink -f /usr/bin/python3
Enter fullscreen mode Exit fullscreen mode
/usr/bin/python3.12
/usr/bin/python3.12
Enter fullscreen mode Exit fullscreen mode

They end at the same file. You can also check that the venv entry is just a link and not a hard copy:

stat -c '%i  %s bytes  %n' /usr/bin/python3.12 demo/bin/python3
Enter fullscreen mode Exit fullscreen mode
16123164  6939856 bytes  /usr/bin/python3.12   ← the real binary (~7 MB)
18122480       16 bytes  demo/bin/python3      ← a 16-byte symlink
Enter fullscreen mode Exit fullscreen mode
   Symlink resolution chain
   ────────────────────────

   demo/bin/python
        │  (symlink)
        ▼
   demo/bin/python3
        │  (symlink)
        ▼
   /usr/bin/python3
        │  (symlink, owned by the distro)
        ▼
   /usr/bin/python3.12   ◄── the one and only real interpreter binary
Enter fullscreen mode Exit fullscreen mode

Experiment 3: Read pyvenv.cfg, the venv's "birth certificate"

cat demo/pyvenv.cfg
Enter fullscreen mode Exit fullscreen mode
home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/ubuntu/demo
Enter fullscreen mode Exit fullscreen mode

home = /usr/bin is the key line. At startup, Python sees this file and uses it to find its real home (and the standard library that comes with it).

Experiment 4: Ask Python where things come from

demo/bin/python - <<'EOF'
import sys, os, json
print("sys.executable :", sys.executable)
print("realpath       :", os.path.realpath(sys.executable))
print("sys.prefix     :", sys.prefix)        # the venv
print("sys.base_prefix:", sys.base_prefix)   # the REAL Python install
print("json stdlib    :", json.__file__)
EOF
Enter fullscreen mode Exit fullscreen mode
sys.executable : /home/ubuntu/demo/bin/python
realpath       : /usr/bin/python3.12
sys.prefix     : /home/ubuntu/demo
sys.base_prefix: /usr
json stdlib    : /usr/lib/python3.12/json/__init__.py   ← NOT inside the venv!
Enter fullscreen mode Exit fullscreen mode

There are two prefixes. sys.prefix is your venv, where packages get installed. sys.base_prefix is the system install, where the interpreter and the stdlib come from.

Experiment 5: Check the size

du -sh demo
ls demo/lib/python3.12/site-packages
Enter fullscreen mode Exit fullscreen mode
12M   demo
pip  pip-24.0.dist-info
Enter fullscreen mode Exit fullscreen mode

Almost all of those 12 MB are pip. The interpreter and the stdlib (tens of MB) aren't there, because they're borrowed.

Experiment 6: Move the venv and watch it break

head -1 demo/bin/pip
mv demo demo-moved
demo-moved/bin/pip --version
Enter fullscreen mode Exit fullscreen mode
#!/home/ubuntu/demo/bin/python3
bash: demo-moved/bin/pip: /home/ubuntu/demo/bin/python3: bad interpreter: No such file or directory
Enter fullscreen mode Exit fullscreen mode

Every console script (pip, pytest, black, and so on) has the absolute path of the venv baked into its shebang line. Venvs aren't relocatable.

mv demo-moved demo   # put it back
Enter fullscreen mode Exit fullscreen mode

Experiment 7: Delete the base Python (the scary one)

We don't want to wreck the VM's real /usr/bin/python3, since apt and cloud-init depend on it. So we'll build a fake "system Python" in /opt, create venvs from it, then "uninstall" it.

# 1. Build a standalone copy of the interpreter + stdlib at /opt/fakepy
sudo mkdir -p /opt/fakepy/bin /opt/fakepy/lib
sudo cp /usr/bin/python3.12 /opt/fakepy/bin/
sudo cp -r /usr/lib/python3.12 /opt/fakepy/lib/
/opt/fakepy/bin/python3.12 -c "import sys; print(sys.prefix)"   # → /opt/fakepy

# 2. Create two venvs from it: default (symlinks) and --copies
/opt/fakepy/bin/python3.12 -m venv --without-pip ~/linked
/opt/fakepy/bin/python3.12 -m venv --without-pip --copies ~/copied
ls -l ~/linked/bin/python3.12     # → /opt/fakepy/bin/python3.12
ls -l ~/copied/bin/python3.12     # → a real 7 MB file

# 3. Simulate "the base Python was upgraded / uninstalled"
sudo mv /opt/fakepy /opt/fakepy.gone
Enter fullscreen mode Exit fullscreen mode

Now run both:

~/linked/bin/python -c 'print("hello")'
Enter fullscreen mode Exit fullscreen mode
bash: /home/ubuntu/linked/bin/python: No such file or directory
Enter fullscreen mode Exit fullscreen mode

💥 The symlink is dangling, so the venv is dead.

~/copied/bin/python -c 'import sys, os; print(sys.base_prefix, os.__file__)'
Enter fullscreen mode Exit fullscreen mode
/usr /usr/lib/python3.12/os.py
Enter fullscreen mode Exit fullscreen mode

😱 The --copies venv did start, but pyvenv.cfg pointed to a home that no longer exists. Python fell back to its compiled-in prefix (/usr) and silently switched to a different standard library. Here the two happened to be identical, so nothing went wrong. On a real machine they could be different patch or minor versions, and you'd get strange ImportErrors or subtle bugs that nobody can explain.

Clean up:

sudo mv /opt/fakepy.gone /opt/fakepy   # or: sudo rm -rf /opt/fakepy.gone
rm -rf ~/linked ~/copied
Enter fullscreen mode Exit fullscreen mode

And when you're finished with the lab, delete the VM:

gcloud compute instances delete venv-lab --zone=us-central1-a   # GCP
aws ec2 terminate-instances --instance-ids <instance-id>          # AWS
Enter fullscreen mode Exit fullscreen mode

Part 2 — What does this mean?

How a venv actually boots

This comes from PEP 405, the spec that defines venv:

  $ .venv/bin/python app.py
            │
            ▼
  ┌──────────────────────────────────────┐
  │ Kernel follows symlinks              │
  │ .venv/bin/python → /usr/bin/python3.12│  ← executes the SYSTEM binary
  └──────────────────────────────────────┘
            │  (but argv[0] / sys.executable still says .venv/bin/python)
            ▼
  ┌──────────────────────────────────────┐
  │ Python looks for pyvenv.cfg next to  │
  │ (or one level above) sys.executable  │
  └──────────────────────────────────────┘
            │ found!
            ▼
  ┌──────────────────────────────────────┐        ┌──────────────────────────────┐
  │ sys.prefix      = .venv              │ ─────► │ .venv/lib/python3.12/        │
  │                                      │        │        site-packages/  📦     │
  │ sys.base_prefix = derived from       │        └──────────────────────────────┘
  │                   "home = /usr/bin"  │        ┌──────────────────────────────┐
  │                                      │ ─────► │ /usr/lib/python3.12/  📚      │
  └──────────────────────────────────────┘        │ (os, json, ssl, asyncio ...) │
                                                  └──────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

So a venv is three things:

Piece Where it lives Owned by
Interpreter binary /usr/bin/python3.12 (symlinked) 🧑‍💼 The OS / package manager
Standard library /usr/lib/python3.12/ (referenced) 🧑‍💼 The OS / package manager
Third-party packages .venv/lib/python3.12/site-packages/ 🙋 You

⚠️ Being precise: "the venv is a symlink" is a handy shorthand, but the venv directory is real, and so is site-packages.
What's symlinked is the interpreter. The stdlib is borrowed through pyvenv.cfg and never linked or copied.
On Windows, venvs use a small copied python.exe launcher instead of a symlink, but it still reads pyvenv.cfg and depends on the base install in the same way.

Why did Python's designers do it this way?

On purpose. Venvs are meant to be cheap, fast and disposable, not self-contained deployments.

✅ Pros

Pro Why it matters
Tiny ~12 MB (mostly pip) instead of ~40–100 MB per env. Having 30 projects doesn't mean 30 copies of CPython.
Fast to create Creating a few symlinks takes milliseconds. Great for CI, tox and nox matrices.
Security patches for free When apt upgrade ships a patched python3.12 (say, an ssl or zipfile CVE fix), every venv picks it up with no rebuild.
One interpreter to trust Only one binary sits on disk to audit, scan and patch.
Package isolation still works Two projects can pin different versions of requests without conflict, which is the reason venvs exist.

❌ Cons

Con What it looks like
Fragile to base changes Base Python removed or upgraded → No such file or directory / bad interpreter.
Silent minor-version drift Venv made with python3 -m venv links to /usr/bin/python3, not python3.12. If the distro repoints python3 to 3.13, your venv starts running 3.13 and looks for lib/python3.13/site-packages, which doesn't exist. Result: ModuleNotFoundError for everything you installed.
Not relocatable Absolute paths live in shebangs, pyvenv.cfg and activate. You can't mv, cp -r, rsync to another path, or bake it in one Docker stage and copy it to another path.
Not portable You can't copy .venv to another machine unless the exact same Python exists at the exact same path.
Compiled wheels are tied to the base C extensions (numpy, psycopg, cryptography) were built against a specific ABI (cp312). Swapping the base breaks them.
--copies is only half a fix It copies the binary but still borrows the stdlib, and falls back silently if home disappears (see Experiment 7).

How it bites in real life

                   ┌──────────────────────────────┐
                   │   Your venv  (.venv/)        │
                   │   python3 → /usr/bin/python3 │
                   └───────────────┬──────────────┘
                                   │ depends on
                                   ▼
   ┌───────────────────────────────────────────────────────────────┐
   │            Base interpreter you DON'T control                 │
   └───────────────────────────────────────────────────────────────┘
      ▲               ▲                 ▲                   ▲
      │               │                 │                   │
 do-release-upgrade   brew upgrade   pyenv uninstall    Docker: copy venv
 22.04 → 24.04        python@3.12    3.11.4             into distroless /
 (3.10 → 3.12)        + brew cleanup                    alpine image
      │               │                 │                   │
      ▼               ▼                 ▼                   ▼
 ModuleNotFound   dangling link     dangling link      "no such file"
 (drift)          → venv dead       → venv dead        at container start
Enter fullscreen mode Exit fullscreen mode

Some scenarios you may have hit already:

  1. Ubuntu release upgrade. 22.04 ships Python 3.10 and 24.04 ships 3.12. After do-release-upgrade, venvs built with python3 -m venv start running 3.12 and all your packages are gone. Venvs built with python3.10 -m venv are dead links.
  2. macOS + Homebrew. brew upgrade installs a new patch version, and brew cleanup deletes the old Cellar directory your venv pointed to. This is the classic "my venv broke overnight".
  3. Cron jobs / systemd services on a VM. You set ExecStart=/opt/app/.venv/bin/python ... in a unit file. Months later, unattended upgrades or an OS image refresh change the base Python, and the service dies at 3 a.m.
  4. Docker multi-stage builds. You build /opt/venv in python:3.12 and copy it into gcr.io/distroless/base. /usr/local/bin/python3 doesn't exist there, so the venv's symlink points nowhere.
  5. Renaming a project folder. mv my-app my-app-v2 breaks pip, pytest and every other console script.
  6. CI caching. You cache .venv between runs. The runner image updates its Python, the cache restores a venv linked to a Python that's gone, and the build fails in confusing ways.

Part 3 — Building a venv that doesn't betray you

The root problem isn't the symlink itself. It's that the venv depends on an interpreter you don't control. So the fix is to control the base interpreter, and to treat venvs as disposable.

                    Which venv strategy do I need?
                    ──────────────────────────────

                  ┌──────────────────────────────┐
                  │ Is this for local dev / CI ? │
                  └───────┬───────────────┬──────┘
                       yes│               │no (deploying / shipping)
                          ▼               ▼
         ┌──────────────────────┐   ┌───────────────────────────────┐
         │ Rule 1 + Rule 2      │   │ Must it run where Python may   │
         │ pinned base + lock   │   │ be missing/different?          │
         │ file, recreate often │   └──────┬─────────────────┬──────┘
         └──────────────────────┘       yes│                 │no
                                           ▼                 ▼
                        ┌──────────────────────────┐  ┌─────────────────────┐
                        │ Rule 4: ship the         │  │ Rule 3: Docker with │
                        │ interpreter too          │  │ venv at the SAME    │
                        │ (standalone Python /     │  │ path, SAME base img │
                        │  container / PyInstaller)│  └─────────────────────┘
                        └──────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Rule 1 — Never build venvs on the distro's python3

The system Python exists for the OS (apt, cloud-init, gcloud/aws tooling), and the OS will upgrade it whenever it needs to. Install your own interpreter at a pinned, versioned path that only changes when you change it.

Option A: uv (fast and simple)

curl -LsSf https://astral.sh/uv/install.sh | sh
uv python install 3.12            # downloads a standalone CPython build
uv venv --python 3.12 .venv       # venv linked to *uv's* Python, not /usr/bin
uv pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Option B: pyenv (compiles from source)

curl -fsSL https://pyenv.run | bash      # then follow the shell setup it prints
pyenv install 3.12.7
~/.pyenv/versions/3.12.7/bin/python -m venv .venv
Enter fullscreen mode Exit fullscreen mode

Either way, the venv is still a symlink, but now it points at an interpreter that belongs to you:

   BEFORE (fragile)                         AFTER (stable)
   ────────────────                         ──────────────
   .venv/bin/python3                        .venv/bin/python3
        │                                        │
        ▼                                        ▼
   /usr/bin/python3  ◄── apt owns this      ~/.pyenv/versions/3.12.7/bin/python3.12
        │                (changes whenever       ◄── YOU own this
        ▼                 the OS wants)              (changes only when you
   /usr/bin/python3.12                                pyenv uninstall it)
Enter fullscreen mode Exit fullscreen mode

Option C: No extra tools? At least pin the minor version

python3.12 -m venv .venv      # ✅ links to python3.12 — no silent 3.12→3.13 drift
python3    -m venv .venv      # ❌ links to python3   — drifts when the distro repoints it
Enter fullscreen mode Exit fullscreen mode

Check it:

ls -l .venv/bin/ | grep python
# python3.12 -> /usr/bin/python3.12   ✅ pinned
Enter fullscreen mode Exit fullscreen mode

Rule 2 — Treat the venv as a cache, not a pet

Assume any venv can die, and make rebuilding it a one-liner:

# Lock exact versions
pip freeze > requirements.lock          # or: uv pip compile / poetry lock / pip-tools

# Rebuild from scratch any time
rm -rf .venv
python3.12 -m venv .venv
.venv/bin/pip install -r requirements.lock
Enter fullscreen mode Exit fullscreen mode

Also:

  • Never commit .venv to git (add it to .gitignore).
  • Never mv a venv. Recreate it at the new path instead.
  • If you upgraded the base Python in place (same minor version), refresh the links with:
  python3.12 -m venv --upgrade .venv
Enter fullscreen mode Exit fullscreen mode

Rule 3 — In Docker, keep the venv's world identical across stages

Multi-stage builds work fine as long as the final image has the same interpreter at the same path:

# ---------- build stage ----------
FROM python:3.12-slim AS build
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock

# ---------- runtime stage ----------
FROM python:3.12-slim                 # ✅ SAME base → /usr/local/bin/python3.12 exists
COPY --from=build /opt/venv /opt/venv # ✅ SAME path → shebangs still valid
ENV PATH="/opt/venv/bin:$PATH"
COPY . /app
WORKDIR /app
CMD ["python", "main.py"]
Enter fullscreen mode Exit fullscreen mode
   build stage (python:3.12-slim)          runtime stage (python:3.12-slim)
   ┌──────────────────────────────┐        ┌──────────────────────────────┐
   │ /usr/local/bin/python3.12 ◄─┐│        │ /usr/local/bin/python3.12 ◄─┐│
   │ /opt/venv/bin/python ───────┘│ COPY ► │ /opt/venv/bin/python ───────┘│
   │ /opt/venv/lib/.../site-pkgs  │        │ /opt/venv/lib/.../site-pkgs  │
   └──────────────────────────────┘        └──────────────────────────────┘
                 ✅ link target exists on both sides
Enter fullscreen mode Exit fullscreen mode

❌ Don't copy /opt/venv into alpine (musl vs glibc), distroless (no Python at that path), or python:3.13-slim (wrong minor version).

Rule 4 — If Python might be missing at the destination, ship the interpreter

When the target machine may not have a matching Python at all (air-gapped servers, customer machines, minimal images), a venv is the wrong tool. Ship the interpreter along with it:

Approach What you get
Container image Interpreter, stdlib and venv frozen together. The most common answer.
python-build-standalone A relocatable CPython tarball. Unpack it in /opt/myapp/python, build the venv from that Python, and ship the whole /opt/myapp directory.
conda-pack Packs a full conda env (interpreter included) into a tarball you can relocate.
PyInstaller / Nuitka / shiv Bundles your app and interpreter into a single executable or zipapp.

What about --copies?

python3.12 -m venv --copies .venv
Enter fullscreen mode Exit fullscreen mode
   --symlinks (default on Linux/macOS)      --copies
   ───────────────────────────────────      ─────────────────────────────────
   bin/python3.12 → /usr/bin/python3.12     bin/python3.12   (real 7 MB copy)
   stdlib: borrowed from /usr/lib           stdlib: STILL borrowed from /usr/lib
   base removed → hard fail 💥              base removed → silent fallback 😶
   base patched → auto-upgrade ✅           base patched → stale binary +
                                                           new stdlib ⚠️ mismatch
Enter fullscreen mode Exit fullscreen mode

--copies is useful when your filesystem can't do symlinks (some network mounts, Vagrant/VirtualBox shared folders, certain Windows setups). It is not a way to make a venv self-contained, because the stdlib still lives in the base install. In some ways it's worse: a symlinked venv fails loudly, while a copied one can keep running with a mismatched stdlib.

Bonus: a venv health check

Drop this in your dotfiles or CI to spot broken or drifted venvs before they cause trouble:

#!/usr/bin/env bash
# venv-doctor.sh — usage: ./venv-doctor.sh path/to/.venv [more venvs...]
for v in "$@"; do
  py="$v/bin/python"
  if [[ ! -e "$py" ]]; then
    echo "💀 $v: interpreter link is dangling → $(readlink -m "$py")"
    continue
  fi
  "$py" - "$v" <<'EOF'
import sys, os, re
venv = sys.argv[1]
cfg = open(os.path.join(venv, "pyvenv.cfg")).read()
home = re.search(r"^home\s*=\s*(.+)$", cfg, re.M).group(1).strip()
want = re.search(r"^version(?:_info)?\s*=\s*(\d+\.\d+)", cfg, re.M).group(1)
have = f"{sys.version_info.major}.{sys.version_info.minor}"
problems = []
if want != have:
    problems.append(f"version drift: created with {want}, now running {have}")
if not os.path.isdir(home):
    problems.append(f"home '{home}' no longer exists (stdlib fallback: {sys.base_prefix})")
print(("⚠️  " if problems else "✅ ") + venv + (": " + "; ".join(problems) if problems else f": OK (Python {have} → {os.path.realpath(sys.executable)})"))
EOF
done
Enter fullscreen mode Exit fullscreen mode
$ ./venv-doctor.sh ~/proj-a/.venv ~/proj-b/.venv ~/old-thing/.venv
✅ /home/ubuntu/proj-a/.venv: OK (Python 3.12 → /home/ubuntu/.pyenv/versions/3.12.7/bin/python3.12)
⚠️  /home/ubuntu/proj-b/.venv: version drift: created with 3.10, now running 3.12
💀 /home/ubuntu/old-thing/.venv: interpreter link is dangling → /opt/fakepy/bin/python3.12
Enter fullscreen mode Exit fullscreen mode

Cheat sheet

Question Command
Is my venv's Python a symlink? ls -l .venv/bin/python*
What does it really run? readlink -f .venv/bin/python
Where does it think home is? cat .venv/pyvenv.cfg
Venv vs base prefix .venv/bin/python -c 'import sys;print(sys.prefix, sys.base_prefix)'
Where does the stdlib come from? .venv/bin/python -c 'import os;print(os.__file__)'
Create a pinned venv python3.12 -m venv .venv
Create a venv on a Python you own uv venv --python 3.12 / ~/.pyenv/versions/3.12.7/bin/python -m venv .venv
Refresh links after base patch upgrade python3.12 -m venv --upgrade .venv
Rebuild from scratch rm -rf .venv && python3.12 -m venv .venv && .venv/bin/pip install -r requirements.lock

Wrapping up

   A venv is a lightweight overlay:

        ┌───────────────────────────────┐
        │  YOUR packages (site-packages)│  ◄── isolated, yours
        ├───────────────────────────────┤
        │  stdlib                       │  ◄── borrowed
        │  interpreter binary           │  ◄── symlinked
        └───────────────────────────────┘
                 base Python install
Enter fullscreen mode Exit fullscreen mode
  • The symlink design makes venvs cheap, fast and auto-patched. That was a deliberate choice.
  • It also means a venv is only as stable as the interpreter it points to.
  • So: own your base interpreter (uv / pyenv / a pinned python3.X), lock your dependencies, treat venvs as disposable, and when you need portability, ship the interpreter, not just the venv.

Next time a venv "randomly" breaks after an upgrade, run ls -l .venv/bin/python first. There's a good chance the link is pointing at a Python that no longer exists.


Found this useful? Drop a ❤️ or tell me in the comments about your worst "my venv broke overnight" story.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more