Python Advanced

Packaging Python for Production: uv, pyproject, Wheels, Lockfiles, and Private Indexes

Reproducible installs, fast CI, and shared internal libraries. Move from requirements.txt to pyproject and a lockfile, build wheels, publish to a private index, and stop hoping the build resolves the same twice.

DjangoZen Team Sep 05, 2026 19 min read 14 views

A requirements.txt without pinned versions is a promise that today's build and tomorrow's build will be the same. It is not a promise Python can keep. Packaging is the part of the toolchain that decides whether "it worked yesterday" is a fact you can rely on or a coincidence you got used to.

Why unpinned installs eventually fail

Django>=5.2
celery
requests

This installs different code every week. Your dependencies have dependencies, and a patch release three levels down can change behaviour. Two developers running the same command a day apart get different trees, and CI gets a third. When something breaks, nothing in git changed — which is the worst possible debugging position.

The fix has two layers, and conflating them is the usual mistake:

  • Declared dependencies — what your project needs, with ranges. Human-written, in pyproject.toml.
  • The lockfile — every package in the resolved tree, exact version, with hashes. Machine-generated, committed, installed verbatim.

pyproject.toml is the modern manifest

[project]
name = "myshop"
version = "2.4.0"
requires-python = ">=3.12"
dependencies = [
    "Django>=5.2,<6.0",
    "psycopg[binary]>=3.2",
    "celery>=5.4",
    "redis>=5.2",
]

[project.optional-dependencies]
dev = ["pytest>=8.3", "pytest-django>=4.9", "ruff>=0.8", "mypy>=1.14"]

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

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

One file replaces setup.py, requirements.txt, requirements-dev.txt and most tool configuration. The version ranges express intent: >=5.2,<6.0 says "any 5.x, never 6.0 by accident". Upper bounds on the major version only — capping minor versions turns every upgrade into a manual chore and blocks security patches.

uv: the resolver that changed the timings

uv is a package manager written in Rust that resolves and installs dramatically faster than pip — the practical effect being that CI installs stop being something you optimise around.

uv sync                       # create venv + install from the lockfile
uv add "django>=5.2,<6.0"     # add a dependency and update the lock
uv add --dev pytest
uv lock --upgrade-package django
uv run pytest                 # run inside the environment, no activation

uv.lock holds the entire resolved graph with hashes, for every platform you target. Commit it. Two important properties follow: an install can be verified against the hashes (so a compromised mirror cannot substitute a package), and it resolves identically on macOS, Linux and CI.

Migrating an existing project is usually two commands:

uv init --bare
uv add -r requirements.txt

Read the resulting pyproject.toml afterwards. It will list transitive packages your old file pinned by habit — those belong to the lockfile, not to your declared dependencies.

Wheels, and why sdists slow you down

A wheel (.whl) is a built, ready-to-unpack archive. An sdist is source that must be compiled at install time. If a package with C extensions has no wheel for your platform, every install compiles it — which is why one container build takes four minutes and another takes twenty.

Practical consequences:

  • Prefer packages shipping wheels for your Python version and platform. psycopg[binary] instead of building against system libpq, unless you have a reason.
  • Pin your Python minor version in Docker. Wheels are built per version tag; moving from 3.12 to 3.13 can silently switch you to source builds.
  • If you must build from source, do it in a builder stage and copy the result — not in the runtime image.

Your own library, shared across projects

Once you run several Django projects, the same code appears in all of them: a house style for models, email helpers, an audit-log mixin. That is a package.

[project]
name = "acme-django-common"
version = "1.3.0"
dependencies = ["Django>=5.2,<6.0"]

[tool.hatch.build.targets.wheel]
packages = ["src/acme_common"]
uv build          # produces dist/*.whl and dist/*.tar.gz

Three rules keep an internal library from becoming a liability:

  • Semantic versioning, honestly applied. A breaking change is a major bump, even when it is "obviously fine".
  • Keep the dependency list thin. Every dependency you add is inherited by every consumer, and conflicts surface in projects that never asked for it.
  • No Django settings imports at module import time. A shared package that touches settings on import cannot be used in management commands or tests that configure settings later.

Publishing to a private index

You do not need a server. A directory of wheels served over HTTPS is a valid index, and most artifact registries speak the protocol.

[[tool.uv.index]]
name = "internal"
url = "https://pypi.internal.example.com/simple/"
UV_INDEX_INTERNAL_USERNAME=ci
UV_INDEX_INTERNAL_PASSWORD=${REGISTRY_TOKEN}

The dependency-confusion trap deserves a paragraph. If your internal package name also exists on public PyPI and your resolver may choose either, an attacker can publish a higher version publicly and get it installed. Defences: give internal packages a distinctive prefix you own, and pin each private package to its index explicitly rather than letting the resolver pick. Do not rely on ordering.

Docker builds that stay fast

FROM python:3.13-slim AS base
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY . .
RUN uv sync --frozen --no-dev
ENV PATH="/app/.venv/bin:$PATH"
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]

--frozen is the important flag: install the lockfile exactly, and fail if it disagrees with pyproject.toml. Without it, a build can quietly resolve something new — which defeats the entire point of locking. The two-step copy keeps the dependency layer cached across source-only changes.

Upgrading on purpose

uv lock --upgrade                     # everything, deliberately
uv lock --upgrade-package django      # one package
uv run pip-audit                      # known vulnerabilities in the tree

A workable rhythm: security patches when reported, a full --upgrade monthly on a branch with tests, and major versions read the changelog first. Automated dependency pull requests work well if your test suite is trustworthy; without one, they are a queue of unreviewable diffs.

Do not chase the newest release of everything. Do not sit three years behind either — the upgrade you postpone becomes the upgrade you cannot do.

What good looks like

  • Dependencies declared in pyproject.toml with major-version bounds only.
  • A lockfile with hashes, committed, installed with --frozen.
  • Python minor version pinned in the image.
  • Wheels preferred; source builds confined to a builder stage.
  • Shared code in a versioned internal package, not copied between repos.
  • Private packages namespaced and pinned to their index.
  • A scheduled audit and a monthly upgrade branch.

None of this is glamorous, and all of it is invisible when it works. That is the point: packaging done properly is the difference between a deploy you watch nervously and a deploy you do not think about.