Every dependency a CLI declares is downloaded by every user, imported (or at least resolved) on every install, and audited by every security team. Yet many features are used by a minority: YAML output for the Kubernetes crowd, Excel export for the finance team, an S3 backend for the people on AWS, a full-screen Textual interface for those who want one. Extras — PEP 508 optional dependency groups — let you ship those features in the same package while users opt in to the dependencies they need: uv tool install "mytool[yaml,s3]". Done well, the base install stays small and fast, and a user who tries a feature without its extra gets a one-line explanation instead of a ModuleNotFoundError. This guide covers declaring extras, installing them, the runtime pattern that makes them pleasant, and testing both sides. It belongs to the packaging topic.
Prerequisites
- A CLI with a
pyproject.toml, as in writing pyproject.toml metadata for a CLI. - uv or another PEP 621-aware tool; Typer or Click for the command examples.
What should be an extra?
Make a dependency optional when it is large or slow to import, has a heavy transitive tree, requires system libraries, or serves a feature most users never touch. Keep it required when nearly every command needs it, or when making it optional would complicate the code more than it saves.
A useful test: look at your import-time profile and your dependency tree (uv tree --depth 1). Packages that dominate either, and serve one or two commands, are the candidates. Reducing CLI dependency weight covers the startup side of the same decision.
The recipe
Declare the extras
[project]
name = "mytool"
dependencies = [
"typer>=0.12",
"httpx>=0.27,<1",
]
[project.optional-dependencies]
yaml = ["pyyaml>=6"]
excel = ["openpyxl>=3.1"]
s3 = ["boto3>=1.34"]
tui = ["textual>=0.80"]
all = ["mytool[yaml,excel,s3,tui]"]
Extra names are lower-case words users will type, so keep them short and descriptive. The all extra refers back to the package itself, which avoids repeating version constraints. With uv, uv add --optional yaml pyyaml adds an entry and re-locks; the lock file covers every extra, and uv sync --extra yaml or --all-extras installs them for development.
Install with extras
uv tool install "mytool[yaml,s3]"
pipx install "mytool[yaml,s3]"
uvx --from "mytool[excel]" mytool export --to report.xlsx
Quote the requirement — square brackets are glob characters in zsh and some other shells, and an unquoted mytool[yaml] fails with "no matches found". Adding an extra to an existing pipx installation needs pipx install --force "mytool[yaml]" or pipx inject mytool pyyaml; with uv, re-running uv tool install with the new extras replaces the environment.
Fail helpfully when an extra is missing
The runtime half matters most. Import optional modules inside the code path that needs them, and convert a missing import into a message that names the extra:
# src/mytool/optional.py
from __future__ import annotations
import importlib
from types import ModuleType
class MissingExtra(RuntimeError):
def __init__(self, feature: str, extra: str, module: str) -> None:
super().__init__(
f"{feature} needs the optional '{extra}' dependencies (module '{module}' is missing).\n"
f"Install them with: uv tool install --force 'mytool[{extra}]'\n"
f" or: pipx install --force 'mytool[{extra}]'"
)
self.extra = extra
def require(module: str, *, extra: str, feature: str) -> ModuleType:
"""Import an optional module or raise MissingExtra with install instructions."""
try:
return importlib.import_module(module)
except ModuleNotFoundError as exc:
if exc.name and exc.name.split(".")[0] == module.split(".")[0]:
raise MissingExtra(feature, extra, module) from None
raise # a different module is missing: a real bug
# src/mytool/cli.py
import json
import sys
import typer
from mytool.optional import MissingExtra, require
app = typer.Typer()
ROWS = [{"name": "web-1", "cpu": 0.42}]
@app.callback()
def main() -> None:
"""My tool."""
@app.command()
def show(fmt: str = typer.Option("json", "--format", help="json or yaml (needs [yaml])")) -> None:
"""Show servers."""
if fmt == "yaml":
yaml = require("yaml", extra="yaml", feature="YAML output")
sys.stdout.write(yaml.safe_dump(ROWS, sort_keys=False))
else:
typer.echo(json.dumps(ROWS))
def run() -> None:
try:
app()
except MissingExtra as exc:
typer.echo(f"error: {exc}", err=True)
raise SystemExit(3)
Two details make this robust. The import happens inside the branch, so users without the extra never pay for it and never see an error unless they ask for the feature. And require() checks exc.name: if PyYAML is installed but one of its own imports fails, that is a genuine bug and should not be disguised as "install the extra". The dedicated exit code (3 here) lets scripts tell "missing optional feature" apart from usage errors and runtime failures; choosing exit codes for CLI tools discusses reserving codes like this.
Advertise extras where users look
Users cannot opt into what they do not know exists. Mention the extra in the help text of every option that needs it (--format yaml above), list extras in the README's installation section, and consider a mytool doctor or mytool version --verbose command that reports which optional features are available:
from importlib.util import find_spec
FEATURES = {"yaml": "yaml", "excel": "openpyxl", "s3": "boto3", "tui": "textual"}
def available_extras() -> dict[str, bool]:
return {extra: find_spec(module) is not None for extra, module in FEATURES.items()}
importlib.util.find_spec checks availability without importing, so the report stays fast even when the heavy packages are installed.
UX considerations
- Name the install command in the error. "pip install pyyaml" is wrong for pipx and uv tool users, who cannot pip-install into the tool's environment; give the extras-based command for the installers you support.
- Never import optional modules at module top level in code every command loads. One stray
import boto3in a shared module turns an optional dependency into a required one, at runtime if not on paper. - Keep the base install useful on its own. If most users end up installing
[all], the split is wrong. - Version extras like everything else. Moving a dependency from required to optional is a breaking change for users who relied on it being present; announce it, and consider a transition release where the feature warns before it fails.
Testing the behaviour
Test both worlds: with the extra installed, the feature works; without it, the message is right. Setting sys.modules[name] = None makes Python raise ModuleNotFoundError for that import, which simulates a missing extra without uninstalling anything:
# tests/test_extras.py
import sys
import pytest
from typer.testing import CliRunner
from mytool import cli
from mytool.optional import MissingExtra, require
def test_yaml_output_with_extra_installed():
pytest.importorskip("yaml")
result = CliRunner().invoke(cli.app, ["show", "--format", "yaml"])
assert result.exit_code == 0
assert "name: web-1" in result.stdout
def test_missing_extra_gives_install_hint(monkeypatch):
monkeypatch.setitem(sys.modules, "yaml", None)
with pytest.raises(MissingExtra) as exc:
require("yaml", extra="yaml", feature="YAML output")
assert "mytool[yaml]" in str(exc.value)
def test_entry_point_exits_3_without_extra(monkeypatch, capsys):
monkeypatch.setitem(sys.modules, "yaml", None)
monkeypatch.setattr(sys, "argv", ["mytool", "show", "--format", "yaml"])
with pytest.raises(SystemExit) as exc:
cli.run()
assert exc.value.code == 3
assert "uv tool install --force 'mytool[yaml]'" in capsys.readouterr().err
In CI, add one job that installs the package without any extras — uv sync --no-dev and then the test suite's "no extras" subset, or the built wheel with no extras — to prove that importing the CLI and running its base commands never touches an optional module. The Python version matrix is a natural place for that extra job.
Conclusion
Extras let one package serve both the minimal and the full-featured user. Declare them in [project.optional-dependencies] with short names and an all convenience, import optional modules only inside the features that need them, convert a missing import into a message that names the right install command, and test the "extra missing" path as deliberately as the feature itself. The base install stays small and fast, and nobody meets a bare ModuleNotFoundError.
Frequently asked questions
Should development tools be extras?
No. Test, lint and docs tools belong in dependency groups ([dependency-groups]), which are not published with the package. Extras are for features end users opt into; see locking and syncing CLI dependencies with uv.
Can an extra add a whole command?
Yes, and it is a clean pattern: register the command always, import its implementation lazily, and let require() explain what is missing. Alternatively, ship the feature as a separate plugin package discovered through entry points, as in discovering plugins with entry points, when it has its own release cadence.
How do I make an extra the default?
You cannot — PEP 508 has no "default extras". If most users need a dependency, make it required. Some projects publish a second, thin package (mytool-full) that depends on mytool[all] for users who prefer one name.
Do extras work with standalone binaries?
A binary bundles whatever was installed when it was built, so build it with the extras you want to ship — or publish two binaries. The runtime require() check still gives a clear message if a feature was left out.
What if two extras need conflicting versions of a package?
Resolvers treat extras as additions to one environment, so mytool[a,b] must have a single compatible version of every shared dependency. Avoid the problem by keeping extras' version ranges broad, and if two features genuinely need incompatible versions, ship one of them as a separate plugin package with its own environment. With uv you can declare conflicts between extras under [tool.uv] so the lock file handles the case explicitly, but users installing both at once will still get a resolution error — so document which combinations are supported.