← Python Code Setup & Execution
Browse Python Concepts

uv vs pip vs poetry — Choosing a Dependency Tool

Mental Model

Think of pip with requirements.txt as a grocery list, where the store (PyPI) constantly updates its stock with newer versions of ingredients. A locking package manager, conversely, is like a precise recipe with exact ingredient batch numbers, ensuring you get the exact same result every time, regardless of what's new on the shelves.

Rule: Always use a locking package manager like uv or poetry to enforce deterministic, repeatable builds across all development and production environments.

The Setup

You are upgrading a Django monolith's build pipeline. The old raw pip installation is slow and occasionally installs conflicting sub-dependencies, resulting in non-deterministic builds.

What Does This Print?

Broken code
Python
# requirements.txt contains a single unpinned line:
#   flask
# No version pin. No lockfile. pip resolves "latest compatible" at install time.

# Day 1 — pip installs Flask 2.3.3 → pulls Werkzeug 2.3.7
# Day 30 — Werkzeug releases 3.0.0 (breaking change: removed url_quote)
# pip installs Flask 2.3.3 again → now pulls Werkzeug 3.0.0

# This is the error your CI sees on Day 30:
try:
    from werkzeug.urls import url_quote   # removed in Werkzeug 3.0
    print("Import OK")
except ImportError as e:
    print(f"Production crash: {e}")
    print("requirements.txt: 'flask'  (no lock — pip chose Werkzeug 3.0 today)")
What does this code print on Day 30, after pip resolves Werkzeug 3.0.0 instead of the Werkzeug 2.3.7 that was installed on Day 1?

The Output

What actually happens
Production crash: cannot import name 'url_quote' from 'werkzeug.urls' requirements.txt: 'flask' (no lock — pip chose Werkzeug 3.0 today)

This is a real breaking change that hit Flask apps in production in late 2023. Werkzeug 3.0 removed werkzeug.urls.url_quote. Any pip install flask after Werkzeug 3.0 shipped — without a lockfile — silently got the new, incompatible version. Code that ran perfectly on Day 1 crashed on Day 30 with no change to your codebase. This is the exact failure mode that lockfiles prevent: they record the resolved version of every dependency, direct and transitive, so future installs are byte-identical.

Why Python Does This

Python resolves imported modules by traversing paths listed in sys.path. When utilizing standard pip, package resolution is performed on-the-fly without maintaining a historical snapshot of the complete installation graph. This makes installations state-dependent. Modern tools like poetry resolve dependencies into a strict lock file by creating a directed acyclic graph of all parent and child packages. The uv package manager improves on this design by utilizing a fast solver written in Rust that operates on the pubgrub algorithm. It locks and builds environments deterministically, verifying the integrity of packages via cryptographic SHA-256 hashes to guarantee runtime code behaves identically.

The Fix

Corrected pattern
Python
# pyproject.toml with uv — exact lockfile pins every transitive dep
# Run: uv pip compile pyproject.toml -o requirements.lock

# pyproject.toml:
#   [project]
#   dependencies = [
#       "flask>=2.3.3,<3.0"   # pin the major version
#   ]

# requirements.lock (produced by uv — every dep, exact version, hash-verified):
#   flask==2.3.3       --hash=sha256:abc123...
#   werkzeug==2.3.7    --hash=sha256:def456...  <- pinned, never auto-upgrades
#   click==8.1.7       --hash=sha256:ghi789...

# Now verify the pin holds:
from packaging.requirements import Requirement

flask_pin = Requirement("flask>=2.3.3,<3.0")
print(f"Allowed: {flask_pin.specifier}")   # >=2.3.3,<3.0

# pip install -r requirements.lock installs werkzeug==2.3.7 forever.
# Werkzeug 3.0 cannot silently appear — the hash would not match.

Locking package managers generate a lockfile that records the exact version and hash of every single dependency (direct and transitive). This lockfile is then used to ensure that future installations fetch precisely those pinned versions, guaranteeing identical environments across all machines and deployments.

How This Fails in Real Systems

A critical hotfix build took 22 minutes to compile and install via pip on a production Kubernetes cluster. During this time, a broken transitive dependency of an analytics SDK was released, breaking the build and extending an active API outage for an additional 45 minutes until the team manually pinned the sub-dependency. Replacing the toolchain with uv cut build times to 12 seconds and guaranteed lockfile parity.

Key Takeaway

Always use a locking package manager like uv or poetry to enforce deterministic, repeatable builds across all development and production environments.
Common mistake: Developers assume that requirements.txt alone guarantees repeatable builds, not realizing that without a lockfile, pip will always fetch the latest compatible versions of dependencies, leading to non-deterministic outcomes over time.