Reproducible Python builds with uv come down to three habits: commit uv.lock, gate CI with uv lock --check or --locked so lockfile drift fails loudly, and split your Dockerfile so dependencies install in a layer that project code cannot invalidate. Workspaces are optional on top of that. They fit a monorepo whose packages agree on one dependency set, and the documentation is explicit that they are the wrong tool when members have conflicting requirements or want separate environments — use path dependencies there instead. What follows is the configuration and the checklist.

What uv.lock actually captures

uv.lock is a universal lockfile. It records resolution across all possible Python markers — operating system, architecture, Python version — rather than the packages that happened to install on the machine that generated it. That is the substantive difference from a pip freeze output, which is a snapshot of one environment and silently stops being true on a different platform.

Two rules from the project structure documentation are non-negotiable.

It is, in the docs' words, "a human-readable TOML file but is managed by uv and should not be edited manually." Readable is not the same as editable. Hand-patching a version without re-resolving decouples the file from the constraints it claims to satisfy.

And it "should be checked into version control, allowing for consistent and reproducible installations across machines."

The .venv directory goes the other way — uv excludes it through an internal .gitignore, and the environment should be modified through uv add rather than by hand.

One behavior surprises people often enough to state plainly: uv does not treat a lockfile as outdated just because new versions were published upstream. The documentation puts it directly — "the lockfile needs to be explicitly updated if you want to upgrade dependencies." That is the correct default, since build reproducibility should not be at the mercy of someone else's release cadence. But it means a team assuming CI silently picks up security patches is wrong, and that assumption is worth correcting early.

Workspace or not

A workspace is a collection of packages managed together. The official documentation gives the motivating example: a FastAPI web application alongside a set of libraries that are versioned and maintained as separate Python packages, all in one Git repository.

The defining property is that each member has its own pyproject.toml while the workspace shares a single uv.lock. One lockfile is what keeps every member on a consistent dependency set.

The documentation is equally clear about when to walk away. Workspaces are "not suited for cases in which members have conflicting requirements, or desire a separate virtual environment for each member." For those, use a path dependency:

[tool.uv.sources]
bird-feeder = { path = "packages/bird-feeder" }

There is a second constraint that catches people later rather than sooner: a workspace enforces a single requires-python across all members, computed as the intersection of every member's declared value. A library that must support an older interpreter will narrow the whole workspace to that intersection. When that is not what you want, separate projects with path dependencies are the better shape.

The decision reduces to one question: do these packages want to share one dependency resolution? If yes, workspace. If no, don't force it.

Configuring a workspace

Adding a tool.uv.workspace table to a pyproject.toml implicitly creates a workspace rooted at that package:

[tool.uv.workspace]
members = ["packages/*"]
exclude = ["packages/seeds"]

members is required and exclude is optional; both accept glob patterns. Every directory matched by members and not excluded must contain a pyproject.toml, and the workspace root is itself a member.

Declaring dependencies between members

Use tool.uv.sources with workspace = true:

[project]
name = "albatross"
dependencies = ["bird-feeder"]

[tool.uv.sources]
bird-feeder = { workspace = true }

Dependencies between workspace members are editable, so a change in bird-feeder is immediately visible to albatross with no reinstall step. That is the concrete developer-experience win over publishing internal libraries to a private index.

Workspace-root tool.uv.sources entries apply to all members unless a member overrides them locally. Putting shared source configuration at the root and letting members declare only their deviations keeps the tree readable.

How commands scope inside a workspace

Three rules cover almost everything:

  • uv lock operates on the entire workspace at once. There is no per-member lock, because there is only one lockfile.
  • uv run and uv sync operate on the workspace root by default.
  • Both accept --package, letting you target a specific member from any directory in the workspace.

--all-packages covers the "sync everything" case.

In practice:

uv sync --package api          # dependencies for the api member
uv run --package api pytest    # tests in the api member's context
uv sync --all-packages         # every member

Gating lockfile drift in CI

This is the load-bearing part of reproducibility. By default uv updates the lockfile automatically when project metadata changes — a dependency added, a constraint edited. That is convenient locally and actively harmful in CI, where it hides the exact inconsistency you want to catch.

The locking and syncing documentation defines three flags with genuinely different semantics:

Flag Behavior Use it for
--locked Errors if the lockfile is not up to date instead of updating it CI builds, release pipelines
--frozen Uses the existing lockfile without checking whether it is current Container runtime, where the lockfile is known good
--no-sync Runs a command without verifying the environment matches the lockfile Externally managed environments

The distinction matters: --locked verifies and fails; --frozen skips verification. Using --frozen as a CI gate is the same as having no gate.

For an explicit check, uv lock --check exists and the docs describe it as "equivalent to the --locked flag for other commands."

A minimal, honest CI sequence:

uv lock --check                # does the lockfile match pyproject.toml?
uv sync --locked --no-dev      # install from the lockfile, skip dev deps
uv run --no-sync pytest        # run tests without re-syncing

The first line is the one that earns its keep. Someone adds a dependency and forgets to commit the regenerated uv.lock; this fails immediately rather than letting CI pass on an ad-hoc resolution nobody will ever reproduce.

Groups and extras

Selective installation has its own set of flags, easier to remember grouped by purpose:

  • --all-extras or --extra <name> — include optional dependencies from [project.optional-dependencies]
  • --no-dev — exclude the dev dependency group
  • --all-groups — include every group from [dependency-groups]
  • --only-dev or --only-group <name> — install the specified groups without the project itself

--only-group has an underrated use: a lint or type-check job needs the tooling, not the project and not its runtime dependencies. Skipping both makes those jobs substantially smaller and faster to start.

There is also a default that differs between commands. uv sync performs an exact sync, removing extraneous packages; uv run uses an inexact sync that installs what is needed without removing extras. Invert either with uv sync --inexact or uv run --exact. CI should stay exact so a previous build's residue cannot influence the current one.

Docker layering

Dependencies change far less often than application code, so installing both in one layer means every one-line change reinstalls the world. The Docker integration guide recommends isolating transitive dependency installation with --no-install-project:

RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --locked --no-install-project

Note that only uv.lock and pyproject.toml are bind-mounted — the project is not copied in yet. As long as those two files are unchanged, the layer hits cache.

The full two-step pattern:

RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-install-project

COPY . /app

RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked

Three environment variables belong in production images:

ENV UV_LINK_MODE=copy
ENV UV_COMPILE_BYTECODE=1
ENV UV_NO_DEV=1

UV_LINK_MODE=copy applies when the build cache and target live on different filesystems; the guide notes that changing it "silences warnings about not being able to link files." UV_COMPILE_BYTECODE=1 precompiles bytecode in the image — equivalent to uv sync --compile-bytecode — trading build time for startup time. UV_NO_DEV=1 keeps development dependencies out.

Workspaces get matching controls: --no-install-workspace skips all members and --no-install-package skips a named one, with the same layering logic.

Every uv sync above carries --locked. If the image build discovers lockfile drift, it should fail rather than resolve a dependency set that differs from the one CI tested.

Exporting for tools that need it

Some environments cannot drop requirements.txt yet, and some security teams want a bill of materials. uv export --format <format> converts uv.lock into:

  • requirements.txt for existing tooling
  • pylock.toml, the standardized lockfile format from PEP 751
  • a CycloneDX SBOM

Treat every one of these as a derived artifact, never as the source of truth. The truth is uv.lock. The failure mode is predictable: someone hand-edits the exported requirements.txt to fix an environment, the two diverge, and reproducibility is gone. Generate exports in CI rather than committing them.

uv also has a preview malware check that scans against OSV advisories, enabled with audit.malware-check = true or UV_MALWARE_CHECK=1. Preview status means you should decide deliberately whether to make it a blocking gate, but it is a useful signal either way.

Failure modes worth recognizing

"Works locally, fails to install in CI." Usually an uncommitted or stale lockfile. uv lock --check names the problem in one line.

Dependencies were released but the lockfile did not change. Working as designed, not a bug. Run uv lock --upgrade for everything or uv lock --upgrade-package <name> for one.

One workspace member cannot resolve. Check requires-python first. The workspace uses the intersection across members, so one narrow declaration constrains everyone.

Strange behavior after editing uv.lock. Do not edit it. Revert the file and re-resolve from pyproject.toml.

Docker reinstalls everything on every build. Look for a COPY . /app that precedes uv sync. Any source change then invalidates the dependency layer.

Exported requirements.txt does not match the environment. Compare the group and extra flags used at export time against those used by uv sync. Mismatched --no-dev or --extra combinations produce different sets.

Reproducible build checklist

  • uv.lock is committed and never hand-edited
  • .venv is not in version control
  • CI runs uv lock --check first, failing on drift
  • CI installs with uv sync --locked, never --frozen masquerading as a gate
  • Upgrades happen through explicit uv lock --upgrade-package commits that get reviewed
  • The workspace requires-python intersection is known and intentional
  • Inter-member dependencies use tool.uv.sources with workspace = true
  • The Dockerfile splits dependencies with --no-install-project and sets UV_LINK_MODE, UV_COMPILE_BYTECODE, UV_NO_DEV
  • Exported requirements.txt / pylock.toml / SBOM are CI-generated, not committed
  • Dependency upgrades and interpreter upgrades ship as separate changes

That last item deserves a sentence. Bundling a dependency bump with an interpreter bump makes failures unattributable — you cannot tell whether a library regressed or the runtime changed underneath it. Interpreter migration has its own sequence of dependency, ABI, and release-gate work, which we covered in the Python 3.15 Beta 4 Migration Guide; it pairs well with the lockfile gates here.

For what happens downstream — packaging the service, coordinating release state and cache invalidation — the publishing flow in Building an Atomic Bilingual Publishing System is a reasonable reference.

And if you want uv lock --check plus a dependency audit running on a schedule rather than only on pull requests, a small scheduler is enough. Cronova is a single self-hosted binary for exactly that: DAGs in YAML, embedded SQLite, a web console and REST API, with scheduling, dependencies, and failure notifications handled in the scheduler rather than bolted onto CI.

FAQ

Should uv.lock be committed? Yes. The documentation recommends checking it into version control as the basis for consistent, reproducible installs across machines.

Can I edit uv.lock by hand? No. It is human-readable TOML but managed by uv, and the docs state it should not be edited manually.

What is the difference between --locked and --frozen? --locked errors when the lockfile is out of date; --frozen uses it without checking. CI gates need --locked.

Does uv pick up new releases automatically? No. Upgrades require an explicit uv lock --upgrade or uv lock --upgrade-package <name>.

Do multiple packages require a workspace? No. When members have conflicting requirements or want separate environments, the documentation points to path dependencies instead.

Can workspace members have separate lockfiles? No. A workspace shares one uv.lock — that is the mechanism that keeps members consistent. Needing independent resolution is a signal not to use a workspace.

Can I still produce a requirements.txt? Yes, via uv export --format requirements.txt. Generate it in CI and treat it as output, not as a file to maintain.