When does a dependency belong in pyproject.toml's base `dependencies` versus an extra?
answer
- Everyone pays for the base list
- Ask what a bare install must still do
- Guard the import, name the command
- Extras multiply the configurations you test
- Demotion breaks people, promotion does not
basics
~20 sAnything the package cannot import or run its core path without belongs in base dependencies. Optional integrations, alternative backends and heavyweight accelerators belong in extras, guarded by an import that fails with the exact install command. Development tooling belongs in neither: it belongs in a dependency group.
solid answer
~50 sEvery entry in `[project] dependencies` is imposed on **every** consumer, so it costs install size and cold-start time, adds a version-conflict surface and a supply-chain surface, and becomes a compatibility constraint you must maintain. The test is import-time necessity: if the public API cannot be imported or the core code path cannot run without it, it is a base dependency. If it powers an optional backend, an alternative serializer, a reporting or plotting output, or a speed-up with a slower pure-Python fallback, make it an extra and guard the import at the point of use, raising an error naming `pip install 'pkg[name]'`. Ship an `all` meta-extra for convenience, keep the number of extras small enough to test, and put test, lint and build tooling in `[dependency-groups]` instead — it should never be published at all.
code
python · 21 linesimport tomllib
pyproject = '''
[project]
name = "payroll-import"
version = "2.0.0"
dependencies = ["a-csv-schema-lib>=2"]
[project.optional-dependencies]
fast = ["a-fast-parser>=1.4"]
excel = ["a-spreadsheet-reader"]
all = ["payroll-import[fast,excel]"]
[dependency-groups]
dev = ["a-test-runner", "a-type-checker"]
'''
data = tomllib.loads(pyproject)
print("everyone pays for:", data["project"]["dependencies"])
print("opt-in:", sorted(data["project"]["optional-dependencies"]))
print("never published:", sorted(data["dependency-groups"]))go deeper
Recall the simple rule: if the package cannot be imported or do its main job without it, it is a base dependency; if it only powers an optional feature, it is an extra. Development tools belong in neither.
Explain the mechanics behind the rule — base requirements enter every consumer's resolution and install, extras are opt-in and marker-guarded — and show the guarded import that raises an error naming the exact pip install command.
Demonstrate the judgement: weigh resolution conflicts, image size, supply-chain surface and maintenance against convenience, keep the extra count testable, and know that demoting a base dependency to an extra is a breaking change while promoting one is not.
Own it as an interface commitment. The published set of extras is a support matrix and a long-lived promise; decide how many configurations the team will actually test, where platform facts become markers rather than choices, and what the deprecation path looks like when the boundary moves.
### What a base dependency actually costs A line in `[project] dependencies` is a promise made to everyone who installs your package, and it is charged four times over. **Resolution surface.** Every base requirement participates in every consumer's resolution. A narrow pin of yours can be the reason someone else's install is unsolvable, and you will not hear about it as a bug report — you will hear about it as "we could not adopt your library". **Weight.** Base dependencies inflate the environment for everyone, including consumers who never touch the feature that needed them. That is image size, cold-start time, and build minutes across every deployment of every downstream project. **Supply chain.** Each transitive package is code you have chosen on your users' behalf, and an advisory against any of them becomes an advisory that reaches your users through you. **Maintenance.** Every base dependency is a compatibility matrix entry: a major release of it is now your problem, on your release schedule. ### The test Ask what happens on a bare install with nothing else present. If `import yourpkg` fails, or the code path that the README's first example exercises fails, the requirement is **base**. If instead a specific optional capability fails — a database backend, an alternative serializer, an export or reporting format, a native accelerator — the requirement is an **extra**. A useful second test: would a reasonable consumer be surprised to find this installed? A CSV import library that pulls a full spreadsheet engine into every environment has answered that question wrongly. ### Designing the extras ```toml [project] name = "payroll-import" version = "2.0.0" dependencies = ["a-csv-schema-lib>=2"] [project.optional-dependencies] fast = ["a-fast-parser>=1.4"] excel = ["a-spreadsheet-reader"] all = ["payroll-import[fast,excel]"] [dependency-groups] dev = ["a-test-runner", "a-type-checker"] ``` Four rules make this hold up. **Name extras after capabilities, not packages.** `excel` survives swapping the underlying library; an extra named after a vendor does not. **Guard the import where it is used, not at module top level.** The classic bug is an extras-based design whose package still imports the optional module at import time, so the base install cannot even be imported. Import inside the function or behind `importlib.util.find_spec`, and when it is absent raise an error whose message contains the exact command: `pip install "payroll-import[excel]"`. A user should never have to read your source to learn which extra they were missing. **Provide an `all` meta-extra** so consumers who want everything do not have to track your extra list release by release. It works by self-reference and costs one line. **Keep the count small.** Extras multiply configurations: five independent extras are thirty-two installable combinations, and you cannot test them all. Group related optional dependencies under one capability name, and accept that an extra you do not test is an extra you do not really support. ### What does not belong in either Development tooling. A test runner, a linter, a type checker and a docs builder are not something a consumer opts into, and putting them in an extra publishes them to the index and drags them into your distribution's metadata. Since PEP 735 (2024) they go in the top-level `[dependency-groups]` table, which stays in the source tree and never reaches the wheel. Platform conditionals do not belong in an extra either. "Only needed on Windows" is an environment marker on a **base** requirement (`; sys_platform == "win32"`), not something the user should have to opt into by name — an extra is a *choice*, a marker is a *fact about the target*. ### The one-way door Moving a dependency between the two lists is not symmetric. Promoting an extra's requirement into base is invisible to consumers — they simply get more. Demoting a base requirement into an extra silently breaks anyone whose working install relied on it being present, with no error until the feature is exercised, so it belongs in a major release with a loud entry in the notes. Get the boundary approximately right at 1.0 and you rarely need to move anything; get it wrong and every correction is a breaking change. ### The judgement, stated once Default installs should be boring and small: the fewest requirements that make the documented core work. Everything else is opt-in, discoverable through an error message that tells the user exactly what to type, and tested in the combinations you actually claim to support.
- You need to move an existing base dependency into an extra. What breaks?Everyone whose working install depended on it being present. Nothing errors at install time, and nothing errors at import time if the optional path is only reached later, so the breakage surfaces as a runtime `ImportError` in the field. It is a backwards-incompatible change: ship it in a major version, say so prominently in the notes, and make the failure message name the extra to install. Promotion in the other direction is safe.
- A requirement is needed only on Windows. Extra, or base dependency with a marker?Base dependency with an environment marker: `"a-console-shim; sys_platform == 'win32'"`. An extra models a *choice the user makes*, while the operating system is a *fact about the target* that the installer can evaluate for itself. Making it an extra pushes a platform detail onto every Windows user's install command and guarantees some of them will get it wrong.
- How do you keep the number of extras from getting out of hand?Name them after capabilities rather than packages, so several related requirements collapse into one name, and treat every extra as a configuration you have committed to testing — combinations grow exponentially, so an extra you never install in CI is an extra you do not support. Add an `all` meta-extra for consumers who want everything, and move anything development-only out to a dependency group.
- Where should the test runner and type checker be declared?In the top-level `[dependency-groups]` table, not in an extra. Development tooling is not something a consumer opts into, and an extra would publish it in the distribution's metadata and pull it into your dependency graph. A group stays in the source tree, is never built into the wheel, and does not require the project to be installable to fetch it.
Base dependencies are what comes in the box; extras are what the buyer can order. Anything only the factory floor uses goes in neither — it never leaves the building.
saying these in an interview costs you the question
- Puts every optional integration in base dependencies
- Imports the optional module at package import time
- Declares test and lint tools as a dev extra
- Raises a bare ImportError with no install command
- Uses an extra where an environment marker belongs
- Demotes a base dependency to an extra in a patch release