Skip to content
← All articles

Dependency groups vs extras vs requirements.txt

Using extras for dev dependencies leaks your CI toolchain into published metadata, so every consumer sees `pip install yourlib[dev]` as supported and every tool you listed becomes a compatibility constraint you owe them.

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.