Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate ~28 min· topic 7 of 9

Topic 6.7

pyproject.toml and Dependencies

In one line

pyproject.toml is the one standard file that describes a Python project: its name, version, supported Python, dependencies (with version rules), optional extras, development-only groups, commands and tool settings.

Think of it like this

The fridge shopping list versus the receipt. The list on the fridge says "milk, any brand, about 1 litre; rice, 5 kg or more". That is what you *want*. The receipt from the shop says "Brand X milk 1 L, Brand Y rice 5 kg, 4 July". That is what you *got*. pyproject.toml is the list; the lock file is the receipt. You need both: the list explains your intent, the receipt lets anyone get exactly the same shopping again.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

TOML
A simple configuration file format: key = value lines under [table] headings.
Table
A named group in a TOML file, written [project] or [tool.ruff].
Version specifier
A rule about which versions are acceptable, such as >=1.4,<2.
Semantic versioning
The convention MAJOR.MINOR.PATCH, where a major change may break users.
Extras (optional dependencies)
Packages that a feature needs, installed only when the user asks for them.
Dependency group
A named list of packages for developing the project (tests, docs). Not installed by users of the package.
Lock file
A generated file that pins every package, direct and indirect, to an exact version with hashes.
Entry point (script)
A command created when the package is installed, which calls a function in your code.
Compatible release
The ~= operator: ~=1.4.2 means at least 1.4.2 but below 1.5.
Direct and transitive dependency
Direct: a package you list yourself. Transitive (indirect): a package that one of your packages needs, which you did not list.
Resolver
The part of pip or uv that picks one version of every package so that all the rules hold together.

Step by step

01The anatomy of a pyproject.toml

Read this file from the top. [project] is the identity. dependencies is a list of strings. [project.optional-dependencies] and [dependency-groups] hold packages that are not needed by every user. [project.scripts] makes a command. [tool.ruff] configures a tool.

Each dotted name like project.scripts is a table nested inside another.

pyproject.tomlwhole filetoml
[project]
name = "inventory-tool"
version = "0.3.0"
description = "Track stock from the command line"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
    "httpx>=0.27,<1",
    "pydantic>=2",
]

[project.optional-dependencies]
excel = ["openpyxl>=3.1"]

[project.scripts]
inventory = "inventory.cli:main"

[dependency-groups]
dev = ["pytest>=8", "ruff"]
docs = ["mkdocs"]

[tool.ruff]
line-length = 100

02Reading it from Python

Because TOML is standard, any program can read it. tomllib.loads(text) returns ordinary dictionaries and lists. tomllib.load(file) reads from a file opened in binary mode ("rb", which means "read the raw bytes"). with open(...) as f: opens the file and closes it again when the block ends (Topic 7.4). Tools do exactly this to find your settings.

main.pywhole filepython
import tomllib

with open("pyproject.toml", "rb") as f:
    data = tomllib.load(f)

print(data["project"]["name"])
print(data["project"]["dependencies"])

03Version specifiers in practice

A specifier combines one or more rules joined by commas. Reading them aloud helps: httpx>=0.27,<1 is "httpx, version 0.27 or later, below 1".

Setting an upper bound (<2) protects you from a future breaking change. Setting no lower bound lets the resolver pick a very old, possibly insecure release. Libraries should be generous so their users can combine them with other packages; applications should lock exact versions.

pyproject.tomlwhole filetoml
dependencies = [
    "requests==2.32.3",     # exactly this version
    "numpy>=1.26,<3",       # from 1.26 up to, not including, 3
    "rich~=13.7",           # >=13.7, <14
    "pandas",               # any version (avoid in real projects)
]

04Why versions must be compared as numbers

Version strings are *not* text. "2.10.0" is newer than "2.9.0", but alphabetical comparison puts "2.10.0" first because "1" comes before "9". Tools split a version into numbers and compare part by part. The packaging library (which pip and uv build on) implements all of PEP 440 for you, including release candidates.

The tuple (2, 10, 0) compares as (2, 9, 0) should: element by element, as numbers.

main.pywhole filepython
print("2.10.0" > "2.9.0")                 # False: text order
print((2, 10, 0) > (2, 9, 0))             # True: number order

05Optional dependencies and dependency groups

Extras change what *users* get: pip install "inventory-tool[excel]" also installs openpyxl. Groups change what *developers* get: uv sync installs the dev group by default in a uv project. Users of your published package never see the groups.

This is why tools like pytest and ruff do not belong in dependencies.

terminalwhole filebash
pip install "inventory-tool[excel]"     # an extra, for users
uv add --dev pytest                     # a dependency group, for developers

06Lock files: from ranges to exact versions

The ranges in pyproject.toml allow many valid sets of versions. A lock file picks one set and writes it down, with a hash for each file. Installing from the lock gives every machine the identical environment; updating is a deliberate action (uv lock --upgrade).

Lock applications (things you deploy). Libraries are normally published without a lock, because their users make the choices; the library keeps its own lock only for its own development.

Lock files: from ranges to exact versionsdiagram
Rendering diagram…

07requires-python and the minimum you can promise

requires-python = ">=3.11" states which Python versions your project supports. Installers refuse to install it on an older Python. Choose the oldest version you actually test on in CI (Topic 11.8). If your code uses tomllib or ExceptionGroup, 3.11 is the floor; the match statement needs 3.10; list[int] as a type needs 3.9.

Try it yourself

  1. 1

    Add an extra

    In the first example, add a new extra plots = ["matplotlib>=3.8"] under [project.optional-dependencies] and print list(project["optional-dependencies"]). Predict the list before running: it should now have two names.

  2. 2

    Try a new rule

    In the second example, add the version "1.4.10" to the list in the last for line. Does it satisfy ~=1.4.2? Predict, then run. Then add "1.10.0" and check it against >=1.4,<2. Think about why comparing the versions as text would get it wrong.

  3. 3

    Break the TOML

    Change name = "inventory-tool" in the first example to name = inventory-tool (no quotes) and run it. TOML needs quotes around strings, and tomllib reports the line and column of the problem.

Code & diagrams

Parsing a pyproject.toml with tomllib New tab

The text is the same file as above. Notice that tables become nested dictionaries, lists stay lists, and the project's dependency groups can be counted.

Sign in to run this example in your browser.

Expected output

name and version: inventory-tool 0.3.0
runtime dependencies: ['httpx>=0.27,<1', 'pydantic>=2']
extras: ['excel']
command: {'inventory': 'inventory.cli:main'}
dependency groups: {'dev': 2, 'docs': 1}
top-level tables: ['project', 'dependency-groups', 'tool']
tool settings: {'line-length': 100}
Version specifiers, checked by hand New tab

A small model of what an installer does when it tests a version against a rule. The function `allowed` calls itself once for each rule in a comma list (that is recursion, Topic 5.8). Real tools use the `packaging` library, which handles every PEP 440 form. Check how `~=1.4.2` accepts 1.4.9 but rejects 1.5.0.

Sign in to run this example in your browser.

Expected output

2.10.0 > 2.9.0 as numbers: True
2.10.0 > 2.9.0 as text:    False

1.4.1 {'>=1.4,<2': True, '~=1.4.2': False, '==1.4.2': False}
1.4.2 {'>=1.4,<2': True, '~=1.4.2': True, '==1.4.2': True}
1.4.9 {'>=1.4,<2': True, '~=1.4.2': True, '==1.4.2': False}
1.5.0 {'>=1.4,<2': True, '~=1.4.2': False, '==1.4.2': False}
2.0.0 {'>=1.4,<2': False, '~=1.4.2': False, '==1.4.2': False}
Where each kind of dependency livesdiagram
Rendering diagram…

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget the quotes around a string

Write name = my-tool instead of name = "my-tool" and parse the text with tomllib.

main.pywhole filepython
import tomllib

bad = """
[project]
name = my-tool
"""
tomllib.loads(bad)
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
...
tomllib.TOMLDecodeError: Invalid value (at line 3, column 8)

Myth vs fact

Myth

pyproject.toml is only needed for packages I plan to publish.

Fact

Any project benefits: it records the Python version and dependencies, and tools (uv, Ruff, pytest) read their settings from it.

Myth

Semantic versioning means a minor update can never break my code.

Fact

It is a promise by the author, and authors sometimes break it. A lock file and tests are your protection.

Myth

requirements.txt is dead.

Fact

It is still common, especially in older projects and Docker builds. pyproject.toml plus a lock file is the modern way, and both can coexist.

Myth

A tighter version range is always safer.

Fact

Over-tight ranges in a library make it impossible to install alongside other packages. Pin tightly in applications (via the lock), loosely in libraries.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The standards behind this file are public: PEP 517/518 (build systems), PEP 621 ([project]), PEP 440 (versions), PEP 735 (dependency groups). Because they are standards, pip, uv, Poetry, Hatch and PDM can all read the same [project] table; only the lock file format is tool-specific.

  • ▸

    A dependency string can carry an environment marker (PEP 508) after a semicolon, such as "tomli>=2; python_version < '3.11'": install this only where the condition holds. That is how one project supports an old Python (which lacks tomllib) without forcing the extra package on newer ones. Also know that resolvers skip pre-releases (1.0.0rc1) unless you ask for them or the only matching version is one, and that ~= needs at least two version parts: ~=1.4 means >=1.4,<2, while ~=1.4.2 means >=1.4.2,<1.5.

  • ▸

    == pins in a library's dependencies are a classic mistake: they make the library impossible to install next to anything that needs a different version. Put exact versions in the application's lock, not in a library's metadata. The same applies to over-restrictive upper bounds.

  • ▸

    Dependency updates are routine work. Bots such as Dependabot and Renovate open pull requests for new versions, and your tests decide whether to merge. A lock file that is never updated slowly collects known vulnerabilities, so schedule updates (Topic 6.9, Topic 11.8).

  • ▸

    Direct versus transitive (see the words list): you list only what you import; the resolver adds the rest. If your code imports a package, list it yourself rather than relying on another dependency to bring it in, because that dependency may drop it one day.

Remember this

  1. 1

    pyproject.toml is written in TOML, a simple config format of key = value lines grouped under [tables]. Python 3.11 added tomllib to the standard library to read it (Topic 7.8 covers it). Before this file existed, projects scattered settings over setup.py, setup.cfg, requirements.txt and tool-specific files. Now one file holds nearly everything, and tools such as uv, pip, Ruff, pytest and mypy all read it.

  2. 2

    The [project] table (PEP 621) describes the project: name, version, description, readme, requires-python (for example ">=3.11", the Python versions it supports), license, authors, and dependencies. Dependencies are a list of strings, each a package name with an optional version specifier.

  3. 3

    Version specifiers are the rules about which releases are acceptable. (Recall from Topic 6.5: requests==2.32.3 pins one exact version.) ==1.4.2 is exactly that version. >=1.4 means that or newer. <2 means below 2. !=1.5.0 rules out one bad release. A comma means *and*: >=1.4,<2 is "1.4 or newer, but not 2". ~=1.4.2, the compatible release operator, means >=1.4.2, <1.5. Versions compare as numbers part by part, so 2.10.0 is newer than 2.9.0 even though as text "2.10.0" < "2.9.0".

  4. 4

    Semantic versioning (semver) is a convention: MAJOR.MINOR.PATCH. A patch release (1.4.2 to 1.4.3) fixes bugs, a minor release (1.4 to 1.5) adds features without breaking users, a major release (1 to 2) may break things. Many packages follow it, so >=1.4,<2 says "anything that should still work for me". But it is a promise, not a guarantee, and some projects (calendar versions like 2025.10) do not use it. Python's own rules are PEP 440, which also allows forms like 1.0.0rc1 (a release candidate), 1.0.0.post1 and 1.0.0.dev3.

  5. 5

    Not every package belongs in dependencies. Optional dependencies (also called extras) are features users can opt into: pip install "inventory-tool[excel]". They live in [project.optional-dependencies]. Dependency groups (PEP 735, [dependency-groups]) are for people working on the project and are not published with it: dev, test, docs. Tools such as uv add --dev pytest write to a group. Keeping test and lint tools out of the runtime list means users of your package never install them.

  6. 6

    The same file configures tools: [tool.ruff], [tool.pytest.ini_options], [tool.mypy]. [project.scripts] creates command-line commands when the project is installed (inventory = "inventory.cli:main" makes the command inventory call main() in inventory/cli.py). [build-system] names the tool that builds the package (Topic 6.9). And beside this file sits the lock file, generated by the tool: it pins every direct dependency (one you list) and every indirect one (one your packages need) to one exact version, with hashes (fingerprints of the files, Topic 6.9). Builds then do not change when new releases appear. Recall from Topic 6.6: uv.lock is that file.

Explain it without notes

01

What goes into the [project] table of pyproject.toml?

02

Explain >=1.4,<2, ~=1.4.2 and ==1.4.2.

03

What is the difference between optional dependencies and dependency groups?

04

Why do we have both pyproject.toml and a lock file?

Practice

01

Write the dependencies line for a project that needs requests at least version 2.31 but below 3.

02

Write a function newest(versions) that takes a list of version strings such as ["2.9.0", "2.10.0", "2.1.5"] and returns the highest one as a string. Compare the parts as numbers, not as text.

03

Make a string that holds a tiny TOML file with a [project] table and the line name = "demo". Parse it with tomllib.loads and print the name.

Trade-offs

  • ↔

    Wide ranges vs tight pins. Wide ranges are friendly to libraries and get fixes automatically, but behaviour can change under you. Tight pins give repeatability but mean you must update on purpose. A common compromise: ranges in pyproject.toml, exact versions in the lock file.

  • ↔

    Putting tools in a dependency group vs in the runtime list. Groups keep production images small and the published metadata honest, at the cost of one more concept. Mixing them is simpler on day one and leaks test tools into every user's environment.

Done when you can

  • Done when you can read a pyproject.toml and say what each table is for.

  • Done when you can write version specifiers with ==, >=, <, ~= and a comma.

  • Done when you can explain why versions must be compared as numbers.

  • Done when you can tell extras from dependency groups and place pytest correctly.

  • Done when you can explain why a lock file exists and which projects should have one.