A friend asks you to look at a small script. You clone the repo, run python main.py, and within two seconds you are staring at ModuleNotFoundError: No module named 'requests'. You install requests. Now it's pandas. You install pandas, and something deep in the stack complains that numpy is the wrong version. Twenty minutes later you have polluted your system Python with six packages you didn't want, and the script still doesn't run.

The script isn't broken. The environment is missing. Almost every "works on my machine" story in Python comes down to the same gap: the code was shared, but the exact set of installed packages that made it work was not. This post is about closing that gap — first with the tools that ship with Python, then with the faster modern workflow most teams have moved to.

Code is the part you can see. The environment is the part that decides whether the code runs.

Why a global install goes wrong so fast

Python installs packages into a single shared directory by default. Install requests once and every project on your machine sees the same copy — which sounds convenient until two projects disagree.

Say you have an old scraper that pinned urllib3==1.26 because a corporate proxy needed the legacy TLS behavior, and a new project that requires urllib3>=2.0. There is exactly one slot for urllib3 in a global install. Whichever project you touched last wins, and the other one breaks silently — often not at import time, but three functions deep, in production, on a Friday.

The problem compounds with the system Python itself. On most Linux distributions, parts of the OS are written in Python and depend on specific package versions. sudo pip install something incompatible and you can break apt or a system utility. That's why newer distributions actively refuse the command with an "externally-managed-environment" error. It looks like an obstacle; it's a guardrail.

The fix is to give every project its own private package directory. That's all a virtual environment is — a folder with its own site-packages and its own python executable, so what you install for one project is invisible to the rest.

The built-in way: venv plus pip

venv ships with Python. No install required, and it's the baseline every other tool builds on.

# create an environment in a folder called .venv
python3 -m venv .venv

# activate it (macOS / Linux)
source .venv/bin/activate

# activate it (Windows PowerShell)
.venv\Scripts\Activate.ps1

# now pip installs go into .venv, not your system Python
pip install requests pandas

Once activated, your prompt usually shows (.venv) and which python points inside the project folder rather than /usr/bin/python3. That single check is worth building a habit around — before you debug an import error, confirm which Python is actually running:

which python        # -> /home/you/project/.venv/bin/python
python -c "import sys; print(sys.prefix)"

Two rules make this painless. First, always name the folder .venv and put it in .gitignore; an environment is build output, not source code, and it contains platform-specific binaries that won't work on anyone else's machine anyway. Second, if activation annoys you, skip it — .venv/bin/python main.py works without activating anything, which is exactly what you want inside a cron job or a systemd unit where there is no shell to activate in.

Pinning: the part most people skip

An environment on your laptop doesn't help a teammate. What travels is the list of what's inside it. The classic approach is a requirements file:

pip freeze > requirements.txt

That produces something like:

certifi==2026.1.14
charset-normalizer==3.4.1
idna==3.10
requests==2.32.5
urllib3==2.3.0

Anyone can then reproduce it with pip install -r requirements.txt inside their own fresh .venv. This works, and for a one-file script it's often enough. But pip freeze has a real weakness: it flattens everything into one list, so six months later nobody can tell which packages you actually asked for and which came along as dependencies. Removing requests from that file doesn't remove certifi, idna, or urllib3, and the cruft accumulates.

A cleaner habit is to keep two files: requirements.in with only your direct dependencies, and a generated requirements.txt with the fully resolved, pinned result. Direct intent in one file, reproducible reality in the other.

ApproachWhat it recordsGood for
pip install onlynothingthrowaway experiments
pip freeze > requirements.txtevery installed package, flatsmall scripts, quick sharing
.in file + compiled lockintent and resolved versionsanything a second person touches
pyproject.toml + lockfileintent, resolution, and metadatalibraries, apps, teams

The modern shortcut: uv

Over the last couple of years, most of this ceremony collapsed into one tool. uv, from Astral, replaces the venv-create / activate / pip-install / freeze dance with a handful of commands, and it resolves and installs dependencies dramatically faster than pip because it's written in Rust and caches aggressively.

A typical project start looks like this:

uv init myproject
cd myproject

# add a dependency: creates .venv if needed, installs, and records it
uv add requests pandas

# run your code inside the project environment, no activation needed
uv run python main.py

You never explicitly create or activate an environment — uv manages a .venv next to your pyproject.toml and makes sure it matches your declared dependencies before running anything. Your direct dependencies land in pyproject.toml, and the full resolved graph lands in uv.lock.

That lockfile is the piece that matters for reproducibility. It's cross-platform: it records what would be installed across operating systems, architectures, and Python versions, rather than just what happened to be on the machine that generated it. Commit it. On any other machine:

uv sync     # installs exactly what uv.lock specifies

The result is that a new teammate goes from git clone to a running project in one command instead of a paragraph of README instructions. If you have an existing project, you don't need a rewrite either — uv venv and uv pip install -r requirements.txt work as fast drop-in replacements for the classic commands while you migrate.

The goal isn't to use the newest tool. It's that the answer to "how do I run this?" fits on one line.

Choosing, and the habits that actually matter

Which tool to pick depends less on features than on who else touches the code.

For a single script you'll run twice, venv plus pip is fine. It's already installed, there's nothing to learn, and the cost of a wrong choice is zero.

For anything with a second contributor, a server deployment, or a lifespan longer than a month, use a lockfile-based workflow. uv is the fastest path today; Poetry and pip-tools solve the same problem if your team already standardized on them. The specific tool matters far less than having a committed lockfile at all.

Whatever you pick, four habits carry almost all the benefit:

  • One environment per project, always named .venv, always gitignored.
  • Never sudo pip install. If a command tells you the environment is externally managed, that's the system protecting itself — make a virtual environment instead.
  • Commit the lock, not the environment. The lockfile is small, text, and reviewable in a diff. The .venv folder is hundreds of megabytes of platform-specific binaries.
  • Pin your Python version too. A .python-version file or a requires-python line in pyproject.toml prevents the subtler failure where the packages match but the interpreter doesn't.

Do those four and the "works on my machine" conversation mostly disappears. Not because the machines became identical, but because you stopped relying on them being identical.

Environment management is the least glamorous part of Python and the one that quietly eats the most hours. Ten minutes spent setting it up properly at the start of a project is time you're borrowing back from a future evening you would otherwise have spent bisecting version numbers. Set it up once, commit the lock, and go write the part you actually wanted to build.