The High Cost of Non-Deterministic Deploys
If you've ever had a Friday night deploy fail because a transitive dependency published a patch release that broke your build, you know the pain of unpinned environments. Python's default packaging ecosystem historically made reproducible builds surprisingly hard. Running pip install -r requirements.txt inside a Docker container without strict hash checking and pinned sub-dependencies is a ticking clock.
A simple pip freeze on your local macOS machine isn't enough either. It dumps platform-specific wheels and system bindings into a flat text file. When your CI/CD runner or Docker build on debian:12-slim attempts to install those exact pins, compile steps fail or C extensions throw OSError: wrong ELF class. We need true lockfiles that track full dependency graphs, cross-platform hashes, and Python runtime bounds.
Standard venv + pip-tools: Cheap but Manual
Python 3.3 introduced the native venv module. It creates lightweight isolated environments, but offers zero dependency resolution out of the box. To get lockfile support with native virtual environments, teams usually turn to pip-tools (specifically pip-compile).
With this workflow, you define top-level dependencies in a requirements.in file and generate a fully resolved requirements.txt with explicit hashes.
# requirements.in
fastapi==0.115.0
pydantic==2.9.2
uvicorn[standard]==0.31.0
gunicorn==23.0.0
# Command to generate reproducible lockfile:
# pip-compile --generate-hashes --output-file=requirements.txt requirements.in
The resulting requirements.txt looks like this:
#
# This file is autogenerated by pip-compile with Python 3.12
#
fastapi==0.115.0 \
--hash=sha256:7f45c815e982189912061298812678129...
annotated-types==0.7.0 \
--hash=sha256:1f02e8b43d...
# via pydantic
pydantic==2.9.2 \
--hash=sha256:8891024a10...
# via fastapi
pydantic-core==2.23.4 \
--hash=sha256:491823101...
# via pydantic
This approach works, but it's slow. Resolving a moderate dependency graph with 30 packages using pip-compile takes around 25 to 40 seconds on standard CI workers. Worse, managing separate dev and production dependencies requires maintaining multiple .in files or manual chaining. If someone runs pip install directly inside the virtual environment without running pip-compile, the state diverges immediately.
Poetry: Complete Ecosystem, High Overhead
Poetry entered the ecosystem to solve package management holistically. It uses pyproject.toml (defined in PEP 518 and PEP 621) and introduces a deterministic poetry.lock file. Poetry doesn't rely on pip for dependency resolution; it ships its own custom resolver written in Python.
[tool.poetry]
name = "payment-service"
version = "1.4.0"
description = "Internal payment processing backend"
authors = ["Engineering <eng@example.com>"]
readme = "README.md"
packages = [{include = "payment_service"}]
[tool.poetry.dependencies]
python = "^3.12"
fastapi = "0.115.0"
pydantic = "2.9.2"
redis = "^5.0.8"
[tool.poetry.group.dev.dependencies]
pytest = "^8.3.3"
httpx = "^0.27.2"
ruff = "^0.6.8"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Locking dependencies with poetry lock generates a multi-platform lockfile containing exact package metadata and content hashes. Running poetry install --sync in production ensures that extraneous packages removed from the lockfile get uninstalled from the environment.
However, Poetry brings notable friction to server applications:
- Resolution speed: Because the resolver runs in pure Python, resolving complex constraint conflicts across packages with native C extensions (like PyTorch, NumPy, or cryptography) can take minutes or time out entirely on low-spec CI runners.
- Docker layer bloat: Installing Poetry inside a container image requires installing Poetry's own dependencies first. You end up needing pip, poetry, wheel, and build tools in your base layer, ballooning image sizes by 150MB+.
- Non-standard lockfile: The
poetry.lockformat is proprietary to Poetry. Other tools cannot consume it directly without conversion scripts.
uv: The Fast Rust Engine for Deterministic Builds
Astral released uv as a drop-in replacement for pip and pip-tools, written in Rust. In version 0.5.x, uv evolved into a full project and workspace manager that supersedes Poetry and pip-tools while maintaining compatibility with standard pyproject.toml and requirements.txt formats.
uv handles virtual environment creation, resolution, locking, and execution in milliseconds. Resolving a complex dependency tree that takes 35 seconds in Poetry takes under 150 milliseconds with uv lock.
Here is an optimized, multi-stage Dockerfile using uv 0.5 for a production Python 3.12 FastAPI service:
# Stage 1: Build virtualenv with locked dependencies
FROM python:3.12-slim AS builder
# Copy uv binary directly from official image
COPY --from=ghcr.io/astral-sh/uv:0.5.1 /uv /uvx /bin/
WORKDIR /app
# Enable bytecode compilation and copy-based environment creation
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
# Copy dependency specifications first for Docker layer caching
COPY pyproject.toml uv.lock ./
# Install dependencies into /app/.venv without project source
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev
# Copy application source code
COPY . .
# Sync the project itself into the venv
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
# Stage 2: Minimal runtime image
FROM python:3.12-slim AS runner
WORKDIR /app
# Copy virtual environment from builder stage
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src
# Use the virtualenv binaries directly
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Notice what happens here. We copy the compiled /app/.venv directly into a clean runtime stage. The final container image contains no compiler tools, no package managers, and no build artifacts. The build step finishes in under 3 seconds when cached and roughly 8 seconds on cold CI runners.
Comparing Resolution, Speed, and Reproducibility
To evaluate these options for backend web services, we measured resolution time, build repeatability, and developer ergonomic friction across a typical API service stack containing FastAPI, Pydantic, SQLAlchemy, Alembic, Redis, and Pytest.
Here is how the three approaches stack up on Python 3.12:
- Standard venv + pip-tools: Cold resolve time: ~32s. Lockfile format:
requirements.txtwith hashes. Platform support: Requires cross-platform resolution flags or runs natively on target OS. Tool footprint: ~20MB. - Poetry 1.8: Cold resolve time: ~28s. Lockfile format: Proprietary
poetry.lock. Platform support: Full universal resolution stored in lockfile. Tool footprint: ~120MB. - uv 0.5: Cold resolve time: ~0.18s (over 150x faster). Lockfile format: Standardized
uv.lock(human-readable TOML). Platform support: Universal cross-platform lockfile generated by default. Tool footprint: Single binary (~40MB, zero external Python deps).
Cross-Platform Wheel Gotchas
One trap that routinely catches teams switching between macOS (Apple Silicon) local dev environments and Linux x86_64 production servers is platform-specific binary wheels. Packages like uvloop, psycopg2-binary, or pydantic-core provide pre-compiled wheels for specific architectures.
When you generate a lockfile on macOS using naive tools, the lockfile might only pin the macosx_11_0_arm64 wheel hash. When deployed to a Linux container, installation fails because the hash for manylinux2014_x86_64 is missing from the lock file.
Poetry handles this by recording all platform hashes in poetry.lock. uv does the same in uv.lock by default without extra flags. If you stick with pip-compile, you must explicitly pass target platforms or generate requirements files inside a matching Linux Docker container using commands like:
pip-compile --generate-hashes --platform linux_x86_64 --python-version 3.12 -o requirements-prod.txt requirements.in
Missing this flag is the leading cause of No matching distribution found errors during production deployment pipelines.
Which Tool Should You Choose?
For backend services, APIs, and microservices in 2025, uv is the clear choice. It eliminates the main trade-off in Python dependency management: speed versus correctness. You get deterministic cross-platform lockfiles, standard pyproject.toml support, and sub-second CI resolution times.
Poetry remains viable if you maintain open-source Python libraries that publish directly to PyPI, where Poetry's build-backend mature plugin system and publishing workflows are established. But for standalone applications and services, Poetry's slow dependency resolution and large container footprint are unnecessary liabilities.
Raw venv combined with pip-tools should be considered legacy. It requires too much manual boilerplate to reach the same level of safety that uv lock provides out of the box in a fraction of a second.













