For years there were two places to put “the things I need to develop this project”, and both were wrong.
extras are published metadata. [project.optional-dependencies] with a
dev key means pip install yourlib[dev] is a documented, supported install
of your distribution. Every user can see it, some will use it, and every tool
you listed — your linter, your formatter, your test runner — is now part of
your dependency graph and a constraint on theirs. A pinned ruff==0.11.2 in
your dev extra can conflict with somebody else’s ruff, in a project that
has nothing to do with linting your code.
requirements-dev.txt is unpublished, which fixes that, but it is not
metadata at all. It has no standard, no composition mechanism, and every tool
treats it slightly differently — so a lockfile-aware installer cannot reason
about it, and it drifts out of sync with pyproject.toml.
[dependency-groups]
PEP 735, accepted in October 2024, adds a third table designed for exactly this:
[dependency-groups]
test = ["pytest>=8", "pytest-cov"]
typing = ["mypy>=2.0", "types-requests"]
lint = ["ruff>=0.16"]
docs = ["sphinx", "furo"]
dev = [
{include-group = "test"},
{include-group = "typing"},
{include-group = "lint"},
]
Four properties, all of which matter:
Named and standardised. Every conforming tool reads the same table.
Unpublished. Groups do not appear in the built distribution’s metadata. Nothing you put here becomes a promise to your users or a constraint on their resolution.
Composable. {include-group = "test"} splices another group in.
Diamonds are fine — including the same group twice yields its requirements
once — and the composition is declarative rather than a shell script that
concatenates files.
They do not imply installing the project itself. This is the property
people are most often surprised by, and it is deliberate. pip install --group test installs pytest and pytest-cov; it does not install your
package. That is what makes a group usable in contexts where the project
cannot or should not be installed — a docs build, a lint-only job, a
container stage that only needs the toolchain.
💡You maintain a library. Which of test, typing, docs and postgres belongs in [dependency-groups] and which in [project.optional-dependencies]?
click to reveal
test, typing and docs are dependency groups. postgres is an extra.
The dividing question is: is this a choice the user makes, or a choice the maintainer makes?
pip install yourlib[postgres] is a user saying “I want the PostgreSQL
backend”. It changes what your package can do at runtime, it belongs in
published metadata, and it is documented API. That is an extra, and extras
exist for exactly this.
Nobody installing your library wants your test runner. test describes how
you develop the project — it is not part of what you ship, it should not
appear in your published metadata, and every tool listed there would
otherwise become a version constraint your users have to satisfy for no
benefit.
The smell that tells you it is wrong: an extra whose name is dev, test,
lint or all. Those are maintainer concerns wearing a user-facing label.
Tool support
pip 25.1 added --group: pip install --group test, and
--group path/to/pyproject.toml:test for a group defined elsewhere.
uv syncs the dev group by default, with --group NAME to add one,
--no-group NAME to drop one, and --only-group NAME to install a group
without the project or its runtime dependencies. That last one is what you
want for a lint-only CI job: it installs ruff and nothing else, in about a
second.
A set that works
test pytest, plugins, fixtures libraries
typing mypy, stub packages
lint ruff, and whatever else gates style
docs sphinx or mkdocs and the theme
dev includes all of the above
Small groups compose; a single giant dev does not decompose. The payoff is
in CI: the type-check job installs typing and nothing else and starts in a
second, rather than installing sphinx to run mypy.
💡Your CI has one job that runs uv sync --group dev and then lint, typecheck and tests in sequence. What does splitting it into three jobs with --only-group buy, and what does it cost?
click to reveal
It buys speed and clearer failures. Each job installs a handful of packages instead of thirty, so they start faster and run in parallel; a red build tells you which gate failed from the job name alone; and a broken docs dependency can no longer stop your tests from running.
It costs a little duplication in the workflow file, and — the real cost —
three environments where you had one, so a dependency that only exists in
dev can be silently missing from test and you find out from a failing
job rather than a passing one.
A reasonable middle: split the jobs, keep a dev group that includes them
all for local use, and let the composition guarantee the union stays
consistent. That is what {include-group = ...} is for — the shared set is
written once, not copied three times.