Why API services need lockfiles, not version ranges
A library and an application make different promises. A library declares version ranges, such as "any 2.x release of the HTTP client", so it can coexist with whatever else its users install. An API service makes no such promise. It is deployed, and the artifact that passed CI on Tuesday must be the artifact that runs in production on Friday. Ranges suit the first case. They undermine the second.
The gap is in transitive dependencies. Your pyproject.toml or requirements.in may list a dozen direct dependencies: the web framework, an ORM, a validation library. Each of those pulls in its own dependencies, and the full tree for a typical service is often several times larger than what you declared. You pin your framework. You probably do not pin the ASGI server's protocol parser or the cryptography backend your HTTP client resolves to. When one of those publishes a release that drops a Python version, changes a default, or ships a broken wheel for your platform, a rebuild with no code changes produces a different environment. A build that worked yesterday now fails, or worse, it passes and behaves differently.
A lockfile records the exact resolved version of every package in the tree. A good lockfile also stores the artifact hashes, so the installer rejects anything that does not match byte-for-byte.
This gives the criterion for the rest of this comparison. Each tool must produce a reproducible, hash-verified lockfile that installs an identical environment in CI and in the container image.
How pip-tools, Poetry and uv produce and consume a lock
pip-tools keeps the resolver separate from the installer. You declare loose constraints in requirements.in or pyproject.toml, and pip-compile resolves them into a fully pinned requirements.txt, optionally with hashes. pip-sync then installs what is missing and uninstalls anything not listed, so the environment matches the file exactly. The output is ordinary requirements syntax, so production images can install it with plain pip and need no extra tooling.
Poetry treats the project as the unit. It resolves pyproject.toml into poetry.lock, which records the exact version and the file hashes for every package. It also manages the virtualenv, dependency groups and packaging. Installing from the lock in CI means Poetry does no resolution: it installs the locked set, and it errors if the lock no longer matches the manifest. Any drift therefore fails the build instead of being silently re-resolved.
uv is a single Rust binary from one vendor, and it offers three interfaces. uv pip replaces pip-style installs. uv pip compile provides a pip-compile-style workflow. A project workflow writes uv.lock. uv can also install and pin Python interpreters. It is widely reported to resolve and install much faster than the alternatives, but this article has not benchmarked it.
| Tool | Manifest | Lockfile | Install concept | Scope | Interpreter management |
|---|---|---|---|---|---|
| pip-tools | requirements.in / pyproject.toml |
pinned requirements.txt |
sync env to file | resolve + sync only | No |
| Poetry | pyproject.toml |
poetry.lock |
install from lock | deps, venv, groups, packaging | No |
| uv | pyproject.toml / .in files |
uv.lock or requirements.txt |
sync from lock | installer, resolver, projects | Yes |
The lock formats are specific to each tool. poetry.lock and uv.lock cannot be read by each other or by pip, so switching tools means regenerating the lock.
# Each tool resolves loose constraints into a lock, then installs from that lock.
# Run each block in its own project directory; they are alternatives, not steps.
# --- pip-tools: resolver and installer are separate ---
# requirements.in holds loose constraints, e.g. "fastapi>=0.110"
pip-compile --generate-hashes requirements.in -o requirements.txt
pip-sync requirements.txt # install missing, uninstall unlisted
# A production image needs only plain pip to consume the same file:
pip install --require-hashes -r requirements.txt
# --- Poetry: the project is the unit ---
poetry lock # resolve pyproject.toml into poetry.lock
# In CI, install the locked set without re-resolving.
# This fails if poetry.lock no longer matches pyproject.toml.
poetry install --no-root
# --- uv: three interfaces in one binary ---
# 1. pip-compile-style workflow, writes requirements.txt
uv pip compile requirements.in -o requirements.txt
uv pip sync requirements.txt
# 2. Project workflow: pyproject.toml -> uv.lock
uv lock
# --locked makes CI fail if uv.lock is out of date, instead of updating it
uv sync --locked
# 3. Interpreter management, which pip-tools and Poetry do not provide
uv python install 3.12
# The lockfiles are not interchangeable: poetry.lock and uv.lock are read
# only by their own tool. Exported requirements.txt is the common denominator.
The compile/lock and install commands for pip-tools, Poetry and uv side by side, including the CI forms that install from the lock and fail on drift instead of re-resolving.
Hash checking, dev/test/prod separation and cross-platform locks
Recording hashes protects you against two things. A compromised or misconfigured index cannot substitute a different file under the same version number. A republished artifact also fails loudly instead of slipping into a production image. For pip-tools, pip-compile --generate-hashes writes a --hash=sha256: entry beside each pin. pip then enforces every hash through --require-hashes, and it turns that mode on automatically once any requirement carries a hash. Poetry and uv both record hashes in their lockfiles and verify them when they install from the lock.
The friction is predictable. Mirrors and private indexes that rebuild or repackage wheels serve different bytes, so the hashes no longer match. Packages that publish only an sdist get hashed, but their build dependencies are not pinned. Teams that disable hash checking to unblock one build often forget to turn it back on. Scope any exception to a single package and track it.
| Concern | pip-tools | Poetry | uv |
|---|---|---|---|
| Dev/test/prod split | Layered .in files, constrained by -c |
Dependency groups | Dependency groups and extras |
| Resolution scope | Current interpreter and platform | Aims to be cross-platform | Universal lock by default |
Platform-specific resolution has a classic failure. You compile on a macOS laptop, and the resulting lock omits Linux-only dependencies that are gated by environment markers. It can also pin a version that has no Linux wheel for your container's Python, so the build compiles from source or fails. Universal locks reduce this risk without removing it. Before you trust any lock, resolve and install it inside your actual base image in CI.
#!/usr/bin/env bash
# Layered pip-tools workflow: hashed prod lock, dev lock constrained to prod pins,
# both compiled and installed inside the same Linux base image the service ships on.
set -euo pipefail
cat > requirements.in <<'EOF'
fastapi
uvicorn[standard]
EOF
# Dev/test layer: -c reuses the prod pins, so pytest can never move a shared dependency.
cat > dev.in <<'EOF'
-c requirements.txt
pytest
httpx
EOF
# Resolve inside the target image, not on a macOS laptop, so markers and wheel
# availability are evaluated for Linux and the container's Python version.
docker run --rm -v "$PWD":/src -w /src python:3.12-slim sh -ec '
pip install --quiet pip-tools
pip-compile --generate-hashes --output-file=requirements.txt requirements.in
pip-compile --generate-hashes --output-file=dev.txt dev.in
'
# Verify the prod lock installs cleanly in a fresh container.
# --require-hashes is already implied once any line has a hash; stating it makes intent explicit.
# --only-binary :all: fails fast if a pin has no Linux wheel for this Python instead of
# silently compiling from source. Drop it per-package only if you knowingly use an sdist.
docker run --rm -v "$PWD":/src -w /src python:3.12-slim \
pip install --require-hashes --only-binary :all: -r requirements.txt
# Commit requirements.txt and dev.txt; the .in files remain the human-edited source.
A pip-tools workflow that layers a dev lock on top of hashed production pins via -c, then compiles and verifies both inside the Linux base image with --require-hashes so platform and hash mismatches surface in CI rather than in production.
Implementing it: container builds and CI enforcement
The flow has one rule: only a deliberate lock step may resolve versions. Every later step installs exactly what the lockfile says, or fails.
pyproject.toml → lock step → lockfile (committed) → CI freshness check → strict install (tests) → strict install (image)
Dockerfile pattern
Copy only the manifest and lockfile before the application source. Docker can then reuse the dependency layer until one of those two files changes, and an edit to a handler won't trigger a full reinstall.
Use a multi-stage build. The builder stage holds the tool and any compilers and installs into a virtual environment. The final stage copies only that environment and your code. This keeps the resolver, build headers and caches out of production, which shrinks the image and its attack surface.
Pin the tool version, uv especially, either in the base image tag or the install command. Lockfile formats and flag behavior change between releases, and an unpinned tool lets the same lock produce different results over time.
CI enforcement
- Freshness check: fail the build if the lock no longer matches the manifest. Candidates are
uv lock --check,poetry check --lock, or re-runningpip-compileand diffing the output. Confirm the exact flags against your tool version. - Strict install: use
uv sync --locked,poetry installafter the check, orpip install --require-hashes --no-deps -r requirements.txt. None of these should ever re-resolve. - Upgrades as diffs: bump dependencies in a dedicated PR so reviewers see every version and hash change.
# Pin uv explicitly: docker build --build-arg UV_VERSION=<pinned version> .
# No default on purpose, so an unpinned build fails instead of drifting.
ARG UV_VERSION
FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv
# ---- builder: holds uv and any build tooling ----
FROM python:3.12-slim AS builder
COPY --from=uv /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /app
# Manifest + lockfile only: this layer is reused until one of them changes.
COPY pyproject.toml uv.lock ./
# --locked: install exactly what uv.lock says; fail if the lock is stale
# instead of re-resolving. Dependencies only, project source not copied yet.
RUN uv sync --locked --no-dev --no-install-project
# App source changes invalidate only the layers below this line.
COPY . .
RUN uv sync --locked --no-dev
# ---- runtime: no uv, no caches, no compilers ----
# Use the same Python base as the builder so the venv's interpreter path resolves.
FROM python:3.12-slim
WORKDIR /app
RUN useradd --create-home appuser
COPY --from=builder --chown=appuser:appuser /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER appuser
# Placeholder entrypoint; replace with your service's module.
CMD ["uvicorn", "myservice.main:app", "--host", "0.0.0.0", "--port", "8000"]
A multi-stage uv Dockerfile that pins uv through a required build arg, caches the dependency layer on pyproject.toml and uv.lock, installs with --locked so a stale lock fails the build, and ships only the venv and code. Confirm flags against your uv version.
When to use each tool and when to avoid it
All three tools can produce a reproducible lock. The real choice is how much surrounding workflow you want the tool to own, and how much churn you can absorb.
pip-tools suits teams that want the smallest possible footprint. Its output is a plain requirements.txt that pip, every base image and every security scanner already understand. The cost is that you assemble everything else yourself. You maintain the .in/.txt layering for dev and test constraints. You compile separately per platform or Python version. You also need something else to manage interpreters and virtualenvs.
Poetry fits teams already using it who value one command surface for adding, locking and installing. It is a weaker fit for services that are never published. You carry packaging metadata that serves no consumer. Resolution can be slow on large dependency graphs. Configuration conventions have shifted between major releases, so upgrading Poetry itself becomes a migration task.
uv fits when resolve and install speed matter, as in CI minutes and cold container builds. It also fits when you want one binary to handle interpreters, environments and locks. Hedge if you are wary of a young, fast-moving tool controlled by a single vendor. To limit that risk, pin the uv version you use and keep an exported requirements.txt as a fallback.
| Team situation | Recommended | Why |
|---|---|---|
| Minimal tooling, pip-native pipelines | pip-tools | Plain requirements output, few moving parts |
| Existing Poetry monorepo, stable workflow | Poetry | Switching costs exceed the gains |
| Slow CI, many services, mixed Python versions | uv | Fast installs, built-in interpreter management |
| Strict supply-chain or vendor-risk policy | pip-tools, or uv with export fallback | Lock stays readable by standard pip |
Impact on development cost and engineering time
Install speed is the most visible difference between these tools and the weakest reason to switch. A faster resolver and installer saves the most time when the CI cache is cold or the dependency graph is large. If your Dockerfile copies the lockfile and installs dependencies in a separate layer before copying source, most builds reuse that layer, and install time is near zero whichever tool you use. Measure your own pipeline before migrating for speed alone.
Illustrative estimate (assumed numbers, not a benchmark)
Daily runner minutes saved = runs per day × cold-cache fraction × minutes saved per cold install
Assume 200 runs per day, 15% cold-cache runs, and 2 minutes saved per cold install: 200 × 0.15 × 2 = 60 runner minutes per day. This is compute time. It becomes engineer time only when someone is blocked waiting on the pipeline.
Where the larger savings usually are
Reproducible locks save more time than speed does. A new transitive release cannot reach a build without a lockfile change, so "it worked yesterday" incidents become rare. Upgrades arrive as diffs that someone can review and bisect. Laptops, CI and production images resolve to the same versions, which removes a whole category of environment-drift debugging.
Hidden costs
- Two sources of truth. Mixing tools, such as a Poetry lock plus a hand-edited requirements file, gives you two sources of truth that will eventually disagree.
- Exported requirements files drift. They stay accurate only if CI regenerates them and fails the build on any diff.
- Hash enforcement needs maintenance. New platforms, private indexes and internal packages all need hashes. If nobody owns that work, someone will eventually disable the check to unblock a release.
Alternatives and a low-risk migration path
None of these tools is mandatory. The real baseline is plain pip with a hand-maintained requirements.txt of == pins. It works if the dependency tree is small and someone actually reviews transitive updates. It stops working once nobody can say why a given sub-dependency sits at its current version. pip-tools, Poetry and uv all replace that manual bookkeeping with a resolver, and you can swap any one of them for another.
The lowest-risk way to move between them uses requirements format as the bridge, because every tool here can emit it and pip can install it:
- Produce a requirements file from the candidate tool. Compile or export it from the same top-level dependencies you already declare.
- Diff resolved versions against the current lock. Treat any change as a dependency upgrade, even though you didn't ask for one. Different resolvers can make different valid choices.
- Run the full test suite and build the production image from the new file before anything else changes.
- Switch the source of truth only after both pass, then delete the old lock so there is a single authority.
Switching tools always means regenerating the lock. Review what resolved: new transitive packages, dropped markers, and changed hashes.
A common mixed setup is Poetry for local development, with an exported requirements file that the production image installs. This is reasonable, but it creates two artifacts that can drift apart. Add a CI step that re-runs the export and fails if the output differs from the committed file. Without that check, production can quietly run versions that nobody tested locally.
#!/usr/bin/env bash
# Two checks for a Poetry-local, requirements-in-production setup.
# Needs the poetry-plugin-export plugin, which recent Poetry releases no longer bundle.
set -euo pipefail
# Reduce a requirements file to sorted name==version lines.
# This drops comments, environment markers and hash continuation lines.
pins() {
grep -oE '^[A-Za-z0-9_.-]+==[^ ;\\]+' "$1" | tr 'A-Z_' 'a-z-' | sort
}
# Migration step: compare the candidate tool's output with the current lock.
# Usage: ./lockcheck.sh compare requirements.txt requirements.candidate.txt
# Every line printed here is a dependency change that needs review.
if [[ "${1:-}" == "compare" ]]; then
diff <(pins "$2") <(pins "$3") && echo "No resolved-version changes."
exit $?
fi
# CI step: fail if the committed requirements.txt has drifted from poetry.lock.
# First confirm that poetry.lock still matches pyproject.toml.
poetry check --lock
# Re-export into a temp file. Use the same flags that produced the committed file.
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
poetry export -f requirements.txt --output "$tmp"
# Compare the full files, hashes included, so production installs exactly what was tested.
if ! diff -u requirements.txt "$tmp"; then
echo "requirements.txt is stale: re-run 'poetry export' and commit the result." >&2
exit 1
fi
echo "requirements.txt matches poetry.lock."
A small script that diffs resolved versions between the current lock and a candidate tool's requirements output during migration, and in CI fails the build when the exported requirements.txt drifts from poetry.lock.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to uv vs poetry, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.