Poetry Dependency Groups vs Extras: Choosing Where Your Dev Tools Live
Decide between Poetry dependency groups and optional extras for dev tooling: a comparison, a concrete pyproject setup, and commands to verify production installs stay clean.
02 Apr 2026, 19:47 UTC

Every Python project accumulates tooling that production never needs: pytest, ruff, mypy, sphinx. In Poetry you have two supported places to put them — dependency groups and optional extras — and picking the wrong one either bloats your production image or confuses the people installing your package. The short answer: use dependency groups for internal tooling, extras for optional runtime features your end users opt into. The rest of this guide explains why, and shows how to verify the split actually holds.
The decision and its constraints
The question is not "where do I list pytest" but "who installs this, and when?" Three constraints drive the choice:
- Reproducibility: production installs should resolve and install only what the application needs at runtime.
- CI behavior: pipelines need to install test tooling on demand without dragging it into deployment artifacts.
- Clarity for consumers: if your project is published as a library, extras become part of its public interface (
pip install yourpkg[feature]).
Everything below assumes Poetry 1.2 or newer, where dependency groups were introduced. Group semantics changed across the 1.x line and again in Poetry 2.x, so confirm behavior against your installed version with poetry --version before relying on specifics.
Groups vs extras at a glance
| Criterion | Dependency groups | Optional extras |
|---|---|---|
| Intended purpose | Environment separation (dev, test, docs) | Optional runtime features for end users |
| Installed by default | Non-optional groups install by default; excludable with flags | Only when explicitly requested ([extra]) |
| Selective install | --only main, --with dev, --without docs | --extras feature or -E |
| Visible to pip consumers | No — groups are not published metadata | Yes — extras ship in package metadata |
| Lock resolution | Resolved into the lock, isolated by group | Always part of the main dependency resolution |
| Best fit | pytest, ruff, mypy, sphinx, tox | Optional backends, e.g. redis or async drivers |
Why groups win for tooling
Dependency groups are first-class environment separators. A group is a named set of dependencies that Poetry resolves into the same lock file but lets you include or exclude at install time. That gives you one lock for the whole project — no drift between dev and CI — while keeping production installs clean.
Extras work differently. An extra marks a main dependency as optional and exposes it under a name in the published package metadata. Because extras live in the main dependency set, they are always part of the lock resolution, and they exist so that a downstream user can write pip install yourpkg[redis]. Using extras to stash pytest means your internal tooling becomes public API surface, and pip-compatible tools that inspect your package will see it. It also blurs the line your future self will trip over: "is this extra a feature or a leftover dev tool?"
The trade-off is real but small: groups require Poetry 1.2+ on every machine that installs the project, and they require explicit activation (--with dev) in CI scripts. Extras are understood by any pip-compatible tool. For internal tooling, that trade is almost always worth it.
Concrete setup
A pyproject.toml that models the split correctly looks like this:
[tool.poetry]
name = "inventory-api"
version = "0.4.0"
description = "Inventory service"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.110"
redis = { version = "^5.0", optional = true }
[tool.poetry.group.dev.dependencies]
pytest = "^8.0"
ruff = "^0.4"
mypy = "^1.9"
[tool.poetry.extras]
redis = ["redis"]Note the two different mechanisms at work: redis is an optional main dependency surfaced through the redis extra, because end users may want it. pytest, ruff, and mypy live in the dev group because no consumer of the package should ever see them.
Run these from the project root, with Poetry installed and permission to create virtual environments:
# Resolve everything into one lock file
poetry lock
# Production-style install: main dependencies only
poetry install --only main
# Developer install: main plus the dev group
poetry install --with dev
# Library consumer wanting the optional feature
poetry install --only main --extras redisThe risk to watch for: poetry install with no flags installs all non-optional groups by default, which in 1.x includes dev. If your Dockerfile just runs poetry install, your image silently contains pytest. Always be explicit in CI and deployment scripts.
Validating the isolation
Do not trust the pyproject file alone — verify what actually lands in the environment. In a clean checkout (or after poetry env remove python):
poetry install --only main
poetry run python -c "import pytest" # expect ModuleNotFoundError
poetry show --tree # inspect which tree shows dev packagesA ModuleNotFoundError on the pytest import is the desired result — it proves the dev group stayed out. Then compare export output, which is what many Docker builds consume:
poetry export -o requirements-main.txt
poetry export --with dev -o requirements-dev.txt
diff requirements-main.txt requirements-dev.txtThe diff should show exactly the dev group's packages and their transitive dependencies, nothing else. If dev packages appear in requirements-main.txt, something is declared in the wrong section.
Limitations and gotchas
- Legacy
dev-dependencies: the old[tool.poetry.dev-dependencies]section still works in 1.x but is deprecated. Mixing it with[tool.poetry.group.dev.dependencies]can produce duplicated or confusing lock entries. Migrate fully to the group syntax. - Version sensitivity: default group behavior and flag semantics shifted between Poetry 1.2, later 1.x releases, and 2.x. Pin the Poetry version in CI (e.g., via
pipx install poetry==1.8.x) so local and pipeline installs agree. - Extras still resolve: even unrequested extras participate in lock resolution, so an optional dependency with a bad version constraint can block
poetry lockfor everyone, not just users of that extra. - Groups are not published: if you build a wheel, group dependencies vanish from the metadata. That is the point — but it means downstream contributors need the repository, not the wheel, to run tests.
If your project is an application (nothing consumes it as a library), you can skip extras entirely and let groups carry all non-runtime dependencies. Reserve extras for genuine optional runtime features, and the boundary stays self-documenting.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.