Coming from Java you are used to a pom.xml or build.gradle that
belongs to one project and resolves its own dependencies. Python's default is the opposite:
pip install writes into one shared folder for the whole machine, and every project you have
ever touched reads from it. That works beautifully until your second project, and the failure mode is not
an error message — it is a project that used to run and now doesn't.
Below, each project pins the version of each library it was written against — perfectly ordinary, nobody has
done anything wrong. They all share a single global site-packages, so there is only one
version of each library on the machine, and the last pip install wins. Add projects and watch how
many still run:
Notice this is not a rare edge case — it is the default outcome. Two projects already have worse
than even odds. And the breakage is silent: pip install for the new project cheerfully upgrades a
library the old project depended on, prints nothing alarming, and the old project fails weeks later when you
next open it.
A virtual environment removes the problem rather than managing it. python -m venv .venv
creates a directory holding its own site-packages; activating it puts that directory first on the
import path, so pip install writes there and imports read there. Every project gets its own, and
the share-working column becomes 100% by construction — there is nothing left to collide.
python -m venv .venv # create — do this once per project source .venv/bin/activate # macOS/Linux (.venv\Scripts\activate on Windows) pip install -r requirements.txt deactivate # when you are done
Add .venv/ to .gitignore. The environment is a build artifact, not source — what
you commit is the list of what to install, which is the next problem.
A requirements.txt is a shopping list, and pip fills it with whatever is newest at the time
unless you say otherwise. There are three levels of care, and the middle one is where most projects sit —
along with most of the surprises. Nine packages here: three you asked for and six pulled in behind them:
The middle row is the trap worth internalising. You pinned pandas==2.1.4, so pandas is
exactly what you asked for — and numpy, which pandas pulls in, is not pinned by anybody, so it
moves. Your build is reproducible in the packages you thought about and unreproducible in the ones you
didn't, which is exactly the shape of bug that takes a day to find.
pip freeze > requirements.txt # every installed package, exact version — direct AND transitive
That is the cheapest lock file there is, and it turns the bottom row on. Its weakness is that it does not
record why anything is there, so the file grows and nobody dares delete a line — which is the problem
uv,
Poetry and PDM exist to solve: they
keep the short list you maintain separate from the exact lock file they generate. For Phase 0, plain
venv + pip freeze is entirely enough; know that the tools exist and why.
pip install writes to one shared folder by default, and
with a single machine-wide site-packages only a minority of projects keep working — two projects
are already at 56% · python -m venv .venv gives each project its own, which removes the collision
instead of managing it; .gitignore the folder, commit the list · an unpinned requirements file
drifts on every package within a year; pinning only your direct dependencies still leaves 6 of 9 free to move
· pip freeze is the cheap lock file — exact versions for direct and transitive alike · uv/Poetry
exist to keep the list you maintain separate from the lock they generate. Next:
dataclasses.Second opinion (taught here — these corroborate): venv docs · Python Packaging User Guide · uv.