Skip to content

Packaging & Dependency Management with uv

What this lesson gives you

Python's packaging story is finally good. uv installs packages 10–100× faster than pip, manages virtual environments, locks dependencies, and consolidates your entire workflow into pyproject.toml.

Estimated time: 20 min read · Part: Python Tooling & Data Fundamentals

Learning Objectives

After this lesson you will be able to: (1) explain why virtual environments and lockfiles exist and what problem each solves; (2) read and write a production-grade pyproject.toml; (3) use core uv commands to create, sync, and update a reproducible environment; (4) define dependency groups for dev and production; (5) explain how private registries fit into enterprise Python workflows.

Why Python Needs a Dependency Manager

Every Python project accumulates third-party packages. Left unmanaged, packages from different projects conflict on the same system: Project A needs pydantic==1.x; Project B needs pydantic==2.x. Two problems compound this. First, isolation: each project needs its own private Python environment so packages cannot collide. Second, reproducibility: running pip install on Tuesday may resolve different versions than on Wednesday as new releases appear. A lock file captures the exact resolution so that CI, a colleague's machine, and production all install byte-identical packages.

Before uv, the ecosystem was fragmented: pip (ubiquitous but no lock file), pipenv (slow resolver, abandoned for years), poetry (good UX but resolver problems at scale), and conda (a different model for scientific computing). uv — written in Rust by the Astral team (who also built ruff) — resolves all the performance complaints. Its resolver runs in milliseconds on a warm cache, it speaks pip's protocols, and it replaces pip, virtualenv, pipenv, and poetry in a single binary with a coherent CLI.

A Brief History of Python Packaging

Python packaging accumulated layers: distutils (stdlib, ancient), then setuptools and setup.py, then pip in 2008, then wheels and PyPI, then PEP 517/518 introducing pyproject.toml in 2016–2018, then poetry and pipenv as higher-level tools. uv emerged in 2024 and reached community dominance within a year. If a client still uses setup.py with a flat requirements.txt, migrating to pyproject.toml plus uv typically cuts CI install times from several minutes to under thirty seconds — a concrete, easy win to lead with.

Lockfile as a Reproducible Recipe

Think of pyproject.toml as a recipe listing ingredients — "flour, sugar, butter." uv.lock is the production sheet: "King Arthur bread flour, 5 lb, lot 2024-03-10." Anyone reading the production sheet gets exactly the same dish every time. The recipe is what humans author; the lock file is generated by the resolver and committed to version control so that any build, anywhere, reproduces the environment exactly — years later.

pyproject.toml: The Modern Manifest

PEP 517/518 standardized pyproject.toml as the single configuration file for Python projects. It consolidates what used to require setup.py, setup.cfg, MANIFEST.in, tox.ini, .flake8, and mypy.ini into one structured TOML file. Here is a production-grade example for a data-processing service:

pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "acme-data-pipeline"
version = "1.3.0"
description = "ETL pipeline for ACME customer data platform"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Dipayan Dhar", email = "dipayan@acme.io" }]

# Version constraints, not pins — lets the resolver find compatible versions
dependencies = [
    "httpx>=0.27",
    "pandas>=2.2",
    "pydantic>=2.7",
    "sqlalchemy>=2.0",
    "structlog>=24.0",
]

[project.optional-dependencies]
spark = ["pyspark>=3.5"]          # installed with: pip install acme-data-pipeline[spark]

[project.scripts]
acme-pipeline = "acme_data_pipeline.cli:main"   # creates a CLI entry point

[project.urls]
Homepage  = "https://github.com/acme/data-pipeline"

# Dev/test tools — not shipped with the package
[dependency-groups]
dev = [
    "pytest>=8.2",
    "pytest-cov>=5.0",
    "pytest-asyncio>=0.23",
    "mypy>=1.10",
    "ruff>=0.4",
    "pre-commit>=3.7",
]

# Tool configuration consolidated here — no separate .flake8, mypy.ini, etc.
[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "C4", "PIE", "RET"]

[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts   = "-v --tb=short --cov=src --cov-report=term-missing"

[tool.coverage.run]
branch = true
source = ["src"]
Section Purpose Required?
[build-system] Declares the build backend (hatchling, setuptools, flit) used to produce wheels For packages
[project] Package metadata: name, version, description, Python requirement, runtime dependencies Yes
[project.optional-dependencies] Extras installed with pkg[extra] — for optional heavyweight deps (Spark, ML, etc.) No
[project.scripts] Console entry-points installed as CLI commands in the venv's bin/ No
[dependency-groups] Dev/test deps not shipped with the package; newer and cleaner than extras for this use case No
[tool.*] Config for ruff, mypy, pytest, coverage — replaces per-tool config files No

uv Core Commands

uv is distributed as a single static binary — no Python required to install it. The following workflow covers the full lifecycle from project initialization through daily development to CI:

terminal
# Bootstrap a new project (creates pyproject.toml, src/ layout, .python-version)
uv init acme-data-pipeline
cd acme-data-pipeline

# Add runtime dependencies — updates pyproject.toml AND uv.lock atomically
uv add httpx "pandas>=2.2" pydantic sqlalchemy

# Add dev-only tools to [dependency-groups]
uv add --dev pytest pytest-cov ruff mypy pre-commit

# Create / update .venv and install everything from uv.lock (exact versions)
uv sync                       # all deps + dev group
uv sync --no-dev              # runtime only  (for production Docker images)
uv sync --frozen              # CI: fail if lockfile would need to change

# Run any command inside the managed venv without activating it
uv run pytest                 # = .venv/bin/pytest
uv run python src/acme_data_pipeline/cli.py --help
uv run mypy src/

# Upgrade a dep (updates constraint in pyproject.toml AND re-locks)
uv add "pandas>=2.3"

# Export a requirements.txt for legacy tools that still need it
uv export --no-dev -o requirements.txt

Resolved 47 packages in 284ms Installed 47 packages in 1.1s

uv run vs activating the venv

uv run <command> is the modern preferred pattern: it ensures the correct venv is active, creates it if absent, syncs if needed, and doesn't pollute shell state. In CI the pattern uv sync --frozen && uv run pytest is both readable and fully reproducible. You should almost never need to run source .venv/bin/activate manually.

Command What it does
uv init Scaffold a new project with pyproject.toml and src/ layout
uv add <pkg> Add runtime dependency, update lockfile
uv add --dev <pkg> Add to dev dependency group
uv sync Create/update .venv; install exact versions from uv.lock
uv sync --frozen Install from lockfile; fail if lockfile would change (CI gate)
uv sync --no-dev Install runtime deps only (production images)
uv run <cmd> Execute inside the managed venv without activating
uv lock Re-resolve and regenerate uv.lock without installing
uv build Produce sdist and wheel in dist/
uv publish Upload to PyPI or a private registry
uv python install 3.12 Download and manage Python interpreters
uv python pin 3.12 Write .python-version to lock interpreter version

Lock Files, Reproducibility & the --frozen Flag

uv generates a uv.lock file recording every package and every transitive dependency at exact versions, with file hashes for integrity verification. This file must be committed to version control. In CI, uv sync --frozen asserts that the lockfile is consistent with pyproject.toml — if someone edited the manifest without regenerating the lock, the frozen flag fails the build rather than silently installing different packages. This is the difference between "it worked in dev" and "we can reproduce the exact environment of every deployed build."

Why Both pyproject.toml and uv.lock?

They serve different audiences. pyproject.toml declares constraints for humans: "we need httpx at least 0.27." uv.lock records the exact resolution for machines: "httpx 0.27.2, sha256=7e2a5a…" The manifest is intentionally flexible — useful when your package is a library that needs to compose with user environments. The lockfile is pinned — essential for applications where you need identical bytes on every machine.

Don't Use requirements.txt for New Projects

requirements.txt has no understanding of dependency groups, extras, build metadata, or transitive-dependency hashing. It was designed as an export format for pip, not a manifest. For any new project start with pyproject.toml and uv. The only valid reason to keep generating a requirements.txt is interoperability with legacy deployment scripts that don't yet understand pyproject.toml — and even then, generate it from uv with uv export rather than maintaining it by hand.

Docker & Private Registries

In a Docker image, use uv sync --frozen --no-dev to install only runtime dependencies from the locked file. Copying the lockfile before the source code exploits Docker's layer cache — a dependency layer change only occurs when pyproject.toml or uv.lock changes, not when you edit a Python file:

Dockerfile (uv install pattern)
FROM python:3.12-slim
WORKDIR /app

# Install uv into the image
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

# Copy manifests first — cached layer for slow dependency installs
COPY pyproject.toml uv.lock ./

# Install runtime deps only, using exact lockfile versions
RUN uv sync --frozen --no-dev --no-install-project

# Now copy source (this layer changes on every code edit)
COPY src/ ./src/

# Install the project itself (fast — deps already in place)
RUN uv sync --frozen --no-dev

CMD ["uv", "run", "uvicorn", "acme_data_pipeline.api:app",
     "--host", "0.0.0.0", "--port", "8000"]

Large enterprises run a private package registry (Artifactory, Nexus, AWS CodeArtifact) that proxies PyPI and hosts internal packages. This solves three problems: every package is scanned for CVEs before installation; air-gapped or regulated environments need packages from a controlled source; and internal libraries are distributed as versioned packages. Configure uv to use it with an index URL:

pyproject.toml (private registry)
[[tool.uv.index]]
name = "acme-artifactory"
url  = "https://artifactory.acme.io/artifactory/api/pypi/pypi-local/simple/"
default = true   # use instead of PyPI

# Credentials come from env vars UV_INDEX_ACME_ARTIFACTORY_USERNAME / _PASSWORD
# Never hard-code credentials in pyproject.toml

Consulting Lens: Private Registries

When onboarding a new client's Python environment, check for a private registry configuration early — missing it is the most common cause of mysterious "package not found" failures in their CI. For a client's security team, being able to say "every Python package our pipeline installs has been scanned and approved in our internal registry, and uv.lock includes SHA-256 hashes that are verified on every install" is a concrete answer to supply-chain risk questions. The migration pitch for legacy projects is simple: same packages, same code, CI install time drops from 3 minutes to 20 seconds.

Project structure with src/ layout:
acme-data-pipeline/
├── pyproject.toml       ← manifest + all tool config
├── uv.lock              ← committed lockfile (exact deps + hashes)
├── .python-version      ← "3.12" (pins interpreter)
├── .dockerignore
├── Dockerfile
├── src/
│   └── acme_data_pipeline/
│       ├── __init__.py
│       ├── pipeline.py  ← core logic
│       ├── cli.py       ← entry point
│       └── models.py
└── tests/
    ├── conftest.py      ← shared fixtures
    └── test_pipeline.py

WHY the problem being solved Without isolation, two projects on one machine can't use different versions of the same library. Without a lockfile, pip install resolves different packages at different times — silently breaking reproducibility. Virtual environments solve isolation; lockfiles solve reproducibility.

HOW the uv workflow Run uv init once. Add deps with uv add. Commit both pyproject.toml and uv.lock. Use uv run everywhere — no manual venv activation. In CI: uv sync --frozen && uv run pytest. In Docker: uv sync --frozen --no-dev.

WHERE every Python repo Every project from a one-file script to a multi-service monorepo. In consulting contexts, packaging analysis as a proper versioned Python package makes client handoff clean and reproducible — they can pip install acme-analysis==1.2.0 six months later and get the same results.

Knowledge check

InterviewA colleague asks why they should commit uv.lock when it is auto-generated and can always be regenerated. What is the correct answer?

  • There is no reason — lockfiles are build artifacts and should be gitignored.
  • The lockfile records exact versions and SHA-256 hashes of every transitive dependency, guaranteeing that CI, staging, and production install identical packages — not whatever the resolver returns on that day.
  • Committing the lockfile is only necessary for packages published to PyPI.
  • It prevents other developers from adding dependencies.
Answer

The lockfile records exact versions and SHA-256 hashes of every transitive dependency, guaranteeing that CI, staging, and production install identical packages — not whatever the resolver returns on that day.

Without a committed lockfile, uv sync resolves the newest compatible versions at that moment — which may differ from what you tested. A committed lockfile makes every developer, every CI run, and every deployment identical. The --frozen flag in CI additionally asserts the lockfile is consistent with pyproject.toml, making any drift an explicit build failure rather than a silent mismatch.

Knowledge check

ConceptWhat is the difference between a dependency constraint in pyproject.toml and a pinned entry in uv.lock?

  • They contain identical information; pyproject.toml is just human-readable.
  • pyproject.toml declares a version range for library compatibility; uv.lock records the exact resolved version and hash for reproducibility.
  • pyproject.toml is for runtime deps only; uv.lock includes dev deps as well.
  • uv.lock is only used for CI; pyproject.toml is used in production.
Answer

pyproject.toml declares a version range for library compatibility; uv.lock records the exact resolved version and hash for reproducibility.

pyproject.toml has human-authored constraints like "httpx>=0.27" — flexible enough for the resolver to pick compatible versions. uv.lock is the resolver's output: httpx==0.27.2, sha256=7e2a5a... — a precise, repeatable snapshot. Library authors write loose constraints to be compatible with users' environments; application developers commit the lockfile to guarantee reproducibility across every deployment.

Knowledge check

PracticalIn a Dockerfile, you copy pyproject.toml and uv.lock and run uv sync --frozen --no-dev before copying the source code. Why this order?

  • Docker requires manifests to be present before source files or the build fails.
  • Dependencies change rarely; source code changes on every edit. Copying deps first means the expensive install layer is cached and reused on code-only changes — rebuilds go from minutes to seconds.
  • It reduces the final image size by merging layers.
  • The --frozen flag only works when manifests are copied before source.
Answer

Dependencies change rarely; source code changes on every edit. Copying deps first means the expensive install layer is cached and reused on code-only changes — rebuilds go from minutes to seconds.

Docker invalidates the cache of a layer whenever its inputs change. By installing dependencies in a layer that only depends on pyproject.toml and uv.lock — which change rarely — Docker can reuse that cached layer on the next build when only the Python source changed. This ordering (least-volatile to most-volatile) is the single biggest Dockerfile performance optimization.

Exercise

Create a pyproject.toml for a new HTTP data-ingestion service. Requirements: (1) runtime deps: httpx, pydantic, structlog; (2) a dev group with pytest, ruff, mypy; (3) a [project.scripts] entry point called ingest; (4) ruff configured for Python 3.11 with line length 100; (5) mypy in strict mode. Then write the four uv commands you would run to initialize the project, add all dependencies, run tests, and prepare it for a production Docker image.

Show solution
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "data-ingestor"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "httpx>=0.27",
    "pydantic>=2.7",
    "structlog>=24.0",
]

[project.scripts]
ingest = "data_ingestor.cli:main"

[dependency-groups]
dev = ["pytest>=8.2", "pytest-cov>=5.0", "ruff>=0.4", "mypy>=1.10"]

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.mypy]
python_version = "3.11"
strict = true

# Commands:
# uv init data-ingestor
# uv add httpx "pydantic>=2.7" structlog
# uv add --dev pytest pytest-cov ruff mypy
# uv run pytest
# uv sync --frozen --no-dev  (in Dockerfile for production image)

Key Takeaways

  • Virtual environments isolate per-project dependencies; lockfiles freeze exact versions for reproducibility.
  • pyproject.toml is the single source of truth: metadata, dependencies, and tool config all live here.
  • uv replaces pip + venv + poetry in a single Rust-powered binary — 10–100× faster.
  • Commit uv.lock; use uv sync --frozen in CI; --no-dev in production images.
  • Private registries (Artifactory, CodeArtifact) are the enterprise standard for security and compliance.