Environments and Dependencies
A venv is a directory with its own interpreter and site-packages; a lockfile is what makes installing into it repeatable.
python -m venv .venv && .venv/bin/python -m pip install -r requirements.txtInstalling packages into the system Python puts every project's dependencies in one directory, where two projects needing different versions of the same library cannot both win. A virtual environment is that directory, per project: python -m venv .venv creates .venv/ with its own bin/ (Scripts\ on Windows), its own site-packages, and a pyvenv.cfg whose home key points back at the base interpreter.
What activating actually changes
Running source .venv/bin/activate prepends .venv/bin to PATH and sets VIRTUAL_ENV. That is all. The docs are explicit that activation is not strictly necessary — .venv/bin/python -m pip install httpx is the longhand of activating and typing pip install httpx, and it is the form to use in scripts and Dockerfiles where there is no shell session to keep. Inside the interpreter, the reliable test is sys.prefix != sys.base_prefix.
Declared ranges, installed versions
Your pyproject.toml declares what your project can work with: [project] dependencies holds PEP 508 strings such as "httpx>=0.27,<0.28", plus requires-python. That is for whoever installs your package. For your own deploys you want one exact version per package — a lockfile — installed with pip install -r requirements.txt, and --require-hashes if you want pip to verify the bytes. pip freeze snapshots what is installed, which is a start, but it records neither hashes nor the Python version.
Worked on your laptop
It is almost always a package you installed but never declared, a transitive dependency that moved because nothing pinned it, or a different Python version. Delete .venv, recreate it, install only from the lockfile, and the gap shows up locally instead of in production.
Example
When to use it
- Work on two projects that need different versions of the same library
- Reproduce a production install on a laptop by building from the lockfile in a fresh venv
- Install your own package in editable mode so imports work from any directory
- Keep test and lint tooling out of the production environment with an optional dependency group
More examples
A project from nothing to running
Both options install the same bytes into the same place. Use activation while you work interactively; use the explicit interpreter path in Dockerfiles, CI jobs, cron entries and systemd units, where exporting PATH is one more thing to get wrong.
# Create the environment next to the code, named .venv by convention.
python3 -m venv .venv
# Option A: activate, then use the short commands for this shell session.
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install -r requirements.txt
deactivate
# Option B: no activation - the exact same installs, safe in CI and Docker.
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python -m pytest
# Install your own project too, so `import myapp` works from anywhere.
.venv/bin/python -m pip install -e '.[dev]'
# Always `python -m pip`, never bare `pip`: bare pip is whatever is first on
# PATH, which may belong to a different environment entirely.
.venv/bin/python -m pip --versionpyproject.toml: ranges for consumers
This file is the declaration, not the lock. requires-python is the field that stops someone installing on 3.11 and hitting a syntax error in your source. The optional-dependencies group is what pip install -e '.[dev]' reads, which keeps test tooling out of a production install.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "reporting"
version = "0.3.0"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.27,<0.28", # a range: compatible, not pinned
"sqlalchemy==2.0.36", # exact, because a migration depends on it
]
[project.optional-dependencies]
dev = ["pytest>=8", "mypy", "ruff"]
[project.scripts]
reporting = "reporting.cli:main" # installs a `reporting` commandA lockfile that cannot drift
Hashes turn install into a verification step: a package re-uploaded under the same version number, or a corrupted download, fails the install instead of shipping. Keep the generated file in the repo and regenerate it deliberately, so an upgrade is a reviewable diff rather than something that happens on the next build.
# requirements.txt - generated, committed, never hand-edited
httpx==0.27.2 \
--hash=sha256:3f9b1a1b1b1c0ee1d5f1f4b1f0b4f4b7d2a0f1f9e9d8c7b6a5948372615049ab
httpcore==1.0.5 \
--hash=sha256:9a3a1f0a0b4c2d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e
# Install it exactly, and let pip refuse anything that does not match:
# python -m pip install --require-hashes -r requirements.txt
#
# Transitive packages must be listed too - hash-checking mode requires a hash
# for every requirement, which is why this file is generated by a tool.
Discussion