Python Virtual Environments and Pinning
A virtual environment is a directory with its own `python` and its own `site-packages`. It exists so that two scripts on one machine can need two different versions of the same library, and so that a script's dependencies can be written down and rebuilt exactly. This guide creates one, pins it, rebuilds it from the pin, and then demonstrates the failure that makes all of it matter: a scheduled job that runs the wrong interpreter and finds the wrong library.
Scripting Foundations Guide 8 of 39 Beginner
- Python3.14.4
- Control nodeUbuntu 26.04 LTS
- Managed hostsRHEL 10.0
- requests2.34.2
- paramiko5.0.0
- pytest9.1.1
- PyYAML6.0.3
- boto3 / botocore1.43.78
- TimeAbout 17 min
- Reviewed24 August 2026
Written against the versions above. `venv` has been in the standard library since Python 3.3. On Debian and Ubuntu it is split into a separate package - `apt install python3.14-venv`, matching your minor version - and the error if it is missing says so.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| RUNNER01 | 192.168.0.27 | Ubuntu 26.04 LTS | Control node - every script in this path runs here | 2 Core | 4 GB | 50 GB |
Before you start
- guide 2 - PEP 668, and why this is not optional on Ubuntu.
- guide 3 - the three places a module can come from.
-
Create one from scratch
One command, and the result is an ordinary directory you can delete. Nothing is registered anywhere and nothing outside it changes.
bash Example session cd ~/auto && rm -rf demoenv && python3 -m venv demoenv && ls demoenvbinincludeliblib64pyvenv.cfgls ~/auto/demoenv/binActivate.ps1activateactivate.cshactivate.fishpippip3pip3.14pythonpython3python3.14𝜋thon~/auto/demoenv/bin/python --version && ~/auto/demoenv/bin/pip --versionPython 3.14.4pip 25.1.1 from /home/sysadmin/auto/demoenv/lib/python3.14/site-packages/pip (python 3.14)Expected result
bin,include,liband apyvenv.cfg; then the interpreter and pip inside it.Success conditionYou have an environment you can install into.
-
What is in it before you install anything
Almost nothing - and that is the point. A fresh venv sees the standard library and none of the system's third-party packages.
bash Example session ~/auto/demoenv/bin/pip listPackage Version------- -------pip 25.1.1~/auto/demoenv/bin/python -c "import sys; print('prefix ', sys.prefix); print('base_prefix ', sys.base_prefix); print('in a venv? ', sys.prefix != sys.base_prefix)"prefix /home/sysadmin/auto/demoenvbase_prefix /usrin a venv? Truepython3 -c "import sys; print('system python in a venv?', sys.prefix != sys.base_prefix)"system python in a venv? FalseExpected result
pipalone in the list;prefixpointing at the venv whilebase_prefixstill points at/usr; and the system Python reportingFalse.Success conditionYou can tell from inside a script whether you are in a venv.
-
The standard library is shared, the packages are not
Worth being precise about what is isolated. The standard library is not copied -
jsoncomes from the same place either way. Onlysite-packagesdiffers.bash Example session ~/auto/demoenv/bin/python -c "import json, os; print('json comes from ', os.path.dirname(json.__file__))"json comes from /usr/lib/python3.14/json~/auto/demoenv/bin/python -c "import site; print('site-packages ', site.getsitepackages())"site-packages ['/home/sysadmin/auto/demoenv/lib/python3.14/site-packages', '/home/sysadmin/auto/demoenv/local/lib/python3.14/dist-packages', '/home/sysadmin/auto/demoenv/lib/python3/dist-packages', '/home/sysadmin/auto/demoenv/lib/python3.14/dist-packages']Expected result
jsonfrom/usr/lib/python3.14, andsite-packagesinside the venv.Success conditionYou know exactly what a venv isolates.
-
Install something, and pin it
pip installinto the venv works, because PEP 668 only protects the system one. Thenpip freezewrites down exactly what is there, in the formatpip install -rreads back.bash Example session ~/auto/demoenv/bin/pip install --quiet 'requests==2.32.5' && ~/auto/demoenv/bin/pip listPackage Version------------------ ---------certifi 2026.7.22charset-normalizer 3.5.1idna 3.19pip 25.1.1requests 2.32.5urllib3 2.7.0~/auto/demoenv/bin/pip freezecertifi==2026.7.22charset-normalizer==3.5.1idna==3.19requests==2.32.5urllib3==2.7.0cd ~/auto && ~/auto/demoenv/bin/pip freeze > requirements.txt && cat requirements.txtcertifi==2026.7.22charset-normalizer==3.5.1idna==3.19requests==2.32.5urllib3==2.7.0Expected resultrequests and its four dependencies, each pinned with
==to an exact version.Success conditionYou can write down what a script needs.
-
Two venvs, two versions, one machine
The same import name, three answers, on one host. This is the problem a venv exists to solve.
bash Example session ~/auto/demoenv/bin/python -c "import requests; print('demoenv requests', requests.__version__)"demoenv requests 2.32.5~/autoenv/bin/python -c "import requests; print('autoenv requests', requests.__version__)"autoenv requests 2.34.2python3 -c "import requests; print('system requests', requests.__version__)"system requests 2.32.5Expected result
2.32.5in the demo venv,2.34.2in the path's venv,2.32.5from the system.Success conditionYou can keep two scripts with conflicting requirements on one machine.
-
Rebuilding from the pin
A new empty venv and one
pip install -rreproduces the first exactly. This is what makes a pin worth having: the environment is a file in the repository rather than something somebody set up once.bash Example session cd ~/auto && rm -rf rebuilt && python3 -m venv rebuilt && ./rebuilt/bin/pip install --quiet -r requirements.txt && ./rebuilt/bin/pip freezecertifi==2026.7.22charset-normalizer==3.5.1idna==3.19requests==2.32.5urllib3==2.7.0Expected resultThe same five packages at the same five versions.
Success conditionYou can reproduce an environment on another machine.
-
A range against an exact pin
requests>=2.30andrequests==2.32.5are both valid requirement lines and they mean very different things.bash Example session cd ~/auto && printf 'requests>=2.30\n' > loose.txt && rm -rf loose && python3 -m venv loose && ./loose/bin/pip install --quiet -r loose.txt && ./loose/bin/pip freeze | grep -i requestsrequests==2.34.2cd ~/auto && cat requirements.txt loose.txtcertifi==2026.7.22charset-normalizer==3.5.1idna==3.19requests==2.32.5urllib3==2.7.0requests>=2.30Expected resultThe loose file resolving to whatever is newest - 2.34.2 here, against the pinned 2.32.5.
Success conditionYou can see what a version range costs you.
-
Activation is a PATH trick and nothing more
source bin/activateprepends the venv'sbintoPATHand setsVIRTUAL_ENV. That is all it does. Inside a subshell,pythonis the venv's; outside it, nothing changed.bash Example session cd ~/auto && bash -c 'source demoenv/bin/activate; echo "which python: $(which python)"; echo "VIRTUAL_ENV : $VIRTUAL_ENV"; python -c "import requests; print(\"requests\", requests.__version__)"'which python: /home/sysadmin/auto/demoenv/bin/pythonVIRTUAL_ENV : /home/sysadmin/auto/demoenvrequests 2.32.5which python3; python3 -c "import requests; print('outside the subshell, requests', requests.__version__)"/usr/bin/python3outside the subshell, requests 2.32.5Expected resultInside:
which pythonin the venv and its 2.32.5. Outside: the system python3 and its own requests.Success conditionYou know what activation does and does not change.
-
Which is why anything scheduled names the interpreter in full
env -istrips the environment, which is a fair approximation of what a scheduler gives a process. The script needsparamiko, which exists only in the path's venv.Three runs. Read the second one carefully.
bash Example session cat > ~/auto/sched.py <<'PY'import sys print("interpreter:", sys.executable or "(empty)") import paramiko print("paramiko :", paramiko.__version__)PY~/autoenv/bin/python ~/auto/sched.pyinterpreter: /home/sysadmin/autoenv/bin/pythonparamiko : 5.0.0env -i python3 /home/sysadmin/auto/sched.py 2>&1 | tail -3 File "/home/sysadmin/auto/sched.py", line 5, in <module> import paramikoModuleNotFoundError: No module named 'paramiko'env -i sh -c 'echo "PATH inside env -i: [$PATH]"; command -v python3'PATH inside env -i: [/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin]/usr/bin/python3env -i /home/sysadmin/autoenv/bin/python /home/sysadmin/auto/sched.pyinterpreter: /home/sysadmin/autoenv/bin/pythonparamiko : 5.0.0Expected resultWorks with the full path;
ModuleNotFoundError: No module named 'paramiko'with a barepython3; and works again once the interpreter is named in full.Success conditionYou can write a script invocation that survives a scheduler.
-
Refusing to install outside a venv, on purpose
One environment variable that turns "I forgot to activate" from a mess into an error. Worth setting in your shell profile permanently.
bash Example session cd ~/auto && PIP_REQUIRE_VIRTUALENV=true python3 -m pip install requests 2>&1 | tail -3ERROR: Could not find an activated virtualenv (required).cd ~/auto && PIP_REQUIRE_VIRTUALENV=true ./demoenv/bin/pip install --quiet requests && echo " inside a venv: allowed" inside a venv: allowedExpected resultRefused outside a venv, allowed inside one.
Success conditionYou cannot accidentally install into the system Python.
Troubleshooting
ensurepip is not availablecreating a venv.Why: Debian and Ubuntu ship
venvseparately.Fix:
sudo apt install python3.14-venv, matching your minor version.ModuleNotFoundErrorfor a package you definitely installed.Why: A different interpreter ran the script - almost always the system one.
Fix:
python3 -c 'import sys; print(sys.executable)'inside the script's context, and invoke the intended interpreter by absolute path.A venv stops working after a system upgrade.
Why:
bin/pythonsymlinks to the system interpreter, and the minor version moved.Fix:Delete and rebuild from
requirements.txt. This is what the pin is for.A rebuilt environment behaves differently from the original.
Why: The requirements file uses ranges, so a transitive dependency resolved to a newer version.
Fix:Deploy from
pip freezeoutput with==on everything, including transitive dependencies.pip installfails withexternally-managed-environment.Why: PEP 668 - you are outside a venv.
Fix:Create one. Do not reach for
--break-system-packages.A cron job works when you run it by hand.
Why: Your shell has the venv activated and cron's has nothing.
Fix:Use absolute paths for both the interpreter and the script. Never rely on activation.