Choosing Between Poetry Dependency Groups, Extras, and Markers for Environment-Specific Dependencies
Decide where each non-core dependency lives in a Poetry project: groups for internal tooling, extras for user-facing optional features, markers for platform conditions — with a concrete pyproject.toml layout, install commands per environment, and validation steps.
30 Sept 2025, 06:38 UTC

Your pyproject.toml is accumulating pytest, Sphinx, an optional Redis client, and a Windows-only driver — all mixed into one dependency list. CI installs everything on every job, your production image carries test frameworks it never runs, and contributors can't tell which packages are runtime requirements. Poetry gives you three different mechanisms for separating these concerns, and picking the wrong one either leaks internal tooling into user-facing metadata or bloats every install.
The short version: use dependency groups for anything internal (dev tooling, tests, docs), extras for optional features your package's users opt into, and environment markers only for platform-conditional runtime packages. The details below assume Poetry 1.2 or newer — groups did not exist before that, and older versions will fail to parse the group sections.
The decision and its constraints
You're deciding where each non-core dependency lives. The constraints that actually matter:
- Reproducibility: everything must resolve through
poetry.lockso CI and local installs match. - CI speed: a lint job shouldn't install a test database driver; a production build shouldn't install pytest.
- Clear install commands: a new contributor should need one documented command, and a Dockerfile should need one different, smaller one.
- Correct publishing: whatever end users install via pip must get only what the package needs at runtime, plus any optional features they explicitly request.
Comparing the three mechanisms
| Mechanism | Declared in | Visible to pip users? | Best for | Key limitation |
|---|---|---|---|---|
| Dependency groups | [tool.poetry.group.<name>.dependencies] | No — not part of published metadata | Internal tooling: dev, test, docs, lint | Requires Poetry 1.2+; consumers installing the built package never see them |
| Extras | [tool.poetry.extras] referencing optional main dependencies | Yes — installable as package[redis] | Optional runtime features users opt into | No per-environment lock control; adds surface to published metadata |
| Environment markers | Inline on a main dependency, e.g. sys_platform = "win32" | Yes — resolved conditionally | Platform- or interpreter-specific runtime packages | Only conditional on environment, not on user intent |
The dividing line is the audience. Groups are for people working on the project; extras are for people using the package. Markers are neither — they describe where the code runs, not who installs it.
Trade-offs that bite in practice
Groups keep published metadata clean but are invisible outside Poetry. Because groups never appear in the built wheel's metadata, a user running pip install yourpackage gets only main dependencies. That's exactly what you want for pytest and Sphinx, but it means groups are useless for anything a downstream consumer needs.
Extras are user-facing, so treat them as API. Renaming or removing an extra breaks consumers' install commands. They also live in the main dependency list marked as optional, which means the resolver considers them during poetry lock regardless of whether anyone installs them.
Duplication across sections causes real pain. Declaring the same package in main and in a group with different version constraints can produce resolver conflicts or surprising lock changes. If a group needs a package that's already in main, it inherits it — don't restate it.
A concrete layout
A typical pyproject.toml for a library with a test suite, docs, and an optional Redis backend:
[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.31"
redis = { version = "^5.0", optional = true }
pywin32 = { version = "^306", markers = "sys_platform == 'win32'" }
[tool.poetry.extras]
redis = ["redis"]
[tool.poetry.group.test.dependencies]
pytest = "^8.0"
pytest-cov = "^5.0"
[tool.poetry.group.docs.dependencies]
sphinx = "^7.0"
[tool.poetry.group.dev.dependencies]
ruff = "^0.5"
mypy = "^1.10"Note that dev is special: Poetry installs it by default with a bare poetry install. Other groups are opt-in.
The matching install commands, run from the project root with Poetry 1.2+ on PATH:
- New contributor:
poetry install --with test,docs— main plus dev (default), test, and docs. - CI lint job:
poetry install --only dev— skips main entirely if the job only runs ruff and mypy. - Production image build:
poetry install --only main --no-rootin a clean stage;--no-rootskips installing the project itself when you only want dependencies before copying source in.
The risk to watch: --only main in a dirty environment doesn't uninstall what's already there. Run it in a fresh virtual environment or container layer, or the "lean" install silently isn't.
Validating the result
After editing, regenerate the lock file and inspect what each environment actually resolves:
poetry --version # confirm 1.2+ before anything else
poetry lock # regenerate poetry.lock
poetry show --group test # list resolved members of the test group
poetry install --only main --dry-run # resolver plan, no changes--dry-run is the safe check: it reports what would be installed, updated, or removed without touching the environment. For a stronger check, create a throwaway virtual environment, run poetry install --only main, and confirm pip list contains no pytest, Sphinx, or ruff. Finally, build the package (poetry build) and inspect the wheel's METADATA file: it should list main dependencies and the redis extra, and mention nothing from any group. If a group dependency shows up there, it was declared in the wrong section.
Limitations to keep in mind
Groups are a Poetry-native concept; tools that read only standard metadata (or older Poetry versions) won't understand them. If your project must also support contributors using plain pip install -e ., they'll get main dependencies only — document the Poetry requirement. And because group membership affects the lock file, treat changes to group declarations like any dependency change: re-lock, review the diff in poetry.lock, and let CI confirm each install variant still resolves.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.