Skip to content

Towards closing the gap between declaring and verifying Python dependencies

This is a repost of my original article in Siemens' blog with some formatting enhancements.

Choosing dependency declarations by the environment they constrain and pairing each with the evidence that supports it makes the support claims of Python projects deliberate and substantiated.

Python projects in the wild declare their dependencies in widely varied ways, from requirements without version specifiers through lower bounds and lower and upper bounds to pins. The variation invites two questions that often go unanswered: how were these declarations chosen, and how are they verified?

Dependency declarations are claims about package versions: an admitted version is claimed to be supported, and an excluded version is claimed to be incompatible. Each claim requires a matching verification. In practice, however, declarations often follow tool defaults and habit rather than deliberate choice, and verification does not always match their claims, which can compromise a project's downstream utility.

Appropriate declarations depend on whether the environment they constrain is isolated or other packages are co-installed in it: libraries should restrict versions no more than evidence demands, whereas applications and development dependencies should claim no more than evidence supports. This article derives a declaration policy for both cases and pairs each declaration with the evidence that can substantiate its claim, narrowing the gap between declaration and verification.

Primer

Let's assemble the necessary technical background first.

Version specifiers

A requirement such as pydantic>=2.11 consists of a distribution name, which identifies the package, the dependency, and a version specifier, which restricts the admissible versions, as defined by the dependency specifier and version specifier specifications. A specifier may combine several comma-separated clauses, all of which must hold:

Requirement Admissible versions Term
pydantic Any version No version specifier
pydantic>=2.11 2.11 and later Lower bound
pydantic>=2.11,<3 2.11 and later, excluding 3.0 and later Lower and upper bounds
pydantic>=2.11,!=2.11.4 2.11 and later, excluding 2.11.4 Lower bound and exclusion
pydantic==2.11.7 2.11.7 Pin
Version specifier examples with their admissible versions

The table is limited to final releases; other factors, such as pre-releases, post-releases, environment markers, Python compatibility, and the availability of suitable artifacts, further influence which versions qualify.

Dependency declarations

A project records its requirements as declarations in three sections of the pyproject.toml file, which differ in whether they are published with the package and, accordingly, in the environments they constrain:

Location Published Applies to
[project].dependencies Yes, as Requires-Dist metadata Installations of the package
[project].optional-dependencies Yes, as Requires-Dist metadata with an extra marker Installations requesting the extra
[dependency-groups] No The project's own environment

Dependency declaration locations in pyproject.toml

Since resolvers consult only the published metadata, runtime dependencies constrain the environments of consumers, that is, of those who install the package, whereas dependency groups, which hold the development dependencies, constrain only the project's own environment.

Resolution and lock files

Dependency resolution selects, for a target environment, one version of each package that satisfies all applicable requirements. The project declares its direct dependencies, whereas every other package in the resolution is a transitive dependency; together, they form the project's dependency graph, and all of them contribute requirements. Conversely, a package that requires another is a dependent of the latter. Because an environment contains only a single version of each package, co-installed packages must be composable: their requirements must admit a common version of every common dependency. Two libraries that depend on the same package can consequently be co-installed only if their requirements on it overlap.

A lock file records the result of resolution. uv's uv.lock file contains the versions, sources, and artifacts of all direct and transitive dependencies, resolved universally for all environments that the project supports. The declaration thus determines which versions may be installed, while the lock file records which versions were resolved. Since uv prefers locked versions, these persist until an explicit upgrade or a change of requirements. The lock file thus makes environments reproducible and changes to their versions reviewable. This recorded resolution is not part of the published package metadata, so consumers resolve anew.

Declaration policy

A declaration policy prescribes which version specifiers a project declares for each kind of dependency. In its absence, declarations tend to follow tool defaults, such as uv's default lower bound on the currently resolved version, thus making incidental claims. A policy replaces these with deliberate ones, which renders declarations consistent and reviewable against an explicit rationale.

The policy distinguishes declarations by whether the environment they constrain is shared or isolated. An environment is isolated if it contains only packages of the project's own dependency graph, and shared if arbitrary other packages are co-installed in it. The following flowchart summarizes the resulting policy:

Declaration policy ...
Declaration policy by the environment that a declaration constrains

This section derives the declarations for library, application, and development dependencies. The rules for runtime dependencies apply to required and optional dependencies (extras) alike.

Library dependencies

A library is composed with other packages in shared consumer environments, which calls for permissive compatibility ranges. We derive the following four rules for its dependency declarations:

  1. The lower bound is the lowest supported version of the dependency.
  2. No upper bound anticipates incompatible future releases.
  3. Releases with a demonstrated incompatibility are excluded individually or, as a temporary measure until compatibility has been restored, by an upper bound.
  4. Pins are reserved for the rare attributed dependency, whose API or behavior consumers attribute to the library.

The lower bound should be the technical minimum, that is, the lowest version of the dependency whose interfaces and behavior satisfy the implementation's requirements in every supported environment, e.g. on every supported Python version. To illustrate, consider a library whose implementation calls Pydantic's BaseModel.model_validate with the by_alias and by_name parameters, which were introduced in version 2.11. The implementation thus determines a lower bound of 2.11, which we declare explicitly via uv add "pydantic>=2.11", resulting in the following declaration:

pyproject.toml
[project]
# ...
dependencies = ["pydantic>=2.11"]

Deviating from this bound in either direction would misstate the compatibility range, as two plausible alternatives exemplify:

  • pydantic>=2.0 understates it, since it admits releases whose model_validate method does not accept these parameters. More generally, an understated lower bound may cause a library to fail at import time, fail during execution, or silently misbehave.
  • pydantic>=2.11.7 overstates it, since it excludes the compatible releases 2.11.0 to 2.11.6 and can thereby conflict with other requirements that do not overlap with >=2.11.7, e.g. a pin to an older version.

Upper bounds are customary in other ecosystems, such as Node.js, but ecosystem practices do not necessarily transfer. A Python environment results from a flat dependency resolution, which selects a single version of each package that satisfies the requirements of all dependents, whereas Node.js supports nested resolution, which satisfies the requirements of each dependent separately and may thus select distinct versions of the same package. In a flat resolution, an upper bound therefore incurs downstream costs:

  • Composition: The library cannot be composed with other packages that require a version above the bound, even if it would work unchanged.
  • Backtracking: A resolver may fall back to an older library version that lacks the bound, thereby forgoing later fixes without gaining actual compatibility.
  • Propagation: Consumers inherit the bound, whether they depend on the library directly or transitively, and cannot lift it through their own requirements.1

A prospective upper bound, that is, an exclusion of future releases, typically rests on the assumption that the dependency follows Semantic Versioning (SemVer), which permits incompatible API changes only in major releases. Importantly, SemVer encodes the maintainers' intent about API changes rather than verified compatibility with any particular dependent: a major release may break only functionality that a dependent never uses, whereas a minor or patch release may alter behavior on which it relies. A prospective upper bound thus claims incompatibility without evidence, whereas admitting future releases claims support without evidence. The two are not symmetric: the former is an unsubstantiated restriction whose costs consumers bear, whereas the latter is an unverifiable permission without consumer costs, since evidence about unpublished releases cannot exist. Refreshing the lock file detects breakage soon after a release. Libraries should therefore always admit future releases.

Upper bounds and exclusions are justified only where they rest on evidence rather than prediction, that is, against existing releases that demonstrably break the library. An upper bound should be merely temporary and removed once the library either extends support to the incompatible release line, retaining the lower bound, or migrates to it, raising the lower bound.

Pins are warranted only for attributed dependencies, where a dependency's API or behavior becomes part of the library's own, so that consumers attribute it to the library rather than to the dependency; the library version must therefore determine the dependency version. This typically concerns a tightly coupled component that was factored out of the library and could equally have been part of it; Pydantic v2 pins pydantic-core, for instance. A pin risks unsatisfiable resolution whenever a consumer or co-installed package requires a different version of the dependency. Pins suit dependencies that few others require and are hazardous for widely used ones; whether to pin weighs attribution against the likelihood of such a conflict, and the result is rarely a pin.2

Application dependencies

An application, such as a web service, a command-line program, or a data pipeline, is the root of its dependency graph and runs in an isolated environment. Without dependents, it determines the resolution, so each application revision needs to support only the locked dependency graph that its maintainers verify and deploy. A non-pinned requirement would claim support for further versions that are never installed, a claim that is typically unsubstantiated, whereas a pin entails verifying only the version actually installed. For the direct dependencies of applications, we therefore recommend pins:

pyproject.toml
[project]
# ...
dependencies = ["pydantic==2.11.7", "pydantic-settings==2.10.1"]

Pins add neither reproducibility nor reviewability, since the lock file already records the resolved versions and its diff reveals every change. Instead, their benefit lies in explicitness: every change to a direct dependency surfaces in the declarations, including upgrades induced by other direct dependencies. When a new release of one direct dependency requires a newer version of another, e.g. a pydantic-settings release that requires a newer Pydantic version, a non-pinned requirement may admit the second upgrade silently, so that an update of the first can also include the second, visible only in the lock file diff. A pin, in contrast, causes resolution to fail until both pins have been adjusted. This manual adjustment constitutes the cost of pinning but also its purpose, as it turns the induced upgrade into an explicit decision.

uv's add-bounds setting3 controls the version specifier that uv records for newly added dependencies, which allows making pins the default:

pyproject.toml
[tool.uv]
add-bounds = "exact"

With this configuration, uv add pydantic records pydantic==<version> instead of a lower bound. The setting applies to runtime dependencies and dependency groups alike. For applications, pinning the highest admissible version that uv resolves is appropriate, since applications should stay up to date.

A standalone plugin host, installed via uv tool install <host> --with <plugin>, may seem to call for compatibility ranges instead, since plugins are co-installed in its environment, which is therefore shared. We nevertheless recommend pinning the host's direct dependencies, because the host is the root of this environment and plugins are installed into it, not the reverse. The pins give plugins a reviewed baseline to target, and the host verifies exactly the dependency graph it ships. The cost is that a plugin whose requirements contradict the pins cannot be co-installed until the host updates, so keeping the pins current reduces conflicts. Plugins, in turn, are libraries, so their ranges should admit the pinned versions of every host release they support.

Development dependencies

Development dependencies are tools such as formatters, linters, type checkers, and test runners, which run in the project's own isolated environment, controlled by its maintainers, regardless of whether the project is a library or an application. As with applications, each project revision needs to support only the locked tool versions, so we recommend pins for development dependencies:

pyproject.toml
[dependency-groups]
dev = ["mypy==1.16.1", "pytest==8.4.1", "ruff==0.12.1"]

Pins offer two benefits. First, a tool update can change the tool's verdict on unchanged code, e.g. through new rules, stricter checks, or altered defaults. Pins make such updates explicit, including those induced by other tools, such as a plugin release that requires a newer version of its host.4 Second, since uv resolves dependency groups together with the runtime dependencies, pins hold the tool versions constant while alternative resolutions vary the remaining dependency versions, so that a check failing under an alternative resolution points to those versions rather than to a tool.

Since libraries ordinarily declare lower bounds for their runtime dependencies, they should pin tools added with uv ad hoc, e.g. uv add --dev --bounds exact mypy, instead of setting add-bounds = "exact" globally.3

Verification

A declaration claims support for every version it admits, whereas a check substantiates this claim only for the versions installed in the environment where it runs. The resolution determines these versions: the default highest resolution, for instance, selects the highest admissible version, thus leaving the lower bound unverified. Verifying a claim therefore requires checks against a resolution that selects the claimed versions. The following table pairs each declaration with its claim and the evidence that substantiates it:

Declaration Claim Evidence Remaining gap
Lower bound (>=) The bound is the lowest supported version. Lower-bound sample Masked lower bounds
No upper bound Admitted versions are supported, including future releases. Highest resolution, refreshed Unpublished releases
Exclusion (!=), upper bound (<) The excluded versions are incompatible. A reproducible test or report, periodically reassessed Stale evidence5
Pin (==) The pinned version is supported. Highest resolution Transitive dependencies
Wide compatibility range Every admitted version is supported. Sampled resolutions Versions between samples
Evidence that substantiates the claim of each declaration and the gap that remains

A check substantiates its claim only for the code paths it exercises. Since these paths and the installed dependencies may differ between target environments, we run the checks in each of them, e.g. on every supported Python version.

This section presents the resolutions against which checks run: the highest resolution, the lower-bound sample, and compatibility samples.

Highest resolution

uv's default highest resolution strategy selects the highest admissible version of every dependency. Locking the resolved dependency graph makes it reviewable and environments reproducible. For libraries, the checks run against the locked dependency graph; for applications deployed from the lock file, its runtime subset additionally determines the production dependencies, identical to those verified. Refreshing the lock file brings newly released versions into the dependency graph against which the checks run.

uv run --locked fails if the lock file is inconsistent with the declarations in pyproject.toml instead of silently updating it, so we use it for running the checks, and likewise uv sync --locked in production builds of applications, where --no-dev excludes the dependency groups. Since a universal lock file may select different versions or artifacts depending on the environment, checks should cover all target environments.

Lowest-direct resolution

uv's built-in lowest-direct resolution strategy selects the lowest admissible version of every direct dependency and the highest admissible version of every transitive dependency. Running tests and static analysis in a temporary environment6 with this resolution thus checks the declared lower bounds, where <command> is a tool command, such as pytest or mypy:

uv run --isolated --resolution lowest-direct <command>

This check has two drawbacks, though. First, uv recomputes the resolution on every run without recording it in the uv.lock file, so it is neither reviewable nor reproducible. Second, if another requirement excludes the version at a declared lower bound, uv silently selects a higher version, so the check passes although the bound itself remains unverified; this masked lower bound surfaces only by comparing the installed versions with the declared bounds in every target environment.

We therefore prefer an explicit counterpart of the built-in strategy, the lower-bound sample: a dependency group, compat-lowest, that pins every direct runtime dependency to its declared lower bound. Since uv resolves runtime dependencies and dependency groups together, the pins of compat-lowest would constrain the default resolution unconditionally. To make them opt-in yet locked, uv's conflicts setting declares the group mutually exclusive with an empty group, compat-default:

pyproject.toml
[project]
# ...
dependencies = ["pydantic>=2.11"]

[dependency-groups]
compat-default = []
compat-lowest = ["pydantic==2.11"]

[tool.uv]
conflicts = [
  [
    { group = "compat-default" },
    { group = "compat-lowest" },
  ],
]

uv resolves mutually exclusive groups in separate forks, all recorded in the uv.lock file. The empty group compat-default adds no constraints, so its fork is resolved under uv's default highest resolution strategy. Checks then run against each resolution:

uv run --locked <command>
uv run --locked --group compat-lowest <command>

Compared with the built-in strategy, the lower-bound sample is locked and reproducible, and it surfaces a masked lower bound as a resolution error. Its cost is maintenance: every direct runtime dependency is declared twice, and while a pin below its bound fails the resolution, a pin above its bound or a missing pin passes silently, leaving the bound unverified. Pins that textually mirror the bounds, such as ==2.11 for >=2.11, make such drift visible in review; pins above a masked bound remain until maintainers revisit them.

We deliberately trade this maintenance for reproducibility and reviewability.

Compatibility sampling

Neither the highest resolution nor the lower-bound sample covers intermediate releases or the combinatorial space between these two extremes. Since the number of combinations grows exponentially with the number of dependencies, exhaustive verification is infeasible in all but trivial projects. For libraries with wide compatibility ranges, deliberately chosen samples of significant boundaries can therefore add evidence at a tractable cost.

A library that supports two major releases of a dependency, for instance, may sample the highest supported version of the older major release and the lowest supported version of the newer one. Like the lower-bound sample, these samples are dependency groups that join the conflicts declaration, so a single uv.lock file records all of them. For a library that supports both Pydantic v1 and v2, the configuration reads as follows:

pyproject.toml
[project]
# ...
dependencies = ["pydantic>=1.10"]

[dependency-groups]
dev = ["pytest==8.4.1"]
compat-default = []
compat-lowest = ["pydantic==1.10"]
compat-pydantic-v1-highest = ["pydantic==1.10.22"]
compat-pydantic-v2-lowest = ["pydantic==2.0.0"]

[tool.uv]
conflicts = [
  [
    { group = "compat-default" },
    { group = "compat-lowest" },
    { group = "compat-pydantic-v1-highest" },
    { group = "compat-pydantic-v2-lowest" },
  ],
]

Checks then run against the default resolution and each sample:

uv run --locked <command>
uv run --locked --group compat-lowest <command>
uv run --locked --group compat-pydantic-v1-highest <command>
uv run --locked --group compat-pydantic-v2-lowest <command>

While the published runtime dependency retains the full compatibility range, each sample provides a complete, reviewable, and locked dependency graph to run checks against.

The samples apply to optional dependencies as well. A pin on an optional dependency in a compat-* group would force its installation in every sample environment, so it carries the extras environment marker, which conditions the pin on the selection of the extra:

pyproject.toml
[project.optional-dependencies]
yaml = ["pyyaml>=5.4"]

[dependency-groups]
compat-lowest = ["pyyaml==5.4; 'yaml' in extras"]

Selecting the extra with uv's --extra <extra> or --all-extras flag then also activates the corresponding pins of the group:

uv run --locked --extra yaml --group compat-lowest <command>

Automating updates with Renovate

Locked versions make dependency graphs reviewable and environments reproducible, and pins make every change to a direct dependency explicit, but both require explicit action to remain up to date. Without updates, the project environment, and with it the tests, the development tools, and, for applications, the production dependencies, uses increasingly outdated versions. Three kinds of updates are required:

What to update How to update What to review
Pinned requirements for direct dependencies uv add [--group <group>] <package>==<version> pyproject.toml
uv.lock
Locked versions of non-pinned requirements for direct dependencies uv lock --upgrade-package <package> uv.lock
All locked versions uv lock --upgrade uv.lock
Dependency updates with commands and review targets

Updates are frequent and mechanical to perform, which calls for automation: Renovate detects new releases and proposes updates via merge requests (MRs), which checks verify and maintainers review. Renovate's PEP 621 manager automates pin updates and lock file refreshes for runtime dependencies, dependency groups, and the uv.lock file. The following configurations refresh the lock file periodically and pin development dependencies. Runtime dependencies are updated according to the project type: libraries leave their compatibility ranges unchanged, and applications pin them.

renovate.json
{
  "lockFileMaintenance": {
    "enabled": true // (1)!
  },
  "packageRules": [
    {
      "matchManagers": ["pep621"],
      "matchDepTypes": ["project.dependencies", "project.optional-dependencies"],
      "rangeStrategy": "in-range-only" // (2)!
    },
    {
      "matchManagers": ["pep621"],
      "matchDepTypes": ["dependency-groups"],
      "rangeStrategy": "pin" // (3)!
    }
  ]
}
  1. lockFileMaintenance periodically proposes an MR that refreshes the lock file, i.e. updates all locked versions of direct and transitive dependencies to their highest admissible versions.
  2. The in-range-only range strategy leaves compatibility ranges unchanged and updates only the lock file when a new release falls within a range.
  3. The pin strategy converts non-pinned requirements into pins and updates them.
renovate.json
{
  "lockFileMaintenance": {
    "enabled": true // (1)!
  },
  "packageRules": [
    {
      "matchManagers": ["pep621"],
      "matchDepTypes": ["project.dependencies", "project.optional-dependencies"],
      "rangeStrategy": "pin" // (2)!
    },
    {
      "matchManagers": ["pep621"],
      "matchDepTypes": ["dependency-groups"],
      "rangeStrategy": "pin" // (3)!
    }
  ]
}
  1. lockFileMaintenance periodically proposes an MR that refreshes the lock file, i.e. updates all locked versions of direct and transitive dependencies to their highest admissible versions.
  2. Applications pin runtime dependencies like development tools, so this rule uses the pin range strategy as well.
  3. The pin strategy converts non-pinned requirements into pins and updates them.

For libraries with an attributed dependency, the pin requires an additional package rule with the pin strategy:

{
  "matchManagers": ["pep621"],
  "matchDepTypes": ["project.dependencies", "project.optional-dependencies"],
  "matchPackageNames": ["<package>"],
  "rangeStrategy": "pin"
}

Compatibility samples and the lower-bound sample also require dedicated package rules because they evolve differently: a sample at the highest supported version of a release line should advance within that line, whereas a sample at the lowest supported version should remain fixed until the support boundary changes. We append two package rules. The first disables updates for every sample at a lowest supported version, i.e. every group named compat-lowest or compat-*-lowest, which it selects via matchJsonata. Maintainers adjust these pins manually when the declared lower bound changes:

{
  "matchManagers": ["pep621"],
  "matchDepTypes": ["dependency-groups"],
  "matchJsonata": ["$contains(managerData.depGroup, /^compat(-.+)?-lowest$/)"],
  "enabled": false
}

The second confines updates in the compat-pydantic-v1-highest group to v1 via allowedVersions:

{
  "matchManagers": ["pep621"],
  "matchDepTypes": ["dependency-groups"],
  "matchPackageNames": ["pydantic"],
  "matchJsonata": ["managerData.depGroup = 'compat-pydantic-v1-highest'"],
  "allowedVersions": "<2"
}

Limitations

Two situations limit the declaration policy and verification above: lower bounds that other requirements mask in the lowest-direct resolution, and pins in applications published to a package index. This section describes each limitation and its mitigation. The gaps that remain inherently, such as unpublished releases, are listed in the verification table.

Masked lower bounds

A lower bound is masked when another requirement in the resolution excludes the version at that bound, so that the lowest-direct resolution cannot select that version and the bound remains unverified. Since an environment holds a single version of each package, whether consumers can still resolve the unverified versions depends on where the masking requirement originates.

Since uv resolves dependency groups together with the runtime dependencies, a tool's requirement may mask a runtime dependency's lower bound although consumers, who never install the tool, can still resolve those versions. To illustrate, consider a library that applies the typing_extensions.override decorator, introduced in typing-extensions 4.4.0, but understates its lower bound:

pyproject.toml
[project]
# ...
dependencies = ["typing-extensions>=4.0"]

[dependency-groups]
dev = ["mypy==1.16.1"]
compat-default = []
compat-lowest = ["typing-extensions==4.0"]

[tool.uv]
conflicts = [
  [
    { group = "compat-default" },
    { group = "compat-lowest" },
  ],
]

Without the compat-lowest group, uv's built-in lowest-direct strategy would pass with typing-extensions 4.6.0, whereas consumers with a resolved version in the range >=4.0,<4.4.0 encounter an import error. The explicit compat-lowest group, in contrast, fails to lock:

$ uv lock
error: No solution found when resolving dependencies for split (included: <package>[group:compat-lowest]; excluded: <package>[group:compat-default])
  cause: Because mypy==1.16.1 depends on typing-extensions>=4.6.0 and <package>:dev depends on mypy==1.16.1, we can conclude that <package>:dev depends on typing-extensions>=4.6.0.
         And because <package>:compat-lowest depends on typing-extensions==4.0, we can conclude that <package>:dev and <package>:compat-lowest are incompatible.
         And because your project requires <package>:compat-lowest and <package>:dev, we can conclude that your project's requirements are unsatisfiable.

Running development tools outside the project environment, e.g. via ad hoc uvx commands, would avoid such masking but forgoes their declaration and locking. It is also often impractical: editors and IDEs discover tools in the project environment, and tools such as pytest must be co-installed to import the library.

Runtime dependencies can mask lower bounds as well. Suppose the library above additionally declares pydantic>=2.11. The lowest-direct resolution selects Pydantic 2.11.0, which requires typing-extensions>=4.12.2, so typing-extensions resolves to 4.12.2, concealing the understated bound. Consumer environments, in contrast, are typically resolved with the highest resolution strategy, where the understated bound is masked as well if the resolved Pydantic version requires a typing-extensions version at or above the library's technical minimum.

A masked lower bound prompts maintainers to check that the declared bound is not understated, i.e. not below the technical minimum. If it is, they raise it to the lowest supported version, correcting the error that the masking concealed. Otherwise, they keep it and instead pin compat-lowest at the lowest unmasked version, documenting the pin as an unverified claim, analogous to the admission of future releases. Raising the bound would overstate it, as the restriction stems from the masking requirement, not from the library, and a later release may relax it. In the example above, the declaration and the pin read as follows:

pyproject.toml
[project]
# ...
dependencies = ["typing-extensions>=4.4"]

[dependency-groups]
dev = ["mypy==1.16.1"]
compat-default = []
compat-lowest = ["typing-extensions==4.6.0"] # (1)!

[tool.uv]
conflicts = [
  [
    { group = "compat-default" },
    { group = "compat-lowest" },
  ],
]
  1. Masked by mypy, >=4.4,<4.6.0 unverified

Such a pin remains until maintainers revisit it, since no check signals when the masking requirement has vanished.

Pins in published applications

For applications published to a package index, the version specifiers specification strongly discourages pins, since consumers cannot install a security fix outside a pin without a new application release. Consumers can, however, replace a pin themselves via uv's dependency overrides, e.g. by installing the application with an overrides.txt file that pins the affected dependency to another version:

uv tool install --overrides overrides.txt <application>

With pins, we deliberately favor reviewed integration over flexibility at installation time: flexible requirements admit fixes sooner, but they admit unverified changes as well.

Even with pins, installing an application from its published metadata, e.g. via uv tool install or uvx, resolves transitive dependencies anew, possibly selecting unverified versions. Published metadata contains only the direct requirements – neither the locked dependency graph nor a reference to the lock file. A discussion about reproducible package installation explores how this limitation might be overcome.

In the meantime, maintainers may supply the lock file7 together with the installation instructions or distribute a prebuilt environment8 instead of a package. Alternatively, projects using the Hatchling build backend can publish the locked dependency graph as an extra via the hatch-pinned-extra plugin, which consumers install as <application>[pinned]. It is no replacement for the lock file, though: it omits information such as artifact hashes, and installation still resolves anew, albeit under tight constraints.

Conclusion

Dependency declarations are claims about package versions, yet tool defaults and habit often take the place of deliberate choice, leaving a gap between declaration and verification that can compromise a project's downstream utility.

Two measures narrow this gap: choosing declarations by the environment they constrain makes them deliberate, and pairing each declaration with evidence substantiates its claim. A library is composed with other packages in shared consumer environments, so it restricts versions no more than evidence demands, declaring compatibility ranges with justified lower bounds, admitting future releases, and excluding only releases with a demonstrated incompatibility. Applications and development dependencies run in isolated environments, so they claim no more than evidence supports, pinning direct dependencies, which makes changes to them explicit. The checks run against locked resolutions: the highest resolution, a lower-bound sample, and compatibility samples for significant intermediate boundaries. Renovate keeps pins, lock files, and compatibility samples current as dependencies evolve.

Some claims nevertheless remain unverified: the compatibility of unpublished releases, versions between samples, masked lower bounds, exclusions and upper bounds whose evidence goes stale, and the transitive dependencies that consumers of libraries and published applications resolve anew. The policy makes these unverified claims explicit.

If you find this article useful, please consider sharing your feedback and experience via the comment box below. 🙏

Further reading


  1. Consumers' own requirements, pip's constraints files, and uv's constraint-dependencies can only tighten requirements. uv's dependency overrides can lift them, but only by replacing requirements as a whole, which may discard valid restrictions elsewhere in the dependency graph. ↩

  2. Exposing a dependency's API does not by itself justify a pin, since consumers that use this API directly declare their own requirement, and resolution selects a version that satisfies both. Coupling becomes harder to manage when consumers attribute a dependency's behavior to the library itself, e.g. through re-exported symbols (pydantic.ValidationError is pydantic_core.ValidationError) or through user-supplied templates, expressions, or queries that the library evaluates with a dependency. Wrapping dependency types, restricting the supported language subset, and documenting the exposed dependency reduce this coupling, while vendoring avoids pin conflicts at the cost of maintaining bundled code. ↩

  3. The availability of the --bounds option and the add-bounds setting, and whether they require preview mode, depend on the installed uv version. ↩↩

  4. pytest-xdist 3.5.0, for instance, required pytest>=6.2.0, whereas 3.6.1 required pytest>=7.0.0. In a project declaring pytest>=6.2, updating the plugin therefore upgraded a locked pytest 6.2.5 without any change to the pytest declaration, whereas with pytest==6.2.5, resolution failed until the pytest pin had been adjusted as well. ↩

  5. Such evidence goes stale silently, e.g. when a later code change avoids the incompatibility, which is why maintainers must reassess these declarations periodically. ↩

  6. This leaves the project's environment unchanged and, since uv 0.8.7, also the uv.lock file, which records the highest resolution. ↩

  7. uv can export its lock file to the standardized pylock.toml format, so consumers are not limited to uv, provided their package manager supports that format. ↩

  8. An artifact that bundles an application with its already resolved and installed dependencies, so that consumers can run it without any resolution. Examples include container images, bundled executables (e.g. PyInstaller, which also includes the interpreter) and zipapps (e.g. PEX and shiv), archived virtual environments, and operating-system or ecosystem packages (e.g. Debian packages, Homebrew formulae, Nix derivations, and conda packages). All of them are tied to the platforms and interpreters for which they were built. ↩

Comments