Install two Python projects on the same machine and the second one can break the first. One needs requests==2.28, the other needs requests==2.32, and pip has exactly one system-wide site-packages directory to write into. The last install wins, silently. You find out when the other project starts throwing errors that have nothing to do with the code you just changed.
A virtual environment gives each project its own site-packages. Nothing global gets touched, and two projects can depend on incompatible versions of the same library without either one noticing.
What is a Python virtual environment?
A venv is a directory with three things in it: a Python interpreter (a symlink to the system one on Linux and macOS), a pyvenv.cfg file pointing back at that base installation, and a site-packages folder where pip install writes packages. Activating it puts the venv’s bin directory (Scripts on Windows) at the front of your shell’s PATH, so python and pip resolve to the venv’s copies instead of the system ones.
That’s the whole mechanism. There is no filesystem isolation and no second copy of Python. A venv only changes where the shell finds two binaries and where imports get resolved from, which is why creating one takes a second and deleting one is rm -rf .venv. If you need to isolate the operating system and filesystem as well, that’s a job for a container, not a venv.
How do you create and activate a venv?
The standard library ships the venv module, so there is nothing to install:
| |
.venv is the conventional name, and uv and most editors look for it by default. Activation depends on your shell:
| OS / shell | Command |
|---|---|
| Linux / macOS (bash, zsh) | source .venv/bin/activate |
| Windows (cmd.exe) | .venv\Scripts\activate.bat |
| Windows (PowerShell) | .venv\Scripts\Activate.ps1 |
A fresh Windows install blocks PowerShell scripts, the activation script included. Allow locally created scripts once per user:
| |
Once the venv is active, your prompt gets a (.venv) prefix and which python (where python on Windows) points inside .venv. Run deactivate to go back. It’s a shell function the activation script defined, not a separate program.
Activation is optional. .venv/bin/python script.py and .venv/bin/pip install requests work without it, and that’s how CI pipelines and editors call the venv under the hood.
With more than one Python version installed, python -m venv uses whichever python your shell finds first, which is often not the one you meant. Name the version: python3.12 -m venv .venv on Linux and macOS. On Windows, use the py launcher that ships with Python, which picks a version by number regardless of PATH: py -3.12 -m venv .venv.
How do you install packages with pip and requirements.txt?
Inside an active venv, pip install <package> does what you’d expect. For reproducibility, the part that matters is recording what got installed:
| |
pip freeze writes every installed package with its exact version, your dependencies’ dependencies included. Commit requirements.txt, and anyone who clones the project runs:
| |
and gets the versions you had. The weak spot: the file mixes what you asked for with everything pip pulled in on the way. Six months later, telling “I need Flask” apart from “Flask needed Werkzeug 3.1.3” means reading the whole list and guessing.
pyproject.toml vs requirements.txt: what’s the difference?
pyproject.toml records only your direct dependencies, with version ranges:
| |
pip install . reads this and resolves the full dependency tree at install time. pip install ".[dev]" adds the dev group on top. The same file holds packaging metadata if you ever publish the project, something requirements.txt was never designed for.
What it doesn’t do is pin exact versions. flask>=3.0 resolves to whatever is newest on the day you install. Pinning is the job of a lock file, and venv + pip don’t give you one.
In a container build, this is the file you copy in before the rest of the source, so the dependency layer only rebuilds when pyproject.toml changes. The Dockerfile best practices guide explains why that ordering matters for the build cache.
How do you use uv instead of venv and pip?
uv is a single Rust binary that covers the jobs of venv, pip, pip-tools and pyenv: uv python install 3.12 downloads and manages Python versions the way pyenv does, so uv venv --python 3.12 works even if that version isn’t on the machine yet. It’s also fast enough to notice: a resolution that takes pip several seconds finishes in a fraction of that.
Install it once (the astral.sh docs list the command for each platform). You can then use it in two ways.
As a drop-in replacement for venv and pip, if speed is all you want:
| |
uv venv creates .venv the same way python -m venv .venv does, and uv pip install takes the same arguments as pip. You activate the result like any other venv.
In project mode, which adds locking:
| |
uv init scaffolds a pyproject.toml. uv add writes the dependency into it, resolves the full tree and writes a uv.lock file that pins every resolved version, transitive ones included, with hashes. That lock file is the piece pip doesn’t produce. Commit both pyproject.toml and uv.lock, and a teammate runs:
| |
This creates .venv if it’s missing and installs exactly what the lock file says, not whatever the version ranges in pyproject.toml resolve to that day. uv run script.py runs the same sync check before executing, so your environment can’t drift away from the lock file without you noticing.
How do you fix common virtual environment errors?
error: externally-managed-environment. You ran pip install outside a venv on Debian 12+, Ubuntu 23.04+, Fedora 38+ or a similar distro, and the system Python refused. PEP 668 introduced a marker file that the OS package manager places in its Python install to stop pip from overwriting packages that apt or dnf also manage. Before that, a stray sudo pip install could break the OS’s own tools in ways that were miserable to debug. Some older guides tell you to add --break-system-packages. Don’t: it brings back exactly the breakage the marker prevents. Create a venv and install there.
The editor runs the wrong interpreter. VS Code, PyCharm and similar tools pick up whatever python resolved to when the project was opened, which isn’t necessarily your venv’s. The symptoms: imports that work in the terminal show as unresolved in the editor, or a package you installed five minutes ago isn’t found. Set the editor’s interpreter explicitly to .venv/bin/python (.venv\Scripts\python.exe on Windows).
The venv got committed to git. That’s a few hundred megabytes of binaries plus absolute paths in pyvenv.cfg and the activation scripts, so it breaks as soon as someone clones the repo to a different path or OS. Add .venv/ to .gitignore. Since Python 3.13, venv also writes a .gitignore inside the new environment that ignores its contents, but on older versions you have to do it yourself. Commit requirements.txt, or pyproject.toml with uv.lock, instead. Those files rebuild the environment anywhere. The directory itself never belongs in the repo.
venv, pyproject.toml or uv: which should you use?
Stay with venv + pip + requirements.txt for a small project with a short dependency list where you want no extra tooling. Move to pyproject.toml as soon as you have dev-only dependencies like test runners and linters that shouldn’t ship to production, or once you plan to package the project.
Add uv when resolution speed starts to hurt or you want a committed lock file without maintaining one by hand with pip-tools. It doesn’t force a rewrite: run uv init on a new project, or uv venv and uv pip install -r requirements.txt in an existing one, and switch to uv add when you’re ready for the lock file.