A Python CLI that works on your machine and nowhere else almost always has the same root cause: it leaked into the system interpreter. Isolation is the discipline that keeps each tool's dependencies in their own sandbox, so an upgrade for one project never silently breaks another — and so the build that passes in CI is the build your users run. This overview explains why isolation matters specifically for CLIs and routes you to the cross-platform details.
The golden rule
Never install packages into the system Python. On macOS and most Linux distros the
system interpreter is owned by the OS package manager; pip install into it (or worse,
sudo pip install) can corrupt the tools your OS depends on. Newer Python builds even
refuse with an externally-managed-environment error (PEP 668) — that error is a
feature, not an obstacle. Every dependency belongs in a virtual environment.
Three ways to isolate (and when each fits)
The Python ecosystem gives you overlapping tools. They are not competitors so much as layers of the same problem.
venv(standard library) creates a per-project environment from whatever interpreter invoked it. It is universal, zero-install, and the right default for understanding what is happening. Its weakness is that it inherits the Python version you happened to runpython -m venvwith.- uv-managed environments wrap
venvwith a fast resolver, a lockfile, and — viarequires-pythoninpyproject.toml— the ability to download and pin the interpreter itself.uv syncrecreates an identical environment from the lockfile, which is what makes builds reproducible. See uv for Python CLI dependency management for the full workflow. - pyenv manages multiple Python versions on one machine. It does not isolate dependencies — you still create a venv on top of it — but it is how you guarantee that "Python 3.12" means the same patch release on your laptop and in CI.
For end-user installation, the picture flips. Your users should not create a venv to run your tool. Distribute it so each install is isolated automatically:
pipxinstalls a CLI into its own dedicated venv and puts only the entry-point scripts on the user'sPATH. One tool per environment, no cross-contamination.uv tool installdoes the same thing, faster, and can manage the interpreter too.
Both give end users the isolation guarantee without asking them to know what a venv is.
Reproducibility in CI
Isolation is what makes CI trustworthy. The pattern is the same on every provider: create a fresh environment, install from a lockfile, never reuse a mutated global state. With uv that is two commands the runner can cache deterministically:
# Reproducible CI environment — fresh and lockfile-driven.
uv python install 3.12 # pin the interpreter version
uv sync --frozen # install exactly what the lockfile specifies
uv run pytest # run inside the isolated env, no activation needed
Run a matrix across the Python versions you support, and pin them explicitly so a runner image upgrade can't quietly change your interpreter underneath you.
Activation-free execution
Activation (source .venv/bin/activate) is convenient at a terminal but fragile in
scripts, Makefiles, and CI — it mutates shell state that doesn't survive a subprocess.
Prefer calling the environment's interpreter directly (/.venv/bin/python -m yourtool)
or uv run, both of which work without touching the shell. This matters most when the
same command has to run across Linux, macOS, and Windows, where activation scripts and
directory layouts diverge.
Go deeper: cross-platform mechanics
The trade-offs above are platform-agnostic, but the mechanics of activation, PATH
resolution, and interpreter discovery differ sharply between operating systems — bin/
versus Scripts/, four different activation scripts, shebangs that only exist on POSIX.
- Managing Python CLI virtual environments
— venv layout differences, activation across bash/zsh/fish/PowerShell,
PATHresolution, interpreter discovery, shebang versuspython -m, and using uv and pyenv for consistent interpreters on Linux, macOS, and Windows. Includes a portable Python snippet that introspects the running environment.
Creating and using a project environment
The mechanics are short enough to memorise, and worth memorising because every other workflow is a variation on them.
python -m venv .venv # the stdlib way, always available
.venv/bin/python -m pip install -e . # editable install of this project
.venv/bin/mytool --help # the console script, inside the environment
Three details in those three lines matter. The environment lives inside the project as
.venv, so it is obvious which project it belongs to and trivial to delete when something has
gone strange. The install is editable, so the console script points at your working tree and
edits take effect without re-installing. And every command is invoked by path, with no
activation step — which is what makes the same commands work in a script, in CI and on Windows.
With a dependency manager the same thing is one command:
uv sync # creates .venv if needed, installs exactly what uv.lock pins
uv run mytool # runs inside it without activating anything
poetry install # creates the environment and installs the project
poetry run mytool
Activation is a convenience for interactive work, not a requirement. It prepends the
environment's bin (or Scripts) directory to PATH for the current shell and sets
VIRTUAL_ENV; nothing else. Anything that depends on having been activated is a script that will
eventually break in CI, in a cron job, or on a colleague's machine.
Three kinds of isolation, and which you need
The word "environment" covers three different jobs, and conflating them is where most confusion starts.
A project environment isolates the dependencies of this codebase. It exists so that two
projects can want incompatible versions of the same library, and so that a clean checkout can be
reproduced from a lockfile. This is .venv next to your pyproject.toml.
A tool environment isolates an installed application. When a user installs your CLI with
pipx install mytool or uv tool install mytool, it gets a private environment and only its
command is exposed on PATH. That is what stops your tool's pinned dependencies fighting with
another tool's.
An interpreter version is a separate axis again. requires-python in your metadata declares
what you support; uv python install 3.12 or pyenv install 3.12 provides it. A virtual
environment is built from an interpreter — it does not supply one.
Most projects need all three: a project environment while developing, a documented tool-install route for users, and a declared interpreter range that CI actually tests against.
uv python install 3.11 3.12 # both interpreters available
uv venv --python 3.11 # build this project's environment from the older one
Reproducibility beyond the lockfile
A lockfile pins packages. Two other things can still differ between machines, and both bite in practice.
The interpreter. A wheel resolved for 3.12 may not be the wheel you get on 3.11, and a
dependency can behave differently across minor versions. Declare the range in requires-python,
and test the boundaries — the oldest version you claim and the newest you expect — in CI. Those
are the two that break; the versions between them rarely surface anything distinct.
The platform. Path separators, the bin versus Scripts split, case-insensitive filesystems,
and console encoding all differ on Windows. A tool that only ever runs on Linux CI will meet
these for the first time in a user's bug report. One Windows job in the matrix is usually enough
to catch them.
In CI, the sequence that removes the remaining ambiguity is short:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: uv.lock # a stale cache becomes impossible, not just unlikely
- run: uv sync --frozen # fail if the lockfile is out of date
- run: uv run pytest
The --frozen flag is the important one. Without it, a job that finds a stale lockfile quietly
re-resolves, goes green, and hands the drift to a user. With it, the job fails with a message
naming the file, and the fix is a one-line commit.
Environments in the tool you ship
Your own CLI often needs to know about environments too — to run a subprocess, to report diagnostics, or to install something on the user's behalf.
import sys, subprocess
def run_module(module: str, *args: str) -> int:
# sys.executable is the interpreter running *this* code, whatever environment that is
return subprocess.call([sys.executable, "-m", module, *args])
Spawning a bare python hopes PATH agrees with you, and inside a pipx-installed tool, a frozen
binary, or a CI container with three interpreters, it will not. sys.executable is exact.
The same information makes a useful doctor command, which turns a class of support question
into a copy-paste:
@app.command()
def doctor() -> None:
"""Print the environment this tool is running in."""
typer.echo(f"mytool {version('mytool')}")
typer.echo(f"python {sys.version.split()[0]} ({sys.executable})")
typer.echo(f"prefix {sys.prefix}")
typer.echo(f"in venv {sys.prefix != sys.base_prefix}")
Diagnosing an environment that has gone wrong
Almost every "it works for me" report resolves to one of five states, and each has a quick check.
The wrong interpreter is running. which mytool and python -c "import sys; print(sys.prefix)"
disagree, usually because a shell has an old activation lingering or PATH picked up a system
install first. The fix is to invoke by path once to confirm, then fix the shell rather than the
project.
The project is not installed at all. mytool is on PATH from a global install while the
working tree is being edited, so changes appear to have no effect. pip show -f mytool tells you
where the installed copy actually lives; an editable install replaces it with a pointer at your
source.
Two environments, one project. A .venv created by python -m venv sits next to a Poetry
environment in the cache, and commands run in whichever the last tool chose. Pick one, delete the
other, and let the dependency manager own it from then on.
A stale lockfile. Tests pass locally and fail in CI, or vice versa. uv sync --frozen or
poetry check --lock will say so in one line rather than leaving you comparing version numbers
by hand.
Cached bytecode or a partially removed package. Rare, but it happens after an interrupted
install. Deleting .venv and re-syncing costs seconds with a warm cache and eliminates the
possibility entirely, which is why an in-project environment is easier to live with than one
hidden in a cache directory.
The general principle is that a virtual environment should be disposable. If recreating it is frightening, something outside the lockfile is holding state, and that is the real bug.
A checklist for a new project
Six lines, once, and the environment questions stop coming up:
- Create the environment inside the project (
.venv), never globally. - Install the project itself editable, so the console script points at your source.
- Commit the lockfile; ignore the environment.
- Declare
requires-python, and test the ends of that range in CI. - Invoke by path or through
uv run/poetry run— never depend on activation. - Document
pipx installoruv tool installas the way users install the finished tool.
The last one is the most commonly skipped, and it is the one that decides how your tool behaves
on other people's machines. A README that says pip install mytool is telling users to put your
dependencies into whatever environment happens to be active, which is the arrangement that
produces conflicts you will hear about and cannot reproduce.
Frequently asked questions
Should .venv be committed?
Never. It contains absolute paths baked into scripts and platform-specific binaries, so it is
neither portable nor meaningful in a diff. Commit the lockfile instead — that is the artifact
that lets anyone rebuild an identical environment — and add .venv/ to .gitignore.
Do I still need pyenv if I use uv?
Usually not. uv can download and manage interpreters itself, so uv python install plus a
requires-python floor covers what most projects used pyenv for. If your team already
standardises on pyenv there is no need to remove it — uv will happily use the interpreter it
finds — but a fresh project does not need both.
Why does my console script stop working after I move the project?
Because the generated shim on Unix carries the absolute path of its interpreter in the shebang, and Windows uses a launcher pointing at the same fixed location. Moving or renaming a virtual environment invalidates that. Do not try to patch the shims: delete the environment and recreate it from the lockfile, which takes seconds with a warm cache.
Is pip install --user a reasonable alternative?
It avoids one problem and creates another: your tool no longer pollutes the system interpreter,
but every --user install still shares one environment, so two tools with conflicting pins still
collide. Isolated installs with pipx or uv tool give each application its own environment,
which is the property that actually prevents the conflict.
How do I run a tool once without installing it?
uvx mytool or pipx run mytool resolves the package into a cached temporary environment, runs
it, and leaves nothing on PATH. It is ideal for one-off utilities and CI steps where a permanent
install would be noise, and it is a good line to put in your README for people evaluating the
tool before committing to it.
Should CI cache the environment or rebuild it every time?
Cache it, keyed on a hash of the lockfile. That gives you fast runs when nothing changed and a guaranteed-correct rebuild the moment a dependency moves. Caching without keying on the lockfile is the arrangement that produces a green build on dependencies nobody declared.
Can a virtual environment be relocated or copied to another machine?
No, and it is worth understanding why rather than working around it. The scripts inside carry the
absolute path of the interpreter that created them, pyvenv.cfg points at a specific base
installation, and any compiled dependency was built for that platform. Copying one to a colleague
produces something that fails in confusing ways rather than failing cleanly. The lockfile is the
portable artifact; the environment is a local build product derived from it.
What is the difference between sys.prefix and sys.base_prefix?
sys.prefix is the environment your code is running in; sys.base_prefix is the interpreter
installation that environment was built from. When they differ, you are inside a virtual
environment — which is exactly the check a doctor command should print, because a user who is
surprised by which packages are visible is nearly always outside the environment they think they
are in.
Does a container remove the need for a virtual environment?
Not entirely, though it changes the calculus. Inside a container the system interpreter is already isolated from everything else on the host, so a second layer buys less. What it still buys is a clear boundary between your application's dependencies and anything the base image installed, plus the same commands working on a developer laptop and in the image. Many teams keep the environment for that consistency and treat the container as isolation of the operating system layer rather than of the packages.
Related
- Project Setup & Dependency Management — the full track this topic belongs to.
- uv for Python CLI dependency management — lockfiles, interpreter pinning, and the resolver that powers reproducible isolation.
- Managing Python CLI virtual environments — the cross-platform deep dive.