skip to content

Why does pyproject.toml declare dependency ranges while a deployed requirements.txt pins exact versions?

level: juniorimportance: must knowfreq 68%

answer

  1. Two files, two different jobs
  2. One states intent, one states a result
  3. Abstract range versus concrete resolution
  4. Only one version per environment
  5. Libraries range, applications pin

basics

~10 s

pyproject.toml declares abstract dependencies: the ranges a package can work with, re-resolved at install time. A pinned requirements.txt records one concrete resolution so a particular deployment installs exactly the same versions every time.

solid answer

~40 s

They answer different questions. The `[project]` dependencies table in pyproject.toml states **intent**: the distributions this project imports, with acceptable version ranges such as `frame-tools>=2.1,<3`. That metadata ships inside the wheel, so every consumer re-resolves it against whatever else they are installing; a library that pins exactly would force its version on everyone and break co-installation, because an environment holds only one version of a distribution. A pinned requirements.txt is the opposite artefact: a flat, concrete list of the whole resolved closure -- direct **and** transitive -- for one environment, generated by a resolver such as pip-compile or uv rather than hand-written, and consumed with `pip install -r`. Rule of thumb: libraries declare ranges, applications deploy pins. An application still keeps its ranges in pyproject.toml; the pinned file is the compiled output of them.

code

python · 11 lines
python
import tomllib

doc = tomllib.loads('''
[project]
name = "vidmeta"
version = "1.0.0"
dependencies = ["frame-tools>=2.1,<3", "clockdrift~=0.4.2"]
''')

for req in doc["project"]["dependencies"]:
    print(req)

go deeper

for a junior

Recall the one-line split: pyproject.toml says what versions are acceptable, a pinned requirements.txt says what versions were actually chosen. Be able to say which file a library publishes and which one a deployment installs from.

for a middle

Explain the mechanics: published metadata is re-resolved by every consumer, only one version of a distribution exists per environment, and the pinned file is the flattened transitive closure produced by a resolver. Know why upper bounds in a library are contagious.

for a senior

Show the operational side: pins live with the deployable artefact, ranges live with the source, and the pinned file is regenerated in CI so upgrades arrive as a reviewable diff. Be ready to explain what pins still fail to guarantee.

for a principal

Own the policy across a portfolio: which repositories publish ranges, which commit pins, how the two are kept in sync, and the cost of libraries that over-constrain their consumers. Frame it as intent versus resolution rather than as a file-format preference.

### Abstract versus concrete dependencies The cleanest way to hold this is the abstract/concrete split. An **abstract** dependency names a distribution and a set of acceptable versions: *this project needs `frame-tools`, at least 2.1, below 3*. It says nothing about where the artefact comes from or which exact release you will end up with. A **concrete** dependency names one specific artefact -- version, and possibly index and file hash -- and admits no substitution. pyproject.toml is the home of the first; a pinned requirements file is the home of the second. ### What pyproject.toml holds The `[project]` table is standardised metadata (PEP 621). Its `dependencies` list carries requirement specifiers: a distribution name, optional extras, a PEP 440 version specifier and an optional environment marker, for example `clockdrift~=0.4.2; python_version >= "3.11"`. Two properties matter: * It lists **only what this project imports directly**. Transitive dependencies never appear -- they are the business of the packages you depend on. * It is **published**. When a wheel is built, these strings become the `Requires-Dist` metadata that installers read. Every consumer re-resolves them. That second property is why a published library should not pin. Python installs one version of a distribution per environment. If your library requires `frame-tools==2.3.1` and another package in the same environment requires `frame-tools==2.4.0`, there is no resolution -- the user is stuck. Even blanket upper bounds are contagious: they propagate into everyone else's resolution and cause avoidable conflicts. Declare a floor you actually need (`>=2.1`), and add a ceiling only when a specific release has demonstrably broken you. ### What a pinned requirements.txt holds A requirements file is not package metadata at all; it is an argument list for pip. It is flat, it names every distribution that must end up installed -- the transitive closure, not just your direct imports -- and each line is usually pinned with `==`. It may also carry installer options that metadata cannot express: `--index-url`, `--find-links`, and `--hash=sha256:...` entries. Because it is a *resolution*, it should be produced by a resolver and regenerated, never edited by hand. A tool reads the abstract set out of pyproject.toml, resolves it once, and writes the concrete set. Regenerating is how you upgrade; editing one line by hand quietly desynchronises the pins from the graph that produced them. ### The division of labour in practice A published library: ranges in pyproject.toml, no pins in the shipped metadata. It may still keep a lockfile in its repository so its own test runs are deterministic -- that lock is a development artefact and never reaches consumers. An application or service: ranges in pyproject.toml describing intent, plus a committed pinned file (or a lockfile) describing exactly what is deployed. The deployment installs from the pinned artefact; humans read and edit the ranges. ### The classic mistake Reading requirements.txt inside the build backend configuration to populate the published dependency metadata. That inverts the whole design: your CI pins become hard constraints on every consumer of your package, and the library becomes uninstallable next to anything else. Keep the two files independent, and let the pinned file be derived from the abstract one rather than the other way round. ### Where the split stops being enough Exact versions still do not fully determine what lands on disk. The same `frame-tools==2.3.1` may resolve to different wheels on different platforms and interpreter versions, and a plain pinned file records neither the environment markers nor the artefact hashes needed to make that choice verifiable. That gap -- pins are necessary but not sufficient for reproducibility -- is exactly what lockfiles and hash pinning exist to close.

  • What breaks for downstream users if a library pins an exact version in its published dependency metadata?
    An environment holds one version of a distribution at a time, so the pin becomes a hard constraint on everyone. Any other package needing a different version makes the two libraries uninstallable together, and the user cannot override it without patching metadata. Blanket upper bounds cause the same problem more slowly: they propagate into every consumer's resolution and turn a routine upgrade into an unsatisfiable graph.
  • Do transitive dependencies appear in pyproject.toml's dependency list or only in the pinned file?
    Only in the pinned file. The `[project]` dependencies table lists what this project imports directly; each of those distributions declares its own dependencies in its own metadata, and the installer walks that graph. A pinned requirements file is the flattened result of that walk, so it names packages you have never heard of -- which is precisely why it must be generated rather than written by hand.
  • Why should a pinned requirements file be regenerated rather than edited line by line?
    The file is a resolver output: every line is consistent with every other because one resolution produced them together. Bumping a single line by hand can pin a version whose own requirements contradict the rest of the file, and pip will either backtrack unexpectedly or install a set no resolver ever approved. Change the range in pyproject.toml, re-run the compile step, and review the diff.

A recipe that calls for a medium onion travels to any kitchen; the till receipt naming the exact onion you bought reproduces one specific dinner. pyproject.toml is the recipe, the pinned file is the receipt.

saying these in an interview costs you the question

  • Says a published library should pin exact versions to be safe
  • Treats pyproject.toml and requirements.txt as interchangeable
  • Believes pyproject.toml lists transitive dependencies as well
  • Populates published dependency metadata by reading requirements.txt
  • Thinks exact pins alone make an install fully reproducible
  • Hand-edits single lines of a generated pinned file

context