You have read the parent note and you know what a virtual environment is: a self-contained folder with its own Python and its own site-packages/. This page does one thing — it walks you through every kind of situation you will actually hit, from the boring happy path to the weird degenerate cases nobody warns you about.
Before we start, one promise: every command here is explained in plain words the first time it appears. If you have never opened a terminal, you can still follow from line one.
Intuition What "every scenario" means here
A virtual environment is really just a set of paths — a list of folders Python looks inside to find code. Almost every problem you will ever have is one of a small number of path stories : the right folder is first (good), the wrong folder is first (bug), or a folder is empty / missing (crash). If we enumerate those path stories, we have covered everything.
Definition Symbols and words we use below
Two math shorthands appear when we compare version ranges — here is what they mean in plain words, so nothing is used before it is defined:
∞ ("infinity") means "no upper limit" — the range keeps going up forever. So [ 1.20 , ∞ ) means "any version 1.20 or higher, with no ceiling."
∅ ("the empty set") means "nothing at all fits" — a range with zero values inside it. If two ranges share no common value, their overlap is ∅ .
One tooling word too:
A lockfile is a file listing the exact version of every package (e.g. numpy==1.24.0). In pip's world the file requirements.txt produced by pip freeze is your lockfile — "lock" because it pins versions so they cannot drift.
Think of each row as a distinct case class . The worked examples below are labelled with the cell they cover, and together they touch every row.
#
Case class
The core question
Degenerate / edge version
C1
Happy path
Create → activate → install → freeze
Empty requirements.txt (zero packages)
C2
Isolation proof
Is python really the env's python?
Two envs pointing at same Python version
C3
Version conflict
Two packages want incompatible numpy
Constraint that cannot be satisfied at all
C4
The leak bug
A package "works" but isn't in the env
pip freeze silently missing a package
C5
Reproduce elsewhere
Recreate exact env on new machine
Wrong Python version → install fails
C6
pip vs conda choice
Which tool for a non-Python dependency?
Package exists in neither (must build)
C7
Word problem
Real team, real deadline
Teammate on Windows (different activate)
C8
Exam twist
Predict sys.path ordering
Global + venv both have the package
The single idea that unlocks all of them:
sys.path — Python's search order
sys.path is an ordered list of folders . When you write import numpy, Python walks this list top to bottom and stops at the first folder that contains numpy. "Activating" an environment simply puts the env's folder at the top of that list (by changing where the python command points). That is the whole magic — see the figure.
Figure s01 — reading it. This is a flow diagram , not a graph with numeric axes; the boxes are folders and commands, the arrows show which folder Python reaches first . On the left, the butter box is the python command. The lavender arrow (labelled "activated: look here 1st") goes up to the env's site-packages box, which contains requests, then out to the mint "FOUND" box — so when activated, the env folder is searched first and wins. The coral arrow (labelled "not activated: skip to global") goes down , past the env, straight to the pale global site-packages box and the "fallback" box. The vertical position encodes search order: higher = searched earlier . Every bug below is one of these two arrows pointing the "wrong" way.
Definition One note on activation across operating systems
Every source env/bin/activate line below is the macOS/Linux form. On Windows the activation script lives in a different folder and is run without source:
PowerShell: env\Scripts\Activate.ps1
Command Prompt (cmd): env\Scripts\activate.bat
Why the difference? On UNIX, source runs a script inside your current shell so it can edit that shell's PATH; Windows shells have their own activation scripts under Scripts\ that do the same job. Everything after activation (pip install, pip freeze, python -c ...) is identical on all three. We say "activate the env" from here on to mean "run the right one for your OS."
Worked example Create, activate, install, freeze — from nothing
Statement. You have a brand-new folder and Python 3.10 installed. Build an isolated environment, install requests==2.31.0, and export a lockfile.
Forecast. Guess first: after pip freeze, how many lines will the file have — exactly one (requests) or more? Write down your guess.
Step 1. python3 -m venv env
Why this step? -m venv runs Python's built-in environment maker. It creates env/bin/python (a link to your real Python) and an empty env/lib/python3.10/site-packages/. Empty is the key word — the sandbox starts clean.
Step 2. source env/bin/activate (macOS/Linux) — or the Windows form from the box above.
Why this step? This edits this terminal's PATH so the word python now finds env/bin/python first. Your prompt gains an (env) badge so you can see it worked.
Step 3. pip install requests==2.31.0
Why this step? requests does not stand alone — it depends on urllib3, certifi, charset-normalizer, idna. pip downloads those dependencies first (topological order) then requests itself.
Step 4. pip freeze > requirements.txt
Why this step? freeze prints every installed package with its exact version. Redirecting > writes them to a file. This file is your lockfile (see the definition above) — the reproducibility receipt.
Verify. The lockfile will contain 5 lines , not 1: requests plus its 4 dependencies. If you guessed 1 , this is the lesson — freeze captures the whole dependency tree, not just what you typed.
Empty edge case (C1-degenerate). If you run pip freeze immediately after Step 2 with nothing installed, the file has 0 lines . A zero-package environment is still a perfectly valid environment — it just isolates nothing yet . This is the "degenerate input": the sandbox exists but is empty, exactly like a fresh notebook.
Worked example Is this really the env's Python?
Statement. You activated env. Prove — don't trust — that python is the sandbox's interpreter and not the system one.
Forecast. Which path will sys.executable print: something with /env/ in it, or /usr/bin/python3?
Step 1. python -c "import sys; print(sys.executable)"
Why this step? sys.executable is the full path to the running interpreter . It cannot lie — it reports the actual binary in use. If it ends in /env/bin/python, activation succeeded; if it shows /usr/bin/python3, you never activated this terminal (each terminal must be activated separately).
Step 2. Read the output. Activated correctly, it prints something ending in /env/bin/python.
Why this step? This is the geometric fact from the figure: the lavender arrow reached the env folder first.
Step 3. python -c "import sys; print(sys.path[0:3])"
Why this step? We check the front of the search list. Concretely, at least one of the first entries must contain the substring env/lib (the env's own site-packages). If you see only /usr/lib/... paths in that slice, the env is not in front and isolation is broken.
Verify. Precedence check, not just non-emptiness: find the position of the env's site-packages and the global one in sys.path, and confirm the env comes first . As logic: with path = ["", ".../env/lib/.../site-packages", "/usr/lib/python3.10/site-packages"], the index of the env folder (1) must be less than the index of the global folder (2) — env wins. If the env index were larger, activation failed.
Same-Python edge case (C2-degenerate). Two different envs can both be built on the same Python 3.10. That is fine — isolation is about which site-packages folder is first , not about the interpreter version. Build both, activate env_A, and sys.path[1] contains env_A/lib; activate env_B in another terminal, and sys.path[1] contains env_B/lib. Identical Python version, different first folder → different packages. The interpreter being shared does not leak packages between them.
Connects to Python basics — sys is a standard-library module you import like any other.
Worked example Two packages, incompatible numpy
Statement. pkg_A requires numpy>=1.20. pkg_B requires numpy<1.20. You need both.
Forecast. Can one single environment hold both? Yes / No — commit before reading.
Step 1. Write the constraints as a number line. pkg_A wants numpy in [ 1.20 , ∞ ) . pkg_B wants numpy in ( − ∞ , 1.20 ) .
Why this step? Conflicts are just set intersections . If the ranges overlap, a solution exists.
Step 2. Intersect: [ 1.20 , ∞ ) ∩ ( − ∞ , 1.20 ) = ∅ (empty).
Why this step? An empty intersection means no single numpy version satisfies both . One environment is mathematically impossible — this is the degenerate branch of C3.
Step 3. Make two environments, one per package.
python3 -m venv env_A && source env_A/bin/activate
pip install "pkg_A" # pulls numpy 1.24 → in [1.20, inf)
deactivate
python3 -m venv env_B && source env_B/bin/activate
pip install "pkg_B" # pulls numpy 1.19 → in (-inf, 1.20)
Why this step? Two sandboxes = two independent number lines. Each constraint lives alone and is trivially satisfiable. The deactivate command between them undoes the activation of env_A — it restores your PATH so the word python points back at the system Python, ensuring env_B is built and activated cleanly rather than nested inside env_A. (On Windows, deactivate works the same way in all shells.)
Verify. In env_A: numpy.__version__ == "1.24.0" satisfies 1.24 ≥ 1.20 ✓. In env_B: 1.19.0 satisfies 1.19 < 1.20 ✓. The "impossible in one env" claim is confirmed by the empty intersection — you cannot argue your way around set logic.
Non-degenerate contrast. If instead pkg_B wanted numpy<1.25, the intersection would be [ 1.20 , 1.25 ) — non-empty — and one environment with numpy 1.24 serves both. Always check the intersection before reaching for two environments.
Worked example pandas works, then vanishes for your colleague
Statement. You installed pandas globally long ago. Later you make a fresh env, forget to install pandas inside it, but your code still runs. What breaks, and when?
Forecast. Will pip freeze inside the new env list pandas? Yes / No?
Step 1. Trace the import. import pandas walks sys.path. The env's site-packages is first but empty of pandas, so Python keeps walking and finds pandas in the global folder.
Why this step? This is the coral-arrow-falls-through scenario in the figure: env first, miss, then global, hit.
Step 2. Run pip freeze inside the env.
Why this step? freeze only reports the env's site-packages. Since pandas lives in the global folder, freeze lists 0 pandas lines — even though your code just used it.
Step 3. Your colleague runs pip install -r requirements.txt (no pandas line) → their env has no pandas → ModuleNotFoundError.
Why this step? The "works on my machine" trap: your machine leaks from global, theirs does not.
Verify. Count-based check: (packages_in_env_sitepackages == 0) and (code_imports_pandas == True) should be True — a contradiction that smells wrong, and that smell is the bug. Fix: always pip install pandas after activating, so it lands in the env folder and freeze sees it.
Worked example Recreate the exact env on a new laptop
Statement. You exported requirements.txt (your lockfile) from Python 3.10. Your teammate has only Python 3.7. What happens?
Forecast. Will pip install -r requirements.txt succeed on 3.7?
Step 1. Suppose the lockfile pins tensorflow==2.10.0. TF 2.10 ships wheels for Python 3.7–3.10.
Why this step? A wheel is a pre-built package tagged with the Python versions it supports. pip refuses wheels that don't match your interpreter.
Step 2. On Python 3.7 the wheel tag matches → install succeeds. On Python 3.13 (say a future teammate) there is no matching wheel → pip errors "no matching distribution."
Why this step? This is the degenerate C5 case: the packages are fine, but the interpreter version is the hidden constraint.
Step 3. Fix by pinning Python too. requirements.txt cannot pin Python, but conda's environment.yml can:
conda env export > environment.yml # records python=3.10
conda env create -f environment.yml # rebuilds python 3.10 exactly
Why this step? conda manages the interpreter itself , closing the gap pip leaves open. This is exactly the pip-vs-conda trade-off from the parent note.
Verify. Compatibility check: (3, 7) <= (3, 10) <= (3, 10) for TF 2.10's supported range → True. A version outside [(3,7),(3,10)], e.g. (3,13), returns False → predicted failure. See Setting up the dev environment for pinning in CI.
Worked example NumPy with Intel MKL, plus a PyPI-only package
Statement. You want NumPy compiled against Intel's fast MKL math library, and also a small package kaggle that lives only on PyPI.
Forecast. Which package goes through conda, which through pip?
Step 1. MKL is a non-Python binary library (compiled C/Fortran). pip installs Python code, not system libraries. So MKL-optimized NumPy → conda .
Why this step? Matching tool to job: conda ships pre-compiled binaries including the C libraries; pip would need a compiler and the raw MKL installed separately.
Step 2. kaggle is pure Python and only published to PyPI → pip .
Why this step? conda's repos don't have it. pip is the backup for PyPI-only packages.
Step 3. Order matters — conda first, pip second:
conda create -n ds python= 3.9 numpy # numpy+MKL from conda
conda activate ds
pip install kaggle # PyPI-only, into same env
Why this step? Installing conda packages first lets conda solve the heavy binary dependencies; pip then fills gaps without fighting conda's solver.
Verify (degenerate branch). The rule we are testing is: "pick conda when the package needs a compiled binary, pip otherwise." We model that as a tiny function tool(needs_binary) returning "conda" or "pip". Why is this string-logic check sufficient? Because the decision has exactly two inputs mapping to two outputs — there is nothing continuous to approximate; the function is the rule stated literally. Interpreting the output: tool(True) must give "conda" (binary case, e.g. MKL) and tool(False) must give "pip" (pure-Python case, e.g. kaggle). If either returns the other string, the rule is wrong. The truly degenerate case — a package in neither conda nor PyPI — falls outside this two-way rule and forces a source build (pip install ./local_source/).
Worked example Three developers, one flaky model — and one on Windows
Statement. A team's model gives different accuracy on each laptop. Alice: numpy 1.24, Bob: numpy 1.21, Carol: numpy 1.26. No lockfile exists. Carol is on Windows. Fix the reproducibility so all three get identical numbers.
Forecast. Which single command, run once at project start, would have prevented this whole mess?
Step 1. Diagnose: three different numpy versions → floating-point routines differ slightly → different results. The root cause is no pinned environment .
Why this step? Reproducibility failures are almost always version drift; enumerate the versions and the culprit appears.
Step 2. Pick one reference machine (say Alice's, numpy 1.24), freeze it into a lockfile:
pip freeze > requirements.txt # numpy==1.24.0 recorded
Why this step? One authoritative lockfile becomes the single source of truth. requirements.txt is plain text and OS-neutral, so Windows and Linux read it identically.
Step 3. Bob and Carol rebuild. Bob (Linux) and Carol (Windows) run the same two logical steps, only the activation line differs by OS:
# Bob (Linux/macOS)
python3 -m venv env && source env/bin/activate
pip install -r requirements.txt # numpy 1.24.0
# Carol (Windows PowerShell)
python - m venv env; env\Scripts\Activate.ps1
pip install - r requirements.txt # numpy 1.24.0
Why this step? This is the Windows edge case for C7: the venv folder, pip, and the lockfile are identical across OSes; only the activate command changes . Identical inputs (same versions) → identical outputs. The number line collapses to a single point regardless of OS.
Verify. After the fix, len({alice_ver, bob_ver, carol_ver}) == 1 — the set of versions has exactly one element. Before: len({"1.24","1.21","1.26"}) == 3. The prevention command was Step 2's pip freeze, run at project start. See File I/O for how > writes that file.
Worked example Global has pandas 2.0, venv has pandas 1.5 — which imports?
Statement. The global site-packages contains pandas 2.0.0. The activated venv's site-packages contains pandas 1.5.0. You run import pandas; print(pandas.__version__). Predict the printout.
Forecast. 2.0.0 or 1.5.0? Decide before reading Step 1.
Step 1. Activation put the venv folder first in sys.path. Python walks top-to-bottom and stops at the first hit.
Why this step? This is the ordered-search rule from our definition — first match wins, later folders are never consulted for that name.
Step 2. The venv folder has pandas 1.5.0 → Python stops there → prints 1.5.0. The global 2.0.0 is shadowed (present but unreachable).
Why this step? "Shadowing" is the exam's favorite trap: a package can exist yet never be found because an earlier folder claimed the name.
Step 3. Degenerate twist: if the venv were not activated, its folder isn't at the front, so global 2.0.0 wins → prints 2.0.0.
Why this step? Flip the arrow (lavender ↔ coral in the figure) and the winner flips.
Verify. Index-of-first-match logic: given search order ["venv", "global"] and pandas present in both, the winner index is 0 → "venv" → 1.5.0. If the order were ["global", "venv"], winner index 0 → "global" → 2.0.0. See NumPy — the same shadowing rule decides which numpy your vectorized code actually uses, which affects profiling results.
Recall Quick self-test — cover the answers
Which folder does Python check first after you activate a venv? ::: The venv's site-packages, because activation puts it at the front of sys.path.
A package runs in your env but pip freeze doesn't list it. Where does it actually live? ::: In the global site-packages — it leaked in via the search fall-through (case C4).
Two packages need numpy>=1.20 and numpy<1.20. One env or two? ::: Two — the constraint intersection is empty, so no single version works (case C3).
sys.executable prints /usr/bin/python3 after you "activated". What went wrong? ::: You did not actually activate this terminal; each terminal needs its own activation command.
On Windows, what replaces source env/bin/activate? ::: env\Scripts\Activate.ps1 (PowerShell) or env\Scripts\activate.bat (cmd) — run without source.
Why use conda over pip for MKL-optimized NumPy? ::: pip only installs Python code; MKL is a compiled binary library that conda bundles pre-built (case C6).
Mnemonic "First folder wins"
Every import bug reduces to one phrase: first folder wins . Activate = put your folder first. Forget = someone else's folder wins. Leak = your folder was empty so the next folder won.