PYTHON / MODULES AND PACKAGES
pip, PyPI, and pinning dependencies
Read pip requirement lines as version sets, install into the interpreter you actually run, and pin an environment so installs stay reproducible.
What you will learn
- Install with `python -m pip` so packages land in the interpreter you run
- Read a requirement line as a set of allowed versions, not a single version
- Freeze direct *and* transitive dependencies to make an install reproducible
- Keep version ranges in library metadata, exact pins in a deployed environment
Understanding pip, PyPI, and pinning dependencies
PyPI is a package index: a catalogue of project names, each with a list of released versions and files (wheels, and sometimes source archives). pip is a client for that index. When you run `python -m pip install requests`, pip asks PyPI which versions of `requests` exist, picks one, downloads the matching wheel, and unpacks it into the `site-packages` directory of the interpreter that ran pip. That last detail is why `python -m pip` is safer than a bare `pip`: `pip` on your PATH may belong to a different Python than the one you use for `python script.py`, and then the install succeeds while the import still fails.
The important mental shift is that a requirement is not a version, it is a set of acceptable versions. `requests` means "anything", `requests>=2.28` means "anything at least that new", `requests~=2.31.0` means "2.31.x, at least .0", and only `requests==2.31.0` names one point. pip resolves all of these sets at once, including the requirements declared by your dependencies' dependencies, and prefers the newest version that satisfies everything. Because PyPI keeps gaining new releases, any unpinned set makes your install a function of the day you ran it, which is exactly how a project that "worked last month" breaks with no code change.
So real projects use two layers. The abstract layer describes what your code can tolerate — ranges in `pyproject.toml` or a `requirements.in` — and a library should stay here, because an exact pin in published metadata collides with every other package that wants a different version of the same dependency. The concrete layer is a fully pinned snapshot of one working environment: every package, direct and transitive, at an exact version, which is what `python -m pip freeze > requirements.txt` produces and what `pip install -r requirements.txt` reproduces. Deployments and CI install the concrete layer; humans edit the abstract one and regenerate the pins deliberately.
def split_req(line):
for op in ("==", "~=", ">=", ">"):
if op in line:
name, version = line.split(op, 1)
return name, op, version
return line, None, None
for line in ["requests", "requests>=2.28", "requests~=2.31.0", "requests==2.31.0"]:
name, op, version = split_req(line)
if op is None:
note = "newest version on PyPI at install time"
elif op == "==":
note = f"exactly {version}, every time"
else:
note = f"anything matching {op}{version}"
print(f"{line:18} -> {name}: {note}")A requirement line describes a set of acceptable versions, and only an exact pin of every installed package (not just the ones you named) makes an install reproducible.
Worked examples
What `~=` actually allows
Implements the compatible-release rule so you can see which versions `~=2.31.0` accepts and which it rejects.
def compatible(spec, candidate):
base = [int(p) for p in spec.split(".")]
cand = [int(p) for p in candidate.split(".")]
return cand >= base and cand[:len(base) - 1] == base[:len(base) - 1]
for v in ["2.31.0", "2.31.4", "2.32.0", "3.0.0", "2.30.9"]:
print(f"~=2.31.0 accepts {v}? {compatible('2.31.0', v)}")Example explained
Line 1`cand >= base` is the lower bound: 2.30.9 is older than 2.31.0, so it fails.
Line 2`cand[:len(base)-1] == base[:len(base)-1]` freezes all components except the last, so 2.32.0 and 3.0.0 fail.
Line 3That is why `~=2.31.0` means the same as `>=2.31.0, ==2.31.*`: patch releases are allowed in, feature releases are not.
Line 4Comparing lists of ints, not strings, is essential — see the next example.
Version strings do not sort like versions
Shows why pip compares versions component by component instead of as text.
versions = ["2.9.0", "2.10.0", "2.2.0"]
print("as text: ", sorted(versions))
print("as numbers:", sorted(versions, key=lambda v: tuple(int(p) for p in v.split("."))))
print("newest: ", max(versions, key=lambda v: tuple(int(p) for p in v.split("."))))Example explained
Line 1Lexicographic order compares '1' against '2' in the second component, so 2.10.0 wrongly sorts before 2.2.0.
Line 2Converting each dotted component to an int gives the ordering pip uses, where 10 > 9.
Line 3This is why `pip install 'x>=2.9'` still installs 2.10.0: the bound is numeric, not alphabetical.
Freeze and restore a pinned environment
Simulates the `pip freeze > requirements.txt` then `pip install -r requirements.txt` round trip, including transitive dependencies.
installed = {"requests": "2.31.0", "urllib3": "2.2.1", "certifi": "2024.2.2"}
lock = "\n".join(f"{name}=={ver}" for name, ver in sorted(installed.items()))
print(lock)
print("--- reinstalling from the file ---")
restored = dict(line.split("==") for line in lock.splitlines())
print(len(restored), "packages pinned")
print("urllib3 ->", restored["urllib3"])Example explained
Line 1Only `requests` was ever asked for; `urllib3` and `certifi` came in as transitive dependencies and still appear in the freeze.
Line 2Every line uses `==`, so re-installing this file cannot pick a newer release of anything.
Line 3The round trip through `split("==")` is exactly what makes the format useful: it is plain text one pin per line.
Line 4A file listing only `requests==2.31.0` would leave urllib3 free to float and is therefore not reproducible.
Important notes
`pip freeze` is a snapshot, not a lock file: it has no hashes and no record of which pins you chose versus which were pulled in. Add `--require-hashes` installs or a tool like pip-tools/uv when you need supply-chain guarantees.
The PyPI project name and the import name are unrelated strings: `pip install pillow` gives you `import PIL`, and `pip install beautifulsoup4` gives you `import bs4`.
Common mistakes
Running bare `pip install` when several Pythons are on the machine: the package is installed into some other interpreter's site-packages, and `python -c 'import requests'` still raises ModuleNotFoundError. Use `python -m pip`.
Committing `requirements.txt` with only the packages you typed (`requests`, `flask`) and no versions: months later a new transitive release is picked up and the build fails on a machine where nobody changed a line of code.
Running `pip freeze` outside a virtual environment: the file captures every unrelated package on the system, plus system-manager artefacts like `pkg-resources==0.0.0`, and fails to install anywhere else.
Try it yourself
Change, predict, then run
Write a `satisfies(spec, version)` function that handles the `==`, `>=`, and `~=` operators for dotted numeric versions, then print a table showing which of `1.4.0`, `1.4.9`, `1.5.0`, and `2.0.0` each of `==1.4.0`, `>=1.4.0`, and `~=1.4.0` allows.
Open the Python workspaceCheck your understanding
A library you publish to PyPI declares `urllib3==2.2.1` in its dependency metadata. What is the most likely consequence for your users?
- Installs will fail whenever the user's project also needs a different urllib3 version, because the resolver cannot satisfy both constraints
- Users get a guaranteed reproducible environment, since the pin fixes the version for everyone
- Nothing changes: pip only honours `==` pins inside requirements.txt, not in package metadata
- It only matters for source distributions; wheels ignore the pinned dependency
Show answer
Dependency metadata is an abstract constraint that must be intersected with everyone else's constraints, so an exact pin in a library turns any disagreement into an unresolvable conflict. It also does not give reproducibility: your other dependencies are still free to float, and reproducibility belongs in the application's fully pinned environment file, not in library metadata.