How uv Solves the Cross-Platform Dependency Graph
The resolver is the part of uv that turns a project's requirements into an installable set of package versions. The important question is not simply how uv finds the newest release. It is how uv searches through competing constraints, remembers conflicts, and represents different answers for different Python and platform conditions. Understanding that mechanism lets a reader tell the difference between a valid universal lockfile and an artifact that a particular machine can actually install.
A requirement is roughly a package name plus acceptable versions, but its full meaning can also include extras, a source, and environment markers. A marker might say that a dependency is needed only on Windows or only below a particular Python version. Every transitive dependency adds another condition. The resolver's job is to find a set that satisfies the whole graph, not to satisfy each line independently.
Resolution is guided search, not a latest-version sweep
uv uses pubgrub-rs, an implementation of the PubGrub incremental version-solving algorithm. “Incremental” means that the resolver builds a partial solution, learns from each decision, and extends or revises that partial solution as new dependencies appear. It does not need to know the entire graph before it begins, because package metadata is discovered as candidate versions are considered.
The process starts with a virtual root representing the user's direct requirements. From there, uv chooses an undecided package according to a priority, selects a compatible version, reads that version's dependencies, and adds those dependencies to the work still to be solved. A URL or other direct source receives high priority; exact requirements are easier to decide than broad ranges; and packages encountered earlier can be favored over deeply transitive packages. The exact ordering is part of the resolver's strategy, because it affects how much irrelevant metadata must be loaded and which valid solution is chosen.
Consider a small graph in which the project asks for foo and bar. One version of foo requires lib>=1, while one version of bar requires lib>=2. Choosing lib==2 satisfies both. A harder graph has two versions of each top-level package: a newer foo requires lib==2, while a newer bar requires lib==1. The pair of newer versions cannot coexist, but either one can be combined with an older version of the other. The resolver must explore that relationship rather than conclude that the first conflict proves the whole project impossible.
PubGrub's useful contribution is the form of memory it retains. When a partial solution chooses a at version 2 and b at version 2, then discovers that the first requires c==1 and the second requires c==2, the conflict can be attributed to the pair that caused it. The solver records an incompatibility involving a 2 and b 2, backtracks to an earlier state, and tries a different choice. If an incompatibility eventually includes the virtual root, the problem is not just one unlucky candidate: the requested requirements have no satisfying combination under the available information. uv can then construct an error trace that names the packages and requirements involved.
That explanation is an important design outcome. A resolver that returns only “no solution” leaves the user guessing which constraint to change. A derivation that connects the failure to particular packages can guide a narrower requirement, a different source, or a declared conflict. The error is still a failure, but it is a failure with a causal path.
The search also uses preferences. When a lockfile already contains a compatible version, uv prefers it. The pip-compatible workflow can likewise prefer versions already pinned in its output file, and an installed version can be preferred when resolving into an environment. Under the default strategy, candidates are generally considered from high versions to low versions, but the existence of a preference means a repeated operation often does not need to rediscover an entirely different solution. A dependency changes only when its old version is excluded or an explicit upgrade asks uv to move it.
Preferences are not the same as constraints. A preference says which valid answer to try first; it does not make an incompatible version valid. This distinction is what allows a lockfile to stabilize ordinary runs while still permitting a changed requirement to force new work. It also explains why two commands with equally valid input can produce different but deterministic resolutions when their requirement order or strategy differs. The resolver is choosing within a solution space, not revealing a single mathematically mandatory list.
Metadata acquisition is a major part of that work. To decide what a package version requires, uv may need to read metadata from an index, a cached artifact, a Git source, or a source distribution. Its resolver runs the solver and metadata-fetching work together: the solver operates on a dedicated thread while requests are sent through a channel to a fetcher, and candidate packages can be pre-visited so metadata is fetched in the background. This overlaps waiting for package information with the reasoning that determines which information will be needed next.
The result is not an assertion that every resolution is fast. Large graphs, slow or incomplete metadata, source distributions that need to be built, and repeated backtracking can all dominate. uv's own resolver documentation identifies metadata loading as a central cost and treats prioritization as a performance and user-experience decision. The mechanism improves the common path by avoiding work it can predict it will not need and by reusing prior decisions, not by making dependency solving free.
There is also a feedback loop between conflicts and priorities. If two packages repeatedly conflict, uv can change the order in which it decides them so that the constraining package is considered earlier. The source describes a threshold-based conflict reprioritization heuristic. This is a practical response to a common pattern: one package has high priority and a very new version, while another package can work only with older versions of it. Trying every version of the second package against the first choice would be both slow and likely to end with a poor, overly old candidate. Reordering the decisions can expose the real relationship sooner.
The sequence makes two forms of concurrency and memory visible without requiring an implementation tour. The cache and metadata provider reduce repeated input work; the solver keeps a partial solution and learned incompatibilities; and the fork state allows one logical request to become several conditional resolutions. Those pieces cooperate, but they do not remove the need to check whether a selected distribution exists for the final machine.
Universal means conditional, not artifact-independent
A platform-specific resolver evaluates markers for one target environment. That is often sufficient for an immediate installation, but it would make a project lockfile depend on the machine that created it. uv's project interface instead uses universal resolution. It keeps marker expressions in the graph so the lockfile can describe the dependencies needed across the project's supported Python versions, operating systems, architectures, and implementations.
Universal resolution is more constrained than solving for one machine. Suppose a project supports Python 3.8 and later. If the resolver selects a dependency version that requires Python 3.9, the result cannot satisfy the project's stated lower bound. uv therefore considers the full supported range. When the newest dependency version works only for later Python versions, it can fork the solution: an older version applies to the earlier Python range, while the newer version applies to the later range. The lockfile can contain both, with markers selecting the right one.
This is why a universal lockfile can contain multiple entries for the same package. The entries are not necessarily a duplicate installation. They describe different regions of the marker space. One region might use an older NumPy release on Python 3.8, another a newer release on Python 3.10 and later. When a concrete environment is selected, its markers should select the entry compatible with that environment.
uv's resolver calls these splits forks. A fork can be created when requirements differ by Python version, operating system, architecture, implementation, or another marker. Overlapping or incomplete marker expressions can require additional forks, while identical package results can be merged to keep the representation smaller. The boundaries are saved in the lockfile as resolution markers so a later run can preserve the same shape instead of rediscovering a different split merely because package preferences changed.
Two settings control different forms of platform policy. environments limits what the universal resolver needs to solve. A project that deploys only to Linux and macOS can use it to exclude Windows from the supported set; a project that supports only CPython can exclude alternative implementations. This reduces the problem the lockfile represents. required-environments does the opposite: it expands the conditions that must be installable. If a package has no source distribution and therefore depends on a pre-built wheel, a required environment can insist that a compatible wheel exist for a specific platform or cause resolution to fail early.
That distinction is operationally important. Limiting environments says, “we do not promise to solve this other platform.” Requiring environments says, “we do promise to support this platform, so reject a solution that cannot produce an artifact there.” Neither setting builds missing wheels. They make the project's support boundary explicit instead of leaving it to a deployment failure.
Markers are not the same as wheel tags. A marker can describe a Python version, operating system, architecture, or implementation in a dependency declaration. A wheel tag can encode a more specific compatibility requirement, including a Python ABI, an architecture, and a platform policy such as a glibc baseline. A universal marker graph may therefore be correct while a target machine still has no matching wheel. If a source distribution exists, uv may attempt to build it. If a package publishes only platform-specific wheels, installation can fail on a target for which no wheel was published.
Source distributions add a second dependency graph: the graph needed to build the package and the metadata produced by that build. During resolution, uv may have to build a source distribution just to learn its runtime dependencies when static metadata is unavailable. That can be expensive and can fail on a platform different from the one the package author used. If the metadata produced by that build is incomplete or changes between environments, the resolution can look sound while hiding a later installation problem.
The resolver also relies on metadata consistency. uv's design assumes that wheels for one package version in a given index expose the same dependency metadata, and that rebuilding a source distribution produces equivalent metadata. Without that assumption, a lockfile generated from one wheel or one build could describe a dependency set that changes when another wheel is installed or the source is rebuilt. The environment would then be asked to honor a lock that no longer matches the package's own requirements. This is a property of the packaging ecosystem, not something the resolver can infer safely after the fact.
Reproducibility adds another policy layer. The lockfile records exact versions and artifact information, and uv normally keeps those choices until an explicit upgrade. A time cutoff can make registry resolution ignore distributions uploaded after a date, while a duration can act as a cooldown that delays newly published packages until they have had time to be examined. These rules apply to registry artifacts with the required upload-time information; they do not turn arbitrary Git or source dependencies into time-frozen material. They improve control over future selection, but they do not change the target platform's wheel inventory or make a source build deterministic by declaration alone.
The practical conclusion is a three-part test. First, can the requirements be resolved under the project's markers and Python range? Second, does the selected package version have a compatible wheel or a source distribution that can be built for the target? Third, will the resulting metadata and artifact satisfy the lockfile when it is installed? Universal resolution answers the first question and helps organize the second. It cannot guarantee the third when package metadata, build behavior, or platform artifacts violate the assumptions on which locking depends.
That boundary is not a defect hidden behind terminology. It is the reason uv's lockfile can be useful without pretending to be a universal binary. The project contract says what the team supports; the resolver records conditional choices; the artifact determines what a concrete machine can execute. With those distinctions in place, the next judgment is whether a resolution is reproducible and operationally acceptable when freshness, upgrades, platform coverage, and installation behavior pull in different directions.