Python and virtual environments on Ubuntu 26.04

python3, python3-venv, PEP 668 and running a venv from cron or systemd.

Intermediate35 min · lesson 1 of 16
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (2 files, 1 KB): ps-setup.tar.gz. Unpack it with tar -xzf ps-setup.tar.gz, which creates ps-setup/. SHA-256: dbd930cb29558827af4700d27b09c6da9b52d0a8058e09088c76170b612b6c92

This lesson sets up the Python every later lesson uses. You will see what a fresh Ubuntu 26.04 server gives you, install the one missing package, create a virtual environment, install a pinned library into it, understand why pip refuses to touch the system Python, and run a script from cron without depending on anything your interactive shell set up. It takes about twenty minutes, and it prevents the most common failure of Python automation on Linux: a job that quietly runs with different library versions from the ones you tested. The lab works in ~/ps-setup; unpack the lesson files in your home directory, or create the directory and the two files as they appear.

What a fresh Ubuntu 26.04 server gives you

Ubuntu Server 26.04 ships Python 3.14 as python3, because parts of the operating system are written in it. That is where its Python support for you stops. This terminal is an untouched install with only updates applied:

deploy@web01:~/ps-setup · Ubuntu 26.04 LTS (default install)
$ python3 --version
Python 3.14.4
$ python --version
-bash: line 1: python: command not found
$ pip --version
-bash: line 1: pip: command not found
$ python3 -m pip --version
/usr/bin/python3: No module named pip

Read the four results. python3 is CPython 3.14.4. There is no command called python: Ubuntu installs the interpreter only as python3, and the bare name comes from an optional package, python-is-python3. A crontab line or an old script that calls python fails with exit status 127, the Bash code for "command not found". There is no pip command either, and python3 -m pip says the module does not exist. Ubuntu does not ship pip for its own Python.

In an interactive terminal Ubuntu's command-not-found helper adds a hint under those errors. For pip the hint is apt install python3-pip. Hold off on that one; the PEP 668 section below shows what it leads to.

A virtual environment (venv) is a directory with its own python and its own place for third-party packages, so a library you install for one tool cannot change another tool or the operating system. The standard library module venv creates one. On the fresh server it fails:

deploy@web01:~/ps-setup · Ubuntu 26.04 LTS (default install)
$ python3 -m venv .venv
The virtual environment was not created successfully because ensurepip is not available. On Debian/Ubuntu systems, you need to install the python3-venv package using the following command. apt install python3.14-venv You may need to use sudo with that command. After installing the python3-venv package, recreate your virtual environment. Failing command: /home/deploy/ps-setup/.venv/bin/python3
$ ls .venv
bin include lib lib64 pyvenv.cfg
$ rm -rf .venv
$ apt-cache policy python3-venv
python3-venv: Installed: (none) Candidate: 3.14.3-0ubuntu2 Version table: 3.14.3-0ubuntu2 500 500 http://archive.ubuntu.com/ubuntu resolute/universe arm64 Packages

venv works in two stages. It builds the directory, then runs ensurepip to put a copy of pip inside it. Debian and Ubuntu move ensurepip out of the base Python package into python3-venv, which a server install does not include, so the second stage fails and the command exits 1. apt-cache policy confirms it: Installed: (none), available from the universe component.

Two details in that output matter. The failed run still left a .venv directory behind with no pip in it, so delete it before you try again. And the message suggests python3.14-venv; install python3-venv instead. It pulls in the versioned package for whatever the default python3 is, so it keeps working when a later release moves the default on. Run this once on your machine: sudo apt install python3-venv. The rest of this lesson runs on a machine where that is done.

Create a venv and install into it

deploy@web01:~/ps-setup · Ubuntu 26.04 LTS
$ apt-cache policy python3-venv
python3-venv: Installed: 3.14.3-0ubuntu2 Candidate: 3.14.3-0ubuntu2 …
$ python3 -m venv .venv
$ ls -l .venv/bin/python*
lrwxrwxrwx 1 deploy deploy 7 Sep 28 20:45 .venv/bin/python -> python3 lrwxrwxrwx 1 deploy deploy 16 Sep 28 20:45 .venv/bin/python3 -> /usr/bin/python3 lrwxrwxrwx 1 deploy deploy 7 Sep 28 20:45 .venv/bin/python3.14 -> python3
$ cat .venv/pyvenv.cfg
home = /usr/bin include-system-site-packages = false version = 3.14.4 executable = /usr/bin/python3.14 command = /usr/bin/python3 -m venv /home/deploy/ps-setup/.venv

python3 -m venv .venv prints nothing when it works. .venv is the usual name, and the leading dot keeps it out of a plain ls. The listing shows what a venv really is: .venv/bin/python3 is a symbolic link to /usr/bin/python3, not a copy. The interpreter is the system one. The difference is pyvenv.cfg. When Python starts, it looks for that file next to the executable and in the directory above it, and finds .venv/pyvenv.cfg. home tells it where the base installation lives, so the standard library comes from there, and include-system-site-packages = false hides the third-party packages installed system-wide. Packages you install go into .venv/lib/python3.14/site-packages.

How .venv/bin/python finds its packages
1You run .venv/bin/python
a symbolic link to /usr/bin/python3
2Python finds .venv/pyvenv.cfg
looked for next to the executable and one directory up
3Standard library from home
home = /usr/bin, so /usr/lib/python3.14
4Third-party packages from the venv
.venv/lib/python3.14/site-packages
5System packages stay hidden
include-system-site-packages = false
deploy@web01:~/ps-setup · Ubuntu 26.04 LTS
$ source .venv/bin/activate command -v python pip deactivate command -v python || echo "python: not found"
/home/deploy/ps-setup/.venv/bin/python /home/deploy/ps-setup/.venv/bin/pip python: not found
$ .venv/bin/python -m pip --version
pip 25.1.1 from /home/deploy/ps-setup/.venv/lib/python3.14/site-packages/pip (python 3.14)
$ .venv/bin/python -m pip install urllib3==2.8.0
Collecting urllib3==2.8.0 Downloading urllib3-2.8.0-py3-none-any.whl.metadata (7.4 kB) Downloading urllib3-2.8.0-py3-none-any.whl (135 kB) Installing collected packages: urllib3 Successfully installed urllib3-2.8.0
$ .venv/bin/python -m pip list
Package Version ------- ------- pip 25.1.1 urllib3 2.8.0

source .venv/bin/activate changes only the shell you type it in. It puts .venv/bin first on PATH and adds (.venv) to your prompt (the recorded terminal does not show prompts). python and pip now resolve inside the venv, deactivate undoes it, and python is gone again. Activation is a convenience for interactive work and nothing depends on it: .venv/bin/python called by its path is the same interpreter with the same packages. Scripts, cron and systemd should always use that form.

Install with python -m pip rather than pip. The -m form runs the pip that belongs to that exact interpreter, so a package cannot land in some other Python that happens to be earlier on PATH. Pin the version with == so the next install gets the same code. Locking every dependency with hashes comes in "Packaging, pinning and auditing your tool". The venv's pip is 25.1.1, the version Ubuntu bundles for ensurepip.

To see which interpreter and which library a script really gets, ask it. This is the script the rest of the lesson runs:

whereami.py
import sys
import urllib3
print("interpreter:", sys.executable)
print("inside a venv:", sys.prefix != sys.base_prefix)
print("urllib3", urllib3.__version__, "from", urllib3.__file__)
deploy@web01:~/ps-setup · Ubuntu 26.04 LTS
$ .venv/bin/python whereami.py
interpreter: /home/deploy/ps-setup/.venv/bin/python inside a venv: True urllib3 2.8.0 from /home/deploy/ps-setup/.venv/lib/python3.14/site-packages/urllib3/__init__.py

sys.executable is the interpreter that is running. sys.prefix differs from sys.base_prefix only inside a venv. urllib3.__file__ is where the import was found. Here all three point into .venv.

Why pip refuses the system Python

Here is what happens when someone follows the apt install python3-pip hint. The lab installed python3-pip for this demonstration and removed it afterwards:

deploy@web01:~/ps-setup · Ubuntu 26.04 LTS
$ pip install urllib3==2.8.0
error: externally-managed-environment × This environment is externally managed ╰─> To install Python packages system-wide, try apt install python3-xyz, where xyz is the package you are trying to install. If you wish to install a non-Debian-packaged Python package, create a virtual environment using python3 -m venv path/to/venv. Then use path/to/venv/bin/python and path/to/venv/bin/pip. Make sure you have python3-full installed. If you wish to install a non-Debian packaged Python application, it may be easiest to use pipx install xyz, which will manage a virtual environment for you. Make sure you have pipx installed. See /usr/share/doc/python3.14/README.venv for more information. note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages. hint: See PEP 668 for the detailed specification.
$ python3 -m pip install urllib3==2.8.0
error: externally-managed-environment …
# the same message as above, exit status 1
$ python3 -c 'import pathlib, sysconfig; print(pathlib.Path(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED").exists())'
True

Both forms stop with error: externally-managed-environment and exit status 1, and nothing is installed. This is PEP 668. Ubuntu puts a marker file called EXTERNALLY-MANAGED in the standard library directory, /usr/lib/python3.14/, and pip refuses to install into any interpreter that has one. The reason is ownership: /usr/lib/python3/dist-packages belongs to apt, and system tools import the versions apt put there. A pip upgrade of one of those libraries can break them, and apt has no record that pip changed its files.

The message lists the ways out: apt install python3-xyz when the operating system packages the library, a venv for everything else, and pipx for command-line applications (covered in the packaging lesson). The last line offers --break-system-packages. Treat that flag, and sudo pip install, as mistakes in a review: they overwrite files apt manages, and sudo runs pip, and any build code a package brings, as root. You do not need python3-pip at all. With python3-venv installed, every venv already has its own pip.

The marker is Ubuntu's decision, not a property of Python. The upstream CPython 3.14.7 this course cross-checks on, compiled from python.org source, has no marker:

deploy@web01:~/ps-setup · CPython 3.14.7
$ python3 -c 'import pathlib, sysconfig; print(pathlib.Path(sysconfig.get_path("stdlib"), "EXTERNALLY-MANAGED").exists())'
False

Run as a normal user, pip on a Python like that falls back to installing into your home directory without complaint. Use a venv there too: the reason for a venv is keeping each tool's libraries separate, and the error message is only Ubuntu enforcing it.

Run it the way cron and systemd will

Activation lives in your interactive shell, and scheduled jobs never see it. The environment cron does give a job (/bin/sh, HOME, a short system PATH, nothing from your .bashrc) is covered in the Linux essentials lesson "Scheduling: cron and systemd timers", and auditing a host's scheduled jobs in the Linux hardening lesson "Scheduled jobs: cron and timers". The question here is narrower: which Python and which libraries does a job get? Let cron run two entries once: one runs the script with bare python3, one runs it with the venv's interpreter by absolute path. Every line carries ps-setup, so the test lines are easy to find and remove again.

crontab.txt
# ps-setup test, every minute: the same script under the system python3 and under the venv.
# ps-setup: cron runs each line with /bin/sh and sets HOME to your home directory.
* * * * * cd "$HOME/ps-setup" && python3 whereami.py > cron-system.out 2>&1
* * * * * cd "$HOME/ps-setup" && "$HOME/ps-setup/.venv/bin/python" whereami.py > cron-venv.out 2>&1

crontab crontab.txt would replace your whole crontab with that file, and crontab -r would delete it, so do what the Bash capstone does: save a copy, add the test lines to what is already there, and wait for the next full minute, when cron runs them (at most 90 seconds here):

deploy@web01:~/ps-setup · Ubuntu 26.04 LTS
$ crontab -l > crontab.before 2> /dev/null { cat crontab.before; cat crontab.txt; } | crontab - echo "ps-setup lines in the crontab: $(crontab -l | grep -c ps-setup)"
ps-setup lines in the crontab: 4
$ timeout 90 bash -c 'until [ -s cron-system.out ] && [ -s cron-venv.out ]; do sleep 1; done' sleep 2; ls cron-*.out
cron-system.out cron-venv.out
$ cat cron-system.out cron-venv.out
interpreter: /usr/bin/python3 inside a venv: False urllib3 2.6.3 from /usr/lib/python3/dist-packages/urllib3/__init__.py interpreter: /home/deploy/ps-setup/.venv/bin/python inside a venv: True urllib3 2.8.0 from /home/deploy/ps-setup/.venv/lib/python3.14/site-packages/urllib3/__init__.py

The two outputs are the lesson. Bare python3 ran /usr/bin/python3, and it did not fail. It imported urllib3 2.6.3 from Debian's python3-urllib3 package instead of the 2.8.0 you pinned. That is the dangerous version of this mistake: a job that half-works, against library versions you never tested, with nothing in the log to say so. The venv entry got the right interpreter and the right library without any activation.

Now take the test lines out again, keeping everything else. diff compares the result with the copy you saved and finds no difference, so nothing else in your crontab changed:

deploy@web01:~/ps-setup · Ubuntu 26.04 LTS
$ crontab -l | grep -v -F ps-setup | crontab - crontab -l | diff crontab.before - && echo "crontab is as before"
crontab is as before

You do not have to wait for cron to test an entry. env -i clears the whole environment, and PATH=/usr/bin:/bin sets a PATH even shorter than cron's:

deploy@web01:~/ps-setup · Ubuntu 26.04 LTS
$ env -i PATH=/usr/bin:/bin python3 whereami.py
interpreter: /usr/bin/python3 inside a venv: False urllib3 2.6.3 from /usr/lib/python3/dist-packages/urllib3/__init__.py
$ env -i PATH=/usr/bin:/bin python whereami.py
env: 'python': No such file or directory env: use -[v]S to pass options in shebang lines
$ env -i PATH=/usr/bin:/bin "$HOME/ps-setup/.venv/bin/python" "$HOME/ps-setup/whereami.py"
interpreter: /home/deploy/ps-setup/.venv/bin/python inside a venv: True urllib3 2.8.0 from /home/deploy/ps-setup/.venv/lib/python3.14/site-packages/urllib3/__init__.py

The middle command shows the other trap: python does not exist, so the job fails with 127. The second line of that error is a hint from the uutils env that Ubuntu 26.04 uses and has nothing to do with the problem. The last command is the form to put in a crontab or a systemd unit: absolute paths for both the interpreter and the script, for example ExecStart=/home/deploy/ps-setup/.venv/bin/python /home/deploy/ps-setup/whereami.py (the lab user is deploy; use your own home directory). The capstone schedules a real job this way; the Linux course covers systemd timers.

What a venv is not

A venv is not a sandbox. Code inside it runs as your user, can read everything you can read (~/.ssh, tokens in your environment), and can open network connections. It separates library versions, not privileges. Untrusted code needs a container, a virtual machine or a separate account.

A venv also does not move. The scripts pip installs into bin/ start with a #! line holding the venv's absolute path at the time it was created. Rename the directory and see:

deploy@web01:~/ps-setup · Ubuntu 26.04 LTS
$ mv .venv moved-venv moved-venv/bin/pip --version
-bash: moved-venv/bin/pip: /home/deploy/ps-setup/.venv/bin/python3: bad interpreter: No such file or directory
$ head -1 moved-venv/bin/pip
#!/home/deploy/ps-setup/.venv/bin/python3
$ rm -rf moved-venv python3 -m venv .venv .venv/bin/python -m pip install -q urllib3==2.8.0 .venv/bin/python whereami.py
interpreter: /home/deploy/ps-setup/.venv/bin/python inside a venv: True urllib3 2.8.0 from /home/deploy/ps-setup/.venv/lib/python3.14/site-packages/urllib3/__init__.py

Bash reports bad interpreter: No such file or directory with exit status 126. The file moved-venv/bin/pip exists; the interpreter named in its first line, /home/deploy/ps-setup/.venv/bin/python3, does not. The same happens when a venv is copied to another host, restored from a backup into a different path or copied into a container image. The fix is to rebuild it where it will run, which takes seconds, as the last command shows. Keep what you need to rebuild (the pinned package list) in version control, and add .venv/ to .gitignore.

Never answer PEP 668 by forcing it
pip install --break-system-packages, sudo pip install and apt install python3-pip all try to solve the wrong problem. Install python3-venv once, then give every tool its own venv.

Try this

In ~/ps-setup, build a second venv called try-env and install urllib3==2.8.0 into it with try-env/bin/python -m pip install. Break it: run whereami.py the way a careless crontab would, env -i PATH=/usr/bin:/bin python3 whereami.py, and note the urllib3 version it reports. Diagnose it from the interpreter: line. Fix it by calling "$HOME/ps-setup/try-env/bin/python" with the absolute path of the script, still under env -i PATH=/usr/bin:/bin (your shell expands $HOME before env -i clears the environment). Verify: the broken run reports inside a venv: False and urllib3 2.6.3, the fixed run reports inside a venv: True and urllib3 2.8.0 from try-env. Then delete try-env.

Takeaway

Give every tool its own venv, install into it with <venv>/bin/python -m pip install name==version, and make cron and systemd call that interpreter by absolute path. If pip says externally-managed-environment, you are about to install into the wrong Python.

Quick check
01A crontab entry runs python3 /opt/audit/report.py. You installed urllib3==2.8.0 into /opt/audit/.venv, the job logs no errors, yet a debug line in its output says urllib3 2.6.3. What is going on?
Incorrect — cron activates nothing. It runs the command line with its own short environment, so no venv is involved unless the command names one.
Incorrect — That setting hides the system's packages from the venv; it never adds them. And this job did not use the venv interpreter at all.
Correct — Bare python3 is the system interpreter, and Ubuntu ships its own urllib3 for it, so the job runs without an error against a version you never tested. Use /opt/audit/.venv/bin/python.
Incorrect — <venv>/bin/python -m pip install installs into the venv's own site-packages, not into ~/.local. The version difference comes from which interpreter ran.
02On a fresh Ubuntu 26.04 server python3 -m venv .venv prints "ensurepip is not available" and exits 1. What should you do?
Correct — venv needs ensurepip, which Ubuntu ships in python3-venv. The failed run leaves a directory without pip, so start clean.
Incorrect — venv runs ensurepip, which is not part of python3-pip. That package only adds a system pip, and pip then refuses the system Python with the PEP 668 error.
Incorrect — This installs as root into the system Python, the exact thing PEP 668 blocks, and runs pip and any package build code as root.
Incorrect — There is no pip for the system python3 to run, and --user would bypass the venv entirely. The directory has an interpreter but no way to install into it.
03You rename /opt/scan/.venv to /opt/scan/env to match a naming standard. /opt/scan/env/bin/pip --version now fails with "bad interpreter: No such file or directory" and exit status 126. What happened?
Incorrect — A missing execute bit gives "Permission denied". This message names an interpreter path that does not exist, and mv keeps file modes.
Incorrect — home names the base installation, which did not move. It is still correct after the rename.
Incorrect — Bash's hash table only remembers commands found through PATH. This command was given as a path, so nothing was looked up.
Correct — pip writes the venv's absolute path into every script it installs. A venv only works where it was created, so rebuild it at the new path.

Related