pip installs Python packages and venv builds the isolated environment they go into. Used together they make dependency management predictable: every project gets its own set of libraries, every library version is recorded in a file you can commit, and a broken environment becomes a two-command fix instead of a reinstall of everything.
Why does every Python project need its own virtual environment?
A virtual environment is a folder that contains its own copy of the interpreter's paths and its own site-packages directory. When you activate it, Python stops looking at the system libraries and looks only inside that folder. Two projects can therefore require completely different versions of the same package without arguing.
You can detect a virtual environment from inside Python, which is the check worth putting in any setup script or debugging session.
import sys
print("executable:", sys.executable)
print("prefix:", sys.prefix)
print("base prefix:", sys.base_prefix)
print("inside a venv:", sys.prefix != sys.base_prefix)
Outside a virtual environment the two prefix values are identical and the last line prints False. Inside one, sys.prefix points at the environment folder while sys.base_prefix still points at the interpreter that created it, so the last line prints True.
# create it (one folder, one command, no admin rights needed)
python -m venv .venv
# activate it - Windows PowerShell
.venvScriptsActivate.ps1
# activate it - macOS and Linux
source .venv/bin/activate
# confirm
python -c "import sys; print(sys.prefix != sys.base_prefix)"
# leave again
deactivate
Use python -m venv rather than venv alone. The -m form guarantees that the environment is created by the same interpreter you are about to use, which matters the moment you have several Python versions installed.
How do you install packages without breaking the environment?
Inside an activated environment, install with python -m pip install. The -m form again ties the installer to the interpreter you are holding, so there is no chance of pip belonging to one Python and your script running under another.
python -m pip install requests
python -m pip install "requests==2.32.3"
python -m pip install --upgrade pip
python -m pip uninstall requests
Three rules cover most of the damage people do to their machines. Never install packages system-wide with sudo pip install - it writes into the operating system's Python and breaks other tools. Never mix pip and conda or poetry in the same environment; each has its own resolver and they will fight. And on Debian or Ubuntu the system Python is marked externally managed, so pip refuses to install into it and points you at a virtual environment instead - that refusal is protecting you, not blocking you.
You can audit what an environment already contains without leaving Python.
from importlib.metadata import distributions, version
installed = sorted({d.metadata["Name"] for d in distributions() if d.metadata["Name"]})
print("distributions installed:", len(installed))
print("first five:", installed[:5])
print("pip version:", version("pip"))
The count depends entirely on your machine - a bare interpreter has a handful of packages, a working project environment has hundreds - but the shape of the answer is what matters: the same list is what pip sees, what your program imports, and what you must reproduce on another computer.
Where should you record your dependencies?
Two files are common, and they answer different questions. requirements.txt is a flat list of packages to install, usually pinned exactly. pyproject.toml describes the project itself - its name, version, metadata and its dependencies - and is the file modern tooling reads.
[project]
name = "tutorsbot-report"
version = "0.3.0"
requires-python = ">=3.12"
dependencies = [
"requests>=2.32",
"rich>=13.9",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.6",
]
[project.scripts]
report = "tutorsbot_report.cli:main"
Declare direct dependencies only. If your code imports requests, list requests; the packages that requests needs in turn are the resolver's business, not yours. Listing them yourself is how a project ends up with three incompatible versions of the same library pinned in the same file.
Version specifiers carry meaning worth knowing. requests>=2.32 allows any 2.x release from 2.32 upwards, which keeps security patches flowing in. requests==2.32.3 freezes one exact release, which is what a lock file does. requests~=2.32.0 means "2.32.x but not 2.33", which is the right choice when a major component moves but minor ones do not. Spelling out a range you do not understand is how a build starts failing six months later for reasons nobody can reconstruct.
Requirements files still earn their place for deployment and for freezing a working environment.
# record the exact set that is currently working
python -m pip freeze > requirements.lock.txt
# only the packages you asked for, without their dependencies
python -m pip list --not-required --format=freeze
# install from a lock file
python -m pip install -r requirements.lock.txt
pip freeze lists everything installed, including transitive dependencies, which is exactly what you want in a lock file but exactly what you do not want in a hand-written requirements file. The --not-required flag gives you the top-level list.
How do you check an environment for broken dependencies?
Two questions come up constantly: is anything missing, and does anything conflict? pip can answer both.
import subprocess
import sys
result = subprocess.run(
[sys.executable, "-m", "pip", "check"],
capture_output=True,
text=True,
)
print("pip check exit code:", result.returncode)
print(result.stdout.strip() or result.stderr.strip())
An exit code of zero and the message No broken requirements found. mean every installed package's dependencies are satisfied. Anything else - pip check exits non-zero - prints the conflicting requirement, and you fix it by reinstalling one of the two packages rather than by reinstalling everything.
For a quick look at what is really installed, the standard library can do it without pip at all.
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
requirements = Path("requirements.txt")
requirements.write_text(
"# runtime dependencies\npip==25.2\npytest>=8\n",
encoding="utf-8",
)
for raw in requirements.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
name = line.split("==")[0].split(">=")[0].split("<=")[0].split("<")[0].split(">")[0].strip()
try:
print(f"{name}: installed {version(name)}")
except PackageNotFoundError:
print(f"{name}: NOT installed")
Blank lines and comments are skipped, and any requirement that is missing is reported instead of crashing. On the machine this was written on, the output was pip: installed 25.2 and pytest: installed 9.1.1; on yours the second line may say NOT installed, which is exactly the kind of drift this check exists to catch.
What to remember
The most useful habit in this whole area is being able to throw the environment away and rebuild it. Delete the .venv folder, recreate it, install from your lock file, run your tests. If that works, the project is reproducible on any machine. If it does not, you have just found the missing piece of your setup - almost always a library that was installed by hand and never written down. Add the environment folder to .gitignore so nobody commits it, and commit the requirements file so everybody can rebuild it.
- One virtual environment per project, created with
python -m venv .venv. - Install with
python -m pipso the installer and the interpreter always match. - Record direct dependencies in
pyproject.toml; keeprequirements.lock.txtfor reproducible installs. - Use
pip freezefor a full lock file andpip list --not-requiredfor the top-level list. - Run
pip checkwhen an import suddenly fails - it names the conflict instead of leaving you guessing.
Try it yourself: In a new folder, run python -m venv .venv and activate it. Install one small package with python -m pip install. Write a two-line requirements.txt that pins it, then run the audit snippet above against that file. Break the environment on purpose by installing a package with a conflicting version, run python -m pip check, read the message it prints, and repair it - then confirm the audit snippet reports every requirement as installed again.