Virtual Environments & uv
Theme
Palette
Download
Caption (tap to copy)
📖 Deep dive (full written explanation)
This cover sets the tone for the mechanics post by puncturing the word 'activate.' It sounds like it spins up some heavy machinery, but the reality is almost anticlimactic: activation rearranges a single environment variable so a different 'python' wins.
The post's promise is that once you see the few simple mechanisms — a PATH trick, a search-path adjustment, a resolver, and a cache — the commands stop feeling like incantations and start feeling like obvious consequences of how the pieces fit.
The first mechanism is PATH resolution. When you type a command, your shell walks the PATH environment variable from left to right and runs the first matching executable it finds. PATH is just an ordered list of directories.
Activating an environment prepends its Scripts/ directory (on Windows) or bin/ directory (on Unix) to the front of PATH. Now the environment's python and pip are found before the system ones. Deactivating simply removes that prefix, restoring the previous order. That's the entire mechanism behind activation — no processes, no daemons, just a modified search list.
This flow diagram traces what happens end to end when you type 'python' in an activated shell. The shell searches PATH, finds .venv/Scripts at the front because activation put it there, runs the environment's interpreter rather than the system one, and that interpreter then uses the environment's own site-packages.
Laying it out as a flow makes the causal chain explicit: the PATH edit is the cause, and 'imports resolve to this env's packages' is the eventual effect, with each step following mechanically from the one before.
The second mechanism is how the interpreter finds packages. Inside the environment is a pyvenv.cfg file that records the path to the base Python this environment was built from. When the env's interpreter starts, it configures sys.path so that the environment's own site-packages directory takes precedence.
The consequence is that 'import requests' loads the copy installed in this environment and ignores any version the system might have. Crucially, the interpreter binary itself is effectively shared with the base Python; only the package search path is isolated. This is the technical reason environments are lightweight overlays rather than full copies.
The third mechanism is dependency resolution, the genuinely hard part. When you request 'flask' and 'pandas', each brings its own dependencies, and those can demand conflicting versions of some shared library. A resolver's job is to find a single assignment of versions that satisfies every constraint simultaneously — or to prove that none exists.
This is a combinatorial search problem, and it is where uv's Rust implementation shines. The same resolution that pip might labor over for many seconds, uv often completes almost instantly, which is what makes its locking and syncing feel free.
This cycle diagram shows the resolve-lock-sync loop that underpins reproducibility. uv reads your constraints from pyproject.toml, resolves them to one compatible version per package, writes those exact pins to uv.lock, and then syncs the environment by installing exactly what the lock specifies.
Framing it as a cycle highlights that these are distinct phases you can trigger separately. 'uv lock' does the resolve-and-write part; 'uv sync' does the install-from-lock part. Most commands like 'uv add' quietly run the whole loop for you.
This code slide lets you verify the mechanics from inside Python itself. 'sys.executable' prints the absolute path of the interpreter currently running — in an activated environment it points into .venv/Scripts. 'sys.path' is the list of directories searched for imports, and its tail includes the env's site-packages.
These two attributes are your ground truth. When something behaves unexpectedly, printing sys.executable tells you immediately whether you're even in the environment you think you are, which resolves a surprising share of 'mysterious' bugs.
The fourth mechanism is uv's global cache, the secret behind its speed. uv maintains one machine-wide cache of downloaded and built wheels. The first time a particular package version is needed, uv fetches and caches it; every subsequent install into any environment is a hard link or fast copy from that cache rather than a fresh download.
Combine the cache with parallel installation and the fast Rust resolver and you get the headline behavior: a complete environment materializing in milliseconds. The cache also saves bandwidth and disk, since many environments share the same underlying cached files.
This stack diagram visualizes the cache relationship. At the base sits the single uv global cache, where each wheel is downloaded once. Above it, projects A, B, and C each have their own .venv, but rather than holding independent copies of shared packages, they link back to the cached files.
This is why having many environments on one machine costs far less disk than you'd expect. Isolation is logical, not physical duplication — the environments are isolated in what they expose to your code, while sharing storage underneath.
This code slide highlights 'uv run', which removes the most error-prone manual step: activation. 'uv run' locates the project's .venv, ensures it's synced with the lockfile, and then executes your command inside it — all in one shot. 'uv run python app.py' and 'uv run pytest' just work, with no 'source activate' beforehand.
This matters because forgetting to activate, or activating the wrong environment, is one of the most common sources of confusion. By making the environment implicit in the command, uv run sidesteps the whole category of mistake.
This mistake slide names the single most common practical failure: not knowing which python is actually running. A stale activation from an old terminal, a brand-new terminal that was never activated, or an editor configured with its own interpreter all lead to running the wrong python without any obvious sign.
The diagnostic discipline is simple and worth making reflexive: when a command misbehaves or a module appears 'missing,' check sys.executable or run 'which python' (or 'where python' on Windows) before anything else. The majority of 'No module named X' errors are really 'you're in the wrong environment' errors in disguise.
This closes the mechanics post and hands off to the hands-on one. With the machine understood — PATH, sys.path, the resolver, the cache, and uv run — the next post walks a complete real project end to end: init, add, lock, sync, and run, with every command shown.