skip to content

When do PEP 735 dependency groups replace optional-dependencies extras in a project?

level: middleimportance: should knowfreq 28%

answer

  1. Two optional-dependency tables, different audiences
  2. One ships in the wheel, one does not
  3. Dev tooling should not be public metadata
  4. Groups compose with include-group
  5. Groups work without a project table

basics

~20 s

Use extras for optional features your consumers install; use PEP 735 dependency groups for requirements only the repository needs, such as test, lint and docs tooling. Groups are local and never appear in the published distribution's metadata, so they cannot be installed by a consumer.

solid answer

~50 s

Extras — the optional-dependencies table under `[project]` — are **published** metadata: they ship in the wheel, and a consumer opts into one by naming it when they install your distribution. That makes them the wrong home for development tooling, because a `dev` extra advertises your test runner to the world and forces your project to be installable before you can install it. PEP 735 added a top-level `[dependency-groups]` table for exactly this: named lists of requirements that stay in the repository, are never written into the built distribution, and can pull each other in with an `include-group` entry. A group also works in a repository that has no `[project]` table at all — a deployment or scripts repo that is not a distribution. Rule of thumb: if a user of your package could ever want it, it is an extra; otherwise it is a group.

code

python · 17 lines
python
import tomllib

SOURCE = """
[project]
name = "triage-bot"
version = "0.3.0"
dependencies = ["ticket-client>=1.4"]
optional-dependencies = {metrics = ["metrics-exporter>=2.0"]}

[dependency-groups]
test = ["test-runner>=8"]
dev = ["type-checker>=1.9", {include-group = "test"}]
"""

data = tomllib.loads(SOURCE)
print(data["project"]["optional-dependencies"])
print(data["dependency-groups"]["dev"])

go deeper

for a junior

Recognise that pyproject.toml can declare optional requirements in two places, and that the tooling you use to develop the project belongs in a different bucket from features your users install.

for a middle

Explain that extras are published in the distribution's metadata and installable by consumers, while PEP 735 groups stay in the repository, compose with include-group, and work even without a [project] table.

for a senior

Demonstrate the migration judgement: removing a published dev extra is a breaking change for anything installing it, so land the group first and remove the extra separately. Know that groups are still abstract requirements resolved by the lock.

for a principal

Own the framing that extras are a public contract and groups are an internal note, and use it to set an organisation-wide convention so that internal tooling never becomes part of a published compatibility surface.

## Two tables that look alike and are not A `pyproject.toml` can name optional requirements in two places, and the distinction is about **audience**, not syntax. The **optional-dependencies** table under `[project]` defines *extras*. It is PEP 621 metadata, so it is baked into the built distribution: a consumer installs your project and names the extra to pull the feature in. Extras are a public API. Rename one and you break somebody's install line. **Dependency groups** come from PEP 735 and live in a top-level `[dependency-groups]` table. Each key is a group name and each value is a list of requirement strings, plus optionally an `include-group` inline table that pulls another group in wholesale. Groups are *not* part of the distribution's metadata. They are never published, a consumer cannot install one, and they exist purely for people working in the repository. ## Why extras were the wrong tool for dev dependencies Before PEP 735, the community convention was a `dev` or `test` extra, and it had four real problems. **It leaks.** The extra is in the published metadata for everyone to see and install, and it becomes part of your compatibility surface even though it is an internal detail. **It requires the project to be a distribution.** Installing an extra means installing your project, which means your project must be buildable and installable. A repository that is a service, a set of scripts or a monorepo leaf with no distribution to build cannot express its tooling as an extra at all. **It cannot say "not for consumers".** There is no way to mark an extra as private, so tooling that audits published metadata cannot tell a real optional feature from a development bucket. **Composition is awkward.** Making a `dev` extra include a `test` extra requires the project to depend on itself with an extra — a self-referential requirement that works but reads like a trick. Dependency groups answer all four: not published, no project required, obviously internal, and composed with a first-class `include-group`. ## Choosing between them The test is a single question: **could an installer of your distribution ever want this?** - A feature that needs an extra library — an optional metrics exporter, an optional async backend, an optional compression codec — is an **extra**. The consumer opts in. - A test runner, a type checker, a formatter, a docs builder, a benchmark harness, a release script — nobody installing your package wants these. They are **groups**. Applications blur the line less than you would think. An application publishes nothing, so it has no consumers and therefore essentially no legitimate extras; everything optional in an application repository is a group. ## What the tooling does with them All-in-one project managers read `[dependency-groups]` natively and typically install a default group into the project environment on sync, so a fresh checkout comes up with the test tooling already present. pip added support for installing a named group directly in its 25.1 release. Because the table is a standard rather than one tool's invention, moving a repository between managers keeps the groups intact — which is the same portability argument that makes the `[project]` table valuable. One subtlety: a group's contents are still *abstract* requirements, exactly like `dependencies`. Groups describe what the repository needs, not which versions were chosen. The chosen versions live in the lock file, and a manager's lock normally records the groups separately so it can install production dependencies only, or production plus one group, from the same lock. ## Migration in practice Moving an existing project is mechanical: delete the `dev` and `test` extras, add the same requirement lists as groups, and replace the self-referential extra chain with `include-group` entries. The one thing to check is anything outside the repository that installed your dev extra — a CI job, a downstream image, a contributor's script. Because extras are published metadata, removing one is a visible change; because groups are not, adding them is not. That asymmetry is the whole point, and it is worth saying out loud in an interview: extras are a contract with your users, groups are a note to yourself.

  • Can a dependency group be declared in a repository that publishes no distribution at all?
    Yes, and that is one of its main advantages. `[dependency-groups]` is a top-level table that does not depend on `[project]`, so a service repository, a scripts directory or a monorepo leaf with nothing to build can still declare its tooling in the standard place. An extra could never do that, because installing an extra means installing the project.
  • How does one group depend on another, and why not just repeat the requirements?
    A group entry can be an inline table with an `include-group` key naming another group, which pulls that group's contents in. Repeating requirements is how versions drift: two copies of the same list will disagree the first time somebody updates one. Composition also lets you install the narrow group in a fast CI job and the wide one locally.
  • You are removing a long-standing `dev` extra in favour of a group — what breaks?
    Anything outside the repository that installed the extra by name: CI jobs, container builds, downstream tooling, a contributor's shell alias. Extras are published metadata, so removing one is a visible, breaking change for those callers. Grep the organisation for the install line, land the group first, then remove the extra in a separate change.

saying these in an interview costs you the question

  • Says extras and dependency groups are interchangeable
  • Puts the test runner in a published extra by default
  • Thinks a consumer can install a dependency group
  • Believes groups require a project table to exist
  • Pins exact versions in a group instead of the lock file
  • Duplicates requirement lists rather than using include-group

context