Python Virtual Environments and Dependencies

Declare dependencies in pyproject.toml, choose sensible version ranges, lock exact versions and keep development tools separate.

Lesson 64 of 107Advanced12 min read

Dependency management is how a Python project records which third-party packages it needs, which versions are acceptable, and exactly which versions were installed, so that anyone can recreate a working environment. It builds on pip and virtual environments, which isolate each project's packages.

The modern standard is a pyproject.toml file at the project root:

TOML
[project]
name = "booking-service"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
    "requests>=2.31,<3",
    "python-dateutil>=2.8",
]

This lesson explains project metadata, version specifiers, the difference between a specification and a lock file, reproducible set-ups, and separating development tools from runtime needs. The examples manage a small booking service for a climbing wall.

Project metadata

pyproject.toml (defined by PEP 518 and PEP 621) describes the project itself: its name, version, supported Python versions, direct dependencies and how to build it. Tools such as pip, build backends, linters and test runners read it, so configuration that used to be spread across setup.py, setup.cfg and tool-specific files can live in one place.

Keep two things separate in your mind:

  • What the project needs: the direct dependencies you import, with acceptable version ranges. This belongs in pyproject.toml (or a short hand-written requirements.in).
  • What an environment contains: every installed package, including dependencies of dependencies, at exact versions. This belongs in a generated lock file.

requirements.txt from pip freeze is a simple lock file. Tools such as pip-tools (requirements.txt from requirements.in), Poetry (poetry.lock), PDM (pdm.lock) and uv (uv.lock) generate richer ones. Each has its own workflow; pick one per project and document it in the README.

Version constraints

Version specifiers say which releases are acceptable:

SpecifierMeaning
requests==2.32.3exactly this version
requests>=2.312.31 or newer, including future major versions
requests>=2.31,<3at least 2.31, below 3.0
requests~=2.31compatible release: >=2.31, ==2.*
requests!=2.30.0anything except a known-broken release

Most libraries follow semantic versioning loosely: major versions (3.0) may break compatibility, minor and patch versions shouldn't. A range like >=2.31,<3 accepts fixes and features while avoiding the next breaking release.

A range is a policy, not a snapshot. requests>=2.31,<3 can install 2.31 today and 2.40 next year. That's what you want for a library, so it works alongside other packages, but an application also needs a lock file so that every deployment uses the same tested versions.

Reproducibility

A project is reproducible when a fresh checkout can be turned into a working environment by following written steps. Record three things:

  1. The interpreter: requires-python in pyproject.toml, and often a .python-version file for tools such as pyenv and uv.
  2. The dependency inputs: pyproject.toml and the lock file, both committed to Git.
  3. The instructions: a short README section.
Terminal
python -m venv .venv
.venv/bin/python -m pip install -r requirements.txt   # exact versions from the lock
.venv/bin/python -m pip install -e .                  # the project itself, editable

Never rely on copying a .venv folder: it contains absolute paths and platform-specific binaries. Continuous integration that builds the environment from scratch on every push is the best proof the instructions are complete. Update dependencies deliberately, regenerate the lock, run the tests, and commit the new lock file on its own so the change is easy to review.

Development dependencies

Tools for testing, linting, type checking and documentation, such as pytest, Ruff, mypy and Sphinx, are needed by developers but not by the running application. Keep them separate so production installs stay small and secure:

TOML
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.10"]

Install with python -m pip install -e ".[dev]". Python packaging also standardised dependency groups (PEP 735) under [dependency-groups], supported by newer tools including uv and recent pip versions. Poetry uses [tool.poetry.group.dev.dependencies], and pip-tools projects often keep a separate requirements-dev.in.

The syntax differs between workflows, which is one more reason to choose a single tool per project and document the exact commands. Whatever the syntax, the principle is the same: runtime dependencies in one list, development tools in another.

Independent practice and a worked check

Task: document a booking project interpreter and dependency workflow, then recreate it in a fresh environment.

TOML
# pyproject.toml
[project]
name = "booking"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.31,<3"]

[project.optional-dependencies]
dev = ["pytest>=8"]
Terminal
python -m venv .venv
.venv/bin/python -m pip install -e ".[dev]"
.venv/bin/python -m pip freeze --exclude-editable > requirements.lock
# later, on a clean checkout:
python -m venv .venv2
.venv2/bin/python -m pip install -r requirements.lock -e .

On Windows, use .venv\Scripts\python.exe. Comparing pip freeze in both environments should show identical versions. For the worked check, a loose version constraint guarantees a permitted version range, not one exact version.

Reference and next practice

The Python Packaging User Guide explains writing pyproject.toml and version specifiers. Continue with Git and Python projects. Practise with the Python exercises and Python quiz.

Virtual Environments and Dependencies Summary

  • Project metadata: pyproject.toml can describe a package and its declared dependencies using the chosen build tool.
  • Version constraints: A dependency specification defines accepted versions while a lock records a particular resolution.
  • Reproducibility: Record the interpreter, dependency inputs and installation instructions.
  • Development dependencies: Test and analysis tools can be separated from application runtime requirements.

Virtual Environments and Dependencies Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Virtual Environments and Dependencies in a small program

This checks what the current environment provides. The output depends on your set-up: it prints pytest's version if it's installed, otherwise the "not installed" message, followed by the Python major version (3).

Python
import sys
from importlib.metadata import version, PackageNotFoundError
try:
    print(version("pytest"))
except PackageNotFoundError:
    print("pytest is not installed in this environment")
print(sys.version_info.major)
Line-by-line explanation
  1. Line 2importlib.metadata reads installed package information without importing the package.
  2. Lines 3 to 6A missing package raises PackageNotFoundError, which is reported instead of crashing.
  3. Line 7sys.version_info.major confirms the interpreter's major version.
Example 2

Checking an environment against requirements

  • Real projects use packaging.version.Version for full version comparisons; this example keeps to the standard library.
Python
from importlib.metadata import version, PackageNotFoundError

requirements = {"pip": (20, 0), "surely-missing-pkg": (1, 0)}

def major_minor(text):
    parts = text.split(".")
    return int(parts[0]), int(parts[1]) if len(parts) > 1 else 0

for name, minimum in requirements.items():
    try:
        installed = major_minor(version(name))
    except PackageNotFoundError:
        print(f"{name}: MISSING (need >= {minimum[0]}.{minimum[1]})")
        continue
    status = "ok" if installed >= minimum else "TOO OLD"
    print(f"{name}: {status}")
Output
pip: ok
surely-missing-pkg: MISSING (need >= 1.0)
Line-by-line explanation
  1. Line 3A tiny stand-in for a requirements file: package names with minimum versions.
  2. Lines 5 to 7Comparing (major, minor) tuples orders versions numerically, so 2.10 counts as newer than 2.9.

Common Virtual Environments and Dependencies Mistakes

Treating a loose range as a reproducible environment

Don't
# requirements.txt
requests
flask>=2
Do
# pyproject.toml declares ranges; a generated lock pins what was tested:
# requests==2.32.3
# flask==3.0.3
# (plus every transitive dependency at an exact version)

Unpinned requirements can install different versions on different days. Keep ranges for intent and a lock for reproducibility.

Mixing development tools into runtime dependencies

Don't
dependencies = ["requests>=2.31", "pytest>=8", "ruff>=0.6"]
Do
dependencies = ["requests>=2.31"]

[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.6"]

Virtual Environments and Dependencies in Real Projects

Dependency workflows in practice

Teams commit pyproject.toml and a lock file, rebuild environments in continuous integration, and use bots such as Dependabot or Renovate to open pull requests when new versions appear, so updates are tested before they're merged. Security scanners such as pip-audit check locked versions against known vulnerabilities.

uv has quickly become popular for its speed: uv add requests updates pyproject.toml and uv.lock, and uv sync makes the environment match the lock exactly. Poetry and PDM offer similar all-in-one workflows, while pip with pip-tools remains common in projects that prefer minimal tooling.

Virtual Environments and Dependencies FAQ

Should I use requirements.txt or pyproject.toml?

Use pyproject.toml to declare your project and its direct dependencies; it's the current standard. A requirements.txt generated by pip freeze or pip-tools still works well as a lock file of exact versions for deploying an application.

What is a lock file?

A generated file listing every package in a working environment, including indirect dependencies, at exact versions, often with hashes. Installing from it reproduces the tested environment precisely. Examples include poetry.lock, uv.lock and a pinned requirements.txt.

What does ~= mean in Python requirements?

It's the "compatible release" operator. ~=2.31 means >=2.31 and ==2.*, so it accepts 2.32 or 2.40 but not 3.0. ~=2.31.1 means >=2.31.1 and ==2.31.*.

Should libraries pin exact versions?

No. Libraries should declare ranges that they support, so they can be installed alongside other packages. Exact pins belong in applications' lock files, where the goal is a single reproducible environment.

Is Poetry or uv better than pip?

They solve the same problem with different trade-offs. pip with a requirements file is simple and universal; Poetry, PDM and uv add automatic locking, dependency groups and environment management, and uv is particularly fast. Any of them is fine if the whole team uses the same one.

Virtual Environments and Dependencies Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choiceeasy

What does a loose version constraint guarantee?

Multiple choicemedium

Which review concern is valid for virtual environments and dependencies?

Virtual Environments and Dependencies Quiz

2 questions to check what you learned.

  1. What does a loose version constraint guarantee?

    • One exact resolved environment
    • A permitted version range
    • Python ignores the distinction
  2. Which statement correctly describes reproducibility?

    • Record the interpreter, dependency inputs and installation instructions.
    • Only the example output matters, not the rule
    • Every operation has the same behavior