The Project Contract: From pyproject.toml to a Reproducible Environment
For uv, a Python project is not just a folder with an install command. It is a set of related states: declared intent, a requested interpreter, an exact dependency solution, and a working environment. This chapter focuses on how uv connects those states and what uv run and uv sync actually reconcile, so a reader can choose between normal development, strict CI checks, deliberate upgrades, and intentionally frozen execution.
The distinction matters because these states change at different speeds. A project description may remain stable while a virtual environment is recreated. A dependency constraint may change without requiring a new version if the old locked version still satisfies it. A new package release may exist without making an existing lockfile stale. If those differences are hidden, a command that looks harmless can either leave an environment drifting or remove packages that someone expected to remain.
Four artifacts, four kinds of ownership
The first artifact is pyproject.toml. It is the project's declaration of intent: its name and version, supported Python range, dependencies, optional features, development groups, build system, and command entry points. The file answers what the project is and what it asks for. It does not, by itself, say which transitive versions will be installed on every machine. A requirement such as “use a compatible version of a library” is a rule for choosing, not the final choice.
That distinction is why uv requires a pyproject.toml to identify a project root. The file gives the tool a stable place from which to discover configuration and project metadata. Commands such as uv add and uv remove can edit dependency declarations for the user, but the file remains the durable, reviewable expression of what the project wants. It can also say whether the current directory contains a package that should be built and installed, or only an unpackaged application whose dependencies should be available.
The second artifact is .python-version. It is not a list of package requirements. It is a default request for an interpreter: perhaps Python 3.12, perhaps a particular implementation, or perhaps a more detailed version request. uv searches for this file in the working directory and its parents, within project or workspace boundaries, and uses it when no more specific request has been supplied. The project's requires-python field serves a related but different purpose. It states the Python versions the project supports and affects dependency selection; .python-version chooses the default interpreter to use locally.
This separation prevents a common category error. A project can support a range such as Python 3.10 through 3.13 while a developer pins one of those versions for local work. Conversely, a pinned interpreter does not make the project compatible with every dependency version. The supported range still constrains resolution. One setting describes the project's promise; the other describes the runtime a particular invocation should prefer.
The third artifact is the project environment, normally .venv. It is mutable execution state: a directory containing an interpreter and the installed packages that a particular machine needs. uv creates it when a project command requires it and stores it beside the project so editors can find it for completion and type information. The directory is intentionally not the project contract's source of truth. It is normally excluded from version control, and it can be recreated from the declaration, lockfile, selected interpreter, and available artifacts.
That makes .venv different from a manually maintained global Python environment. The project environment has a clear owner and a known relationship to the project root. It can still be activated like an ordinary virtual environment, but activation is not required for uv run; uv can locate and use the project environment itself. The practical consequence is that a developer can treat the environment as a generated result rather than a place where undocumented fixes accumulate.
The fourth artifact is uv.lock. It records the exact resolved versions and sources that the project environment is intended to use, along with the conditions that determine where those choices apply. Unlike pyproject.toml, it is not a broad request. Unlike .venv, it is not a machine-local installation. It is the reproducibility record that can be reviewed and checked into version control so multiple developers and deployment systems begin from the same resolution.
The lockfile is human-readable TOML, but that does not make it a hand-edited configuration file. uv manages its format, and the format is specific to uv. Its role is to preserve a resolved result, not to serve as a general interchange format for every Python tool. When another workflow needs a different representation, uv can export the resolution, but translation does not erase the distinction between the richer project lock and a more limited requirements file.
These ownership boundaries explain why a project can be healthy even when its .venv is absent. The environment is the current materialization; the manifest and lockfile describe how to recreate it. They also explain why committing only pyproject.toml is weaker than committing the lockfile: the former allows a future resolver to make new choices, while the latter records the choices the team has already accepted. The lockfile still depends on conditions outside the repository, including package indexes, platform artifacts, and build behavior, but it makes the dependency decision explicit.
Commands are reconciliation policies
Once the four artifacts exist, uv's project commands behave less like a sequence of imperative installs and more like state reconciliation. When uv run is used for a project command, uv checks whether the lockfile reflects the project metadata and whether the environment reflects the lockfile before launching the requested command. If something is missing, uv can create or update the relevant state first. The command is therefore coupled to the environment it needs, rather than relying on the user to remember a separate installation step.
This automatic behavior is useful in ordinary development. A developer adds a dependency with uv add, and the declaration, resolution, and environment can move together. A fresh checkout can run a project command without a ritual of creating a virtual environment, compiling requirements, and remembering which installer should be used. An editor can be brought into alignment with an explicit uv sync. The convenience is not magic; it comes from uv owning the relationships between the artifacts.
Freshness has a precise meaning. If a dependency is added to the manifest, or its constraint is changed so that the locked version no longer satisfies it, the lockfile is outdated. But the release of a newer version does not by itself make the lockfile outdated. That behavior is intentional: otherwise a routine command could silently change the dependency set whenever a package publisher released something new. To request that change, the user must use an explicit upgrade operation, such as upgrading all packages or targeting one package.
The flags around locking are policies for what uv is allowed to change or assert. --locked says that the existing lockfile must already match the project metadata. If it does not, the command fails instead of updating the file. This is a useful assertion in CI because it turns an uncommitted dependency change into a visible failure. It also makes the source of a deployment resolution easier to audit: the job is not silently generating a new lock while it runs.
--frozen is different. It tells uv to use the lockfile without checking whether it is current. That can be appropriate when a build intentionally has only the lockfile and the minimal metadata needed to install from it, or when a known snapshot is being reused. It is not a stronger form of --locked; it deliberately gives up the freshness check. A team that uses it should know why the check is unavailable or intentionally skipped.
--no-sync controls another transition. It allows a command to run without bringing the environment up to date. This can be useful when the caller owns the environment lifecycle or when an already prepared environment is being inspected, but it also means the command should not be read as proof that the environment matches the lockfile. The three policies therefore answer different questions: may uv rewrite a stale lockfile, may it trust an existing lockfile without checking, and may it modify the environment before execution?
uv sync is the explicit environment operation. By default it is exact: packages not represented in the selected lockfile state are removed. That behavior is valuable when the goal is a clean, reproducible environment, especially in a build or deployment context. It is also consequential in a shared or specially prepared environment. Exact synchronization is not a neutral inspection; it is permission to delete packages that fall outside the requested state. The --inexact option preserves extraneous packages when that is the intended policy.
uv run chooses a different default. It makes sure required packages are present but does not remove extraneous packages unless --exact is requested. That difference reflects the commands' normal contexts. uv sync is often used to make an environment match a declared state, while uv run is often used to get work done without destroying unrelated material someone may have placed in the environment. The same lockfile can therefore lead to different filesystem actions depending on which command owns the transition.
The installed subset also depends on how the project groups its requirements. Optional dependencies, often called extras, are not synchronized by default; the user can select one or all of them. Development dependencies are organized through dependency groups, with the dev group receiving special default treatment and other groups controlled explicitly. The lockfile can contain the possible dependency relationships even when a particular invocation installs only one subset. In other words, “the project environment” is not always one fixed list. It is a selected materialization of a broader project resolution.
Project packages are installed in editable mode by default when the environment is synchronized. In editable mode, changes to the source are reflected without rebuilding and reinstalling the project for every edit. That is a development convenience, not always a deployment choice. A non-editable install removes the environment's dependence on the source tree and can be useful when an environment is copied into a production image. The important point is that editability changes how the environment points to project code; it does not replace the lockfile or the dependency resolution.
The same policy applies to upgrades. With an existing lockfile, uv prefers the versions already recorded there. It changes a package when the project's constraints no longer allow that version or when the user asks for an upgrade. An all-package upgrade accepts broader movement; a package-specific upgrade changes one requested part while retaining other locked choices where possible. This gives the team a controlled distinction between “make the environment satisfy changed intent” and “refresh the versions we have chosen.”
The resulting lifecycle is easier to reason about as a set of transitions than as a command glossary:
The diagram is a policy map, not a claim that a single process permanently moves through one linear sequence. A metadata edit can make the lockfile stale. A lockfile can be checked without being rewritten. An environment can be synchronized exactly or updated inexactly. The transitions are controlled by both the command and the user's flags, and the state that persists differs at each step: declarations and lockfiles belong in source control, while the environment is a replaceable local result.
That is also why the documentation discourages using uv pip install to alter a project environment directly. The pip interface is useful for workflows that intentionally manage an environment at that level, but a project managed by uv has a higher-level contract. Installing a package behind that contract can make the environment appear healthy while leaving no corresponding project declaration or locked choice. The next project command may then reconcile the environment back to the recorded state, or a teammate may recreate an environment and never receive the undocumented package.
The contract does not eliminate complexity; it puts complexity in named places. pyproject.toml owns intent, .python-version supplies a default runtime request, uv.lock owns the accepted resolution, and .venv materializes a selected result. The commands then choose whether to update, check, trust, preserve, or remove state. The harder question is what the lockfile must contain when one project supports several Python versions and operating systems—because the exact list for one machine is not enough to describe the dependency graph for all the machines the project claims to support.