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 = valuelines 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.2means 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.
[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 = 10002Reading 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.
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.
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.
print("2.10.0" > "2.9.0") # False: text order
print((2, 10, 0) > (2, 9, 0)) # True: number order05Optional 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.
pip install "inventory-tool[excel]" # an extra, for users
uv add --dev pytest # a dependency group, for developers06Lock 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.
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
Add an extra
In the first example, add a new extra
plots = ["matplotlib>=3.8"]under[project.optional-dependencies]and printlist(project["optional-dependencies"]). Predict the list before running: it should now have two names. - 2
Try a new rule
In the second example, add the version
"1.4.10"to the list in the lastforline. 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
Break the TOML
Change
name = "inventory-tool"in the first example toname = inventory-tool(no quotes) and run it. TOML needs quotes around strings, andtomllibreports the line and column of the problem.
Code & diagrams
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.
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}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.
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}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.
import tomllib
bad = """
[project]
name = my-tool
"""
tomllib.loads(bad)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 lackstomllib) 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.4means>=1.4,<2, while~=1.4.2means>=1.4.2,<1.5. - ▸
==pins in a library'sdependenciesare 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
pyproject.tomlis written in TOML, a simple config format ofkey = valuelines grouped under[tables]. Python 3.11 addedtomllibto the standard library to read it (Topic 7.8 covers it). Before this file existed, projects scattered settings oversetup.py,setup.cfg,requirements.txtand tool-specific files. Now one file holds nearly everything, and tools such as uv, pip, Ruff, pytest and mypy all read it. - 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, anddependencies. Dependencies are a list of strings, each a package name with an optional version specifier. - 3
Version specifiers are the rules about which releases are acceptable. (Recall from Topic 6.5:
requests==2.32.3pins one exact version.)==1.4.2is exactly that version.>=1.4means that or newer.<2means below 2.!=1.5.0rules out one bad release. A comma means *and*:>=1.4,<2is "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, so2.10.0is newer than2.9.0even though as text"2.10.0" < "2.9.0". - 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,<2says "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 like1.0.0rc1(a release candidate),1.0.0.post1and1.0.0.dev3. - 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 asuv add --dev pytestwrite to a group. Keeping test and lint tools out of the runtime list means users of your package never install them. - 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 commandinventorycallmain()ininventory/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.lockis that file.
Explain it without notes
What goes into the [project] table of pyproject.toml?
Explain >=1.4,<2, ~=1.4.2 and ==1.4.2.
What is the difference between optional dependencies and dependency groups?
Why do we have both pyproject.toml and a lock file?
Practice
Write the dependencies line for a project that needs requests at least version 2.31 but below 3.
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.
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.tomland 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
pytestcorrectly.Done when you can explain why a lock file exists and which projects should have one.