Faragiri

Learn Packages and environments Python

What a virtual environment actually changes, why the import fails from one shell and works from another, and the file that makes a run reproducible.

5Sections
4Min read
4Depth

Four layers, one import

What an import searches, and which layer the folder replaces

An environment does not add a layer, it swaps the two in the middle, which is why the same command behaves differently in two shells.

  1. Your codeImported first, and the reason a file named random.py breaks everythingthe project
  2. The environment's site-packagescertifi, charset_normalizer, idna, requests, urllib3, and nothing elseswapped
  3. The environment's interpretersys.prefix points here, and sys.base_prefix does notswapped
  4. The system interpreter and its packagesStill on the machine, and deliberately not on the search pathinvisible

Activation changes nothing in this stack. It edits PATH so that typing python reaches the third slab, which is why calling the interpreter by its full path works identically and never has to be remembered.

Asking for one package installs several. Measured on 2026-09-02, installing
requests into a fresh environment reported
Successfully installed certifi-2026.7.22 charset_normalizer-3.5.1
idna-3.19 requests-2.34.2 urllib3-2.7.0
: five packages for one name. Four
of them are somebody else's decision, they will be updated on somebody else's
schedule, and they are now yours to keep working.

They landed inside the folder and nowhere else. From the environment's own
interpreter the import succeeds; from the system interpreter in the same shell,
import requests answers
ModuleNotFoundError: No module named 'requests'. That is the whole
mechanism, and it is why the same command works for a colleague and not for
you.

What a virtual environment is, exactly

It is a folder with its own interpreter and its own site-packages, and one
value that gives it away: inside it, sys.prefix differs from
sys.base_prefix. That one line is the reliable way to find out
which environment you are in, and it is worth more than reading a prompt, because
a prompt can be wrong and that comparison cannot.

A fresh environment contains pip and nothing else. Everything the machine had
installed globally is invisible to it, deliberately, and that is the property
being bought: two projects wanting different versions of the same library stop
being a problem that has to be solved at all.

Activating is a convenience, and the path is the fact

Activation edits your PATH so that python means the
environment's interpreter. Nothing else happens, which is why every problem in
this area has the same fix: stop relying on activation and call the interpreter
by path. ./demoenv/bin/python -m pip install requests works
whether or not anything is activated, in a cron job, in a Makefile, and inside
an editor that started its own shell.

Two habits follow. Run pip as python -m pip rather than as
pip, so the pip you run and the interpreter you install into cannot
disagree. And never install into the system interpreter: on a current machine it
will usually refuse outright, and where it does not, it is putting your project's
dependencies where every other project will see them.

What makes the run reproducible

python -m pip freeze prints exactly what is installed with exact
versions, five lines for the install above, starting
certifi==2026.7.22. Committing that is the minimum bar: without
it, the next person gets whatever was current the day they ran the install, and
the bug report begins with two different version sets.

The current shape is better than a frozen list, because a frozen list cannot
tell your direct dependencies from the four that came along. Declaring requests
in pyproject.toml and letting a tool write the lock file keeps that
distinction, so upgrading one thing does not require knowing which of the five
names you actually asked for.

What to run in 2026

uv is the tool most Python projects moved to by 2026, free and open source.
It creates the environment, resolves and installs, and writes a lock file, in
one program where there used to be three, and it is fast enough that the
environment stops being a thing anyone avoids recreating. It replaced the
venv plus pip plus pip-tools combination, and the honest note is that
python -m venv and python -m pip are always present,
which is why this page is written on them.

pyproject.toml is the file, free with the ecosystem and the
standard as of 2026. It replaced setup.py and the loose
requirements.txt convention, and the practical gain is that the
declared dependencies and the resolved ones are two different lists in two
different files, which is the distinction the paragraph above needs.

Where this stalls people

This is the stall of the whole pillar, arriving early: someone who can write
Python meets an environment that will not install, concludes they are bad at
programming, and is actually meeting packaging, which nobody told them was a
separate subject. It is, it has its own vocabulary, and it takes a fortnight
rather than an afternoon.

The one habit that removes most of it: before anything else fails, run
python -c "import sys; print(sys.executable)" and read which
interpreter you are actually talking to. Nearly every install-that-did-not-work
is that path being different from the one running the code. What the interpreter
does with those packages once they are found is
what happens between your
file and the processor
, and what to reach for before adding any of them is
what ships
with Python, and saves you a dependency
. Shipping the environment itself
rather than rebuilding it is
shipping the environment along with
the code
, and the language it all sits in is
readable, everywhere, and the usual
first language
.

Alongside this

Above this