Project Setup

Python CLI Env Isolation Best Practices

Isolate Python CLI dependencies with venv, uv, and pyenv to prevent conflicts and ensure reproducible execution across development and CI environments.

Updated

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.

Isolate every project — each venv holds its own dependencies Isolate every project System Python /usr/bin/python3 — owned by the OS don't install here project-a/.venv click 8.1 rich 13.7 httpx 0.27 project-b/.venv click 7.1 typer 0.12 httpx 0.24 tool env (pipx) mytool 2.0 click 8.1 rich 13.7 each environment pins its own versions — no conflicts, no leaking into the system Python

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.

Three isolation tools, three jobs A comparison of a project virtual environment, pipx or uv tool installs, and a container, across what each isolates and when to use it. Three isolation tools, three jobs Approach Isolates Reach for it when Project .venv this project's dependencies you are developing the tool pipx / uv tool each installed CLI you are using someone's tool Container the whole OS layer system libraries matter too They compose: develop in a project environment, ship something users install with pipx.
  • 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 run python -m venv with.
  • uv-managed environments wrap venv with a fast resolver, a lockfile, and — via requires-python in pyproject.toml — the ability to download and pin the interpreter itself. uv sync recreates 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:

  • pipx installs a CLI into its own dedicated venv and puts only the entry-point scripts on the user's PATH. One tool per environment, no cross-contamination.
  • uv tool install does 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:

A reproducible CI environment A CI job checks out the repository, restores the dependency cache, installs strictly from the lockfile, and runs the test suite. A reproducible CI environment Checkout code and lockfile Restore cache keyed on the lockfile hash Install --frozen fails if the lock is stale Run tests the same versions as locally clone hit or miss ready Keying the cache on the lockfile hash is what makes a stale cache impossible rather than merely unlikely.
# 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, PATH resolution, interpreter discovery, shebang versus python -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:

  1. Create the environment inside the project (.venv), never globally.
  2. Install the project itself editable, so the console script points at your source.
  3. Commit the lockfile; ignore the environment.
  4. Declare requires-python, and test the ends of that range in CI.
  5. Invoke by path or through uv run / poetry run — never depend on activation.
  6. Document pipx install or uv tool install as 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.