How Python packages work: pip, virtual environments, and uv
Installing a package is just copying files into a folder Python searches — and knowing that one fact explains pip, venvs, requirements.txt, and why projects break each other without them. Three simulators walk the import search path, replay the global-install collision that venvs solve, and resolve a dependency tree into a lock file.
mindmap — quick refresh
Prerequisites: Python 3 installed, plus the command line basics from the SSR labs. Time: ~30 minutes.
Every lab on this site so far ran on Python's standard library — deliberately. But the next step is import django, which means someone else's code has to get onto your machine, and that's where beginners hit the wall of pip, venvs, activate, and requirements.txt. One fact flattens the wall: "installing a package" means downloading an archive and unzipping it into a folder Python searches on import. Nothing more. Everything in this guide — pip, virtual environments, lock files, uv — is bookkeeping around that single move. Second fact, the one that causes the pain: by default every project on your machine shares one such folder, which is why installing for project B can silently break project A.
What installing actually is
Start from the failure. import django on a fresh machine raises ModuleNotFoundError — but how did Python decide it doesn't exist? It walked a list of folders, sys.path (print it — it's just a list): the script's own directory, then the standard library, then a folder called site-packages. django wasn't in any of them; error. What pip install django does is exactly as boring as promised: it asks PyPI (the Python Package Index, ~600,000 packages) for the archive, downloads it, and unzips it into site-packages. The next import django walks the same list and now finds a django/ folder there. Watch both walks:
That's the entire mystery of "installation." No registry, no linking — a folder appears in a searched location. It also explains the classic gotcha: name a file re.py in your project and import re finds yours first, because your script's directory is searched before the stdlib.
The collision — and the venv that prevents it
Now the trap built into that design. Site-packages can hold exactly one version of each package. Suppose your foodapp was built on Django 4.2 LTS. Months later you start newapp and run pip install --upgrade django → 5.2 lands in the shared folder. newapp works. foodapp — untouched, unchanged, working yesterday — now imports 5.2 and breaks. Multiply by every package and every project and you get the classic "it works on my machine" archaeology.
Python's fix ships in the box: a virtual environment is a lightweight copy of the Python setup with its own private site-packages, one per project. activate isn't magic either — it just edits your shell's PATH so python and pip point into the project's .venv folder; every install lands there and nowhere else. Two projects, two folders, two Django versions, zero conflict:
The rule that falls out: one venv per project, always — even for toys. The global Python belongs to your operating system; the moment you pip install into it, you're gambling every project (and on Linux, sometimes the OS's own tools) on version luck. Modern Python actually enforces this: many systems now refuse global pip installs with an externally-managed-environment error.
The lab: a project in four commands
The complete workflow, ready for the Django guides — run it in a fresh folder:
python3 -m venv .venv # 1 · create the private environment
source .venv/bin/activate # 2 · point this shell into it (prompt shows (.venv))
pip install django # 3 · lands in .venv/, not the system
pip freeze > requirements.txt # 4 · record exactly what's installed
Prove to yourself where things went: which python now answers inside .venv/, and python -c "import django; print(django.__file__)" prints a path inside .venv/lib/.../site-packages/. Leaving is deactivate; re-entering is step 2 again. Two habits complete the workflow: add .venv/ to .gitignore (it's rebuildable machine output, never source), and commit requirements.txt — because a teammate (or you, next laptop) reproduces the whole environment with pip install -r requirements.txt.
Dependencies have dependencies
Step 4 recorded more than you installed — and that's the point. You asked for one package; pip freeze shows three:
asgiref==3.9.1
Django==5.2.5
sqlparse==0.5.3
Django depends on asgiref and sqlparse, so pip's resolver fetched them too, choosing versions that satisfy every package's declared constraints (Django asks for asgiref>=3.8.1, not an exact pin). Real projects hit tens or hundreds of these transitive dependencies — and "whatever versions the resolver picked today" is not reproducible, because tomorrow's resolve may pick newer ones. That's what the == pins in requirements.txt are for: they freeze today's working answer so every future install replays it exactly:
Vocabulary you now own: a version specifier (>=3.8.1) states what's acceptable, a pin/lock (==3.9.1) states what was chosen, and reproducibility means installing from the locks, not the wishes.
The modern toolchain: uv
Everything above is the mechanism — and pip + venv will serve you fine through every Django guide here. But you should know what the ecosystem actually reaches for in 2026: uv, a single tool that replaces pip + venv + the lock-file scripts, runs 10–100× faster, and manages Python versions too. The concepts transfer one-to-one; the workflow gets shorter:
uv init foodapp # project + pyproject.toml
uv add django # resolves, installs into .venv, updates uv.lock
uv run python manage.py runserver # runs inside the venv, no activate needed
Two upgrades over the manual flow: your wishes live in pyproject.toml (the standard project file: dependencies = ["django>=5.2"]) while the chosen versions live in uv.lock — the specifier/pin split from the last section, formalized into two files — and uv sync rebuilds an identical environment anywhere. Poetry and pipenv pioneered this shape; uv is the current consensus pick. Our recommendation: understand pip + venv (you now do), use uv day-to-day — and when the Django docs say pip install, you'll know uv add is the same move.
Takeaways
- Installing = unzipping into a searched folder —
importwalkssys.path(script dir → stdlib → site-packages); pip just puts files where that walk will find them. - The global site-packages is shared state — one version per package for all projects; installing for one can break another silently.
- One venv per project, no exceptions — a private site-packages each;
activateonly repoints your shell'sPATH. Keep.venv/out of git. - You install one package, you get a tree — the resolver satisfies transitive constraints;
pip freeze > requirements.txtpins the working answer so any machine can replay it. - Specifier vs pin is wishes vs decisions —
>=3.8.1is acceptable-range,==3.9.1is what shipped; reproducible installs come from pins. - Use uv daily, on pip+venv understanding —
uv add/uv syncwithpyproject.toml+uv.lockis the same mechanism, faster and with the bookkeeping automated.
References
- Astral. (n.d.). uv: An extremely fast Python package and project manager (Documentation). Retrieved August 17, 2026, from https://docs.astral.sh/uv/
- Python Packaging Authority. (n.d.). Install packages in a virtual environment using pip and venv (Python Packaging User Guide). Retrieved August 17, 2026, from https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/
- Python Software Foundation. (n.d.). The import system (The Python language reference). Retrieved August 17, 2026, from https://docs.python.org/3/reference/import.html
- Python Software Foundation. (n.d.). venv — Creation of virtual environments (Python documentation). Retrieved August 17, 2026, from https://docs.python.org/3/library/venv.html