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.

KC Ramo · DjangoZen Team Sep 05, 2026 19 min read 231 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.

What a lockfile entry actually records

It helps to look inside one. A single package in uv.lock is a TOML table that pins the exact version, the index it came from, the source distribution and every wheel the resolver considered valid for your targets, each with a SHA-256 hash:

[[package]]
name = "asgiref"
version = "3.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/.../asgiref-3.8.1.tar.gz", hash = "sha256:...", size = 36186 }
wheels = [
    { url = "https://files.pythonhosted.org/.../asgiref-3.8.1-py3-none-any.whl", hash = "sha256:...", size = 23828 },
]

Three details matter in production. The source line records which index supplied the package, so a lockfile diff reveals when a dependency silently moved from your private registry to public PyPI. The hashes mean the installer refuses a file whose bytes differ from what was locked, even if the version number matches. And because every candidate wheel is listed, the same lockfile serves a developer's ARM laptop and an x86-64 production container without re-resolving. Treat the file as generated output: review its diff in pull requests, but never edit it by hand.

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.

Dependency groups versus optional extras

The example above puts development tools in [project.optional-dependencies]. That works, but it publishes them: anyone installing myshop[dev] from your index gets pytest and mypy, and the extra becomes part of your package metadata. Extras are meant for optional runtime features a consumer may opt into, such as psycopg[binary] or celery[redis].

Tools that only the project's own developers need belong in dependency groups (PEP 735), which are never included in built wheels. uv add --dev pytest writes to this table, not to the extras:

[dependency-groups]
dev = ["pytest>=8.3", "pytest-django>=4.9", "ruff>=0.8", "mypy>=1.14"]
docs = ["mkdocs>=1.6"]

uv sync installs the dev group by default; --no-dev excludes it, --group docs adds another group and --only-group docs installs that group alone, which is handy for a documentation build job that does not need Django at all. If you migrate an older project, move the dev extra into a group so the published metadata stops advertising your test tooling.

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.

lock, sync, --locked and --frozen

Most confusion with uv comes from a handful of related operations. uv lock resolves pyproject.toml into uv.lock. uv sync makes the virtual environment match the lockfile, locking first if needed. Two flags control what happens when the lockfile and manifest disagree:

CommandRe-resolves?If uv.lock is staleWhere to use it
uv syncYes, when neededUpdates the lockfile silentlyLocal development
uv sync --lockedChecks onlyFails with an errorCI test jobs: proves the committed lock is current
uv sync --frozenNoInstalls the lock as-is, without checkingDocker builds and deploys

The distinction is useful: run --locked once in CI so a developer who edited pyproject.toml without re-locking gets a red build, then use --frozen in image builds where speed matters and the check has already happened. uv run accepts the same flags, so uv run --locked pytest both verifies and tests.

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.

Reading a wheel filename

The filename tells you exactly where a wheel can be installed. Take psycopg_binary-3.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

PartValueMeaning
Distribution, versionpsycopg_binary-3.2.3Package name and release
Python tagcp312CPython 3.12 only
ABI tagcp312Compiled against the 3.12 C ABI; abi3 would mean the stable ABI, usable on later versions too
Platform tagmanylinux_2_17_x86_64Linux with glibc 2.17 or newer, on x86-64

A pure-Python wheel says py3-none-any and installs anywhere. The platform tag is the one that bites in containers. manylinux wheels require glibc, so they cannot be used on Alpine, which uses musl; Alpine needs musllinux wheels, and packages that do not publish them fall back to source builds. That is the main reason python:3.x-slim (Debian-based) usually builds faster and more predictably than Alpine for Python services, despite Alpine's smaller base.

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.

Test the wheel, not the checkout

Packaging bugs — a forgotten template directory, a migration missing from the archive, a py.typed marker left out — only show up after installation. A short release check catches them:

rm -rf dist/
uv build
python -m zipfile -l dist/acme_django_common-*.whl | grep -E "templates|migrations|py.typed"

uv venv /tmp/wheelcheck
uv pip install --python /tmp/wheelcheck/bin/python dist/acme_django_common-*.whl
/tmp/wheelcheck/bin/python -c "import acme_common; print(acme_common.__file__)"

The final line should print a path inside site-packages, not inside your repository. For a Django library, run the test suite against the installed wheel in CI at least once per release. With hatchling, non-Python files inside the package directory are included by default, but files excluded by .gitignore are not, which surprises people who generate assets during the build.

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.

Pinning packages to their index in uv

In uv, that pinning looks like this:

[[tool.uv.index]]
name = "internal"
url = "https://pypi.internal.example.com/simple/"
explicit = true

[tool.uv.sources]
acme-django-common = { index = "internal" }
acme-billing = { index = "internal" }

explicit = true means the index is consulted only for packages that name it in [tool.uv.sources], so nothing else can accidentally come from it, and the pinned packages are fetched from that index alone. uv also defaults to a first-index strategy: once a package name is found on one index, it does not look for higher versions on later ones. That default is a meaningful protection, which is why you should be wary of --index-strategy unsafe-best-match. It exists to mimic pip's behaviour of merging candidates across indexes, and that merging is precisely what dependency confusion relies on. As further safeguards, register your internal prefix names on public PyPI as placeholder projects if your organisation's policy allows it, and review the source lines in lockfile diffs for any internal name that suddenly points at pypi.org.

Publishing

uv publish uploads everything in dist/ to any registry that implements the standard upload API. Keep the token in the CI secret store and pass it through the environment:

uv build
UV_PUBLISH_TOKEN="${REGISTRY_TOKEN}" \
  uv publish --publish-url https://pypi.internal.example.com/upload/

Registries generally refuse to overwrite an existing version, and that is a feature: a version number must always refer to the same bytes, or every lockfile hash that references it breaks. If a release is bad, publish a new patch version; if your registry supports yanking, yank the bad one so resolvers skip it unless it is pinned exactly.

Credentials without leaks

Never embed credentials in the index URL inside pyproject.toml; the file is committed, and index URLs are also recorded in uv.lock. Use the per-index environment variables shown above, a .netrc file on developer machines, or a keyring provider. In Docker, pass the token as a build secret rather than a build argument, because build arguments are persisted in the image history:

RUN --mount=type=secret,id=registry_token,env=UV_INDEX_INTERNAL_PASSWORD \
    UV_INDEX_INTERNAL_USERNAME=ci uv sync --frozen --no-dev --no-install-project

Use a read-only token scoped to the registry for installs, and a separate write-scoped token that only the release job can see for publishing. If a read token leaks, the damage is disclosure of internal code; if a publish token leaks, an attacker can ship code into every consumer, so rotate it on any suspicion and audit recent uploads.

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:0.8 /uv /usr/local/bin/uv   # pin a uv version you have tested

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.

Cache mounts and a slim runtime stage

Layer caching helps only when the dependency layer is unchanged. When it is invalidated, a BuildKit cache mount lets uv reuse downloaded wheels across builds anyway, and a second stage keeps build tooling out of the image you ship:

FROM python:3.12-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /usr/local/bin/uv
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-dev --no-install-project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev --no-editable

FROM python:3.12-slim
RUN useradd --system --create-home deploy
WORKDIR /app
COPY --from=builder --chown=deploy:deploy /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER deploy
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]

Several details here are load-bearing. UV_LINK_MODE=copy is needed with a cache mount, because the cache lives on a different filesystem and hard links cannot cross it. UV_PYTHON_DOWNLOADS=never forces uv to use the image's interpreter rather than fetching its own, so both stages share exactly the same Python. The virtual environment contains absolute paths, so it must live at the same location (/app/.venv) in both stages, and the base images must match on Python minor version. --no-editable installs your project as a regular package inside the venv, which matters if you later copy only the .venv directory into the runtime stage. Finally, pin the uv image to a version tag you have tested rather than latest, so a new uv release cannot change your build on a day you did not choose.

Upgrading on purpose

uv lock --upgrade                     # everything, deliberately
uv lock --upgrade-package django      # one package
uvx 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.

Reviewing a lock diff

An upgrade pull request is only reviewable if you know what to look for in uv.lock. Scan it for four things: major-version jumps in any package, including transitive ones; new packages that appeared in the tree, since each one is new code you now run; packages whose source changed; and removed wheels for your platform, which foreshadow source builds. A plain git diff --stat uv.lock tells you the size of the change; for a monthly upgrade, a few dozen changed lines is normal, while hundreds suggest a framework major release pulled the tree along with it.

When the vulnerable package is a transitive dependency

An audit often flags something you never asked for, three levels down. The first move is uv lock --upgrade-package on that package; it upgrades only if the parents' ranges allow it. If a parent caps it, you have two tools, in order of preference:

[tool.uv]
# Narrow the allowed range without making it a direct dependency
constraint-dependencies = ["urllib3>=2.2.2"]

# Last resort: replace the requirement, ignoring what parents declare
override-dependencies = ["somepkg==1.8.3"]

A constraint only narrows what the resolver may pick, and resolution fails loudly if it cannot be satisfied — which tells you the parent must be upgraded first. An override replaces the parent's declared range entirely; use it only when you have confirmed the parent works with the newer version, leave a comment with the reason and an issue link, and remove it once the parent releases a fix. Overrides that nobody remembers are how a tree drifts into combinations nobody has tested.

A complete CI pipeline

The pieces above combine into a short pipeline. Whatever CI system you use, the steps are the same:

set -euo pipefail

uv lock --locked                  # 1. the committed lock matches pyproject.toml
uv sync --locked
uv run ruff check .               # 2. lint
uv run mypy .                     # 3. types
uv run pytest -q                  # 4. tests against the locked tree

uv export --frozen --no-dev --output-file /tmp/prod-requirements.txt
uvx pip-audit --requirement /tmp/prod-requirements.txt   # 5. audit

docker build -t registry.example.com/myshop:"${GIT_SHA}" .   # 6. image

The order is chosen so the cheapest failures come first. A stale lockfile fails in seconds, before any installation. Linting and type checks run before the test suite because they are faster and their failures are more precise. The audit runs against the production export, and the image build reuses the same lockfile with --frozen, so the tests and the shipped image contain identical dependency versions. Cache uv's cache directory between CI runs (uv cache dir prints its location), and run uv cache prune --ci before saving it: that removes pre-built wheels that are cheap to re-download while keeping the ones built from source, which are the expensive part.

Troubleshooting common failures

SymptomLikely causeFix
--locked fails because the lockfile needs updatingpyproject.toml changed without re-lockingRun uv lock locally and commit both files together
"No solution found when resolving dependencies"Two packages declare incompatible ranges, often for a shared transitive dependencyRead the explanation uv prints; upgrade the package holding the cap, or relax your own range
Build suddenly compiles C code and takes minutesNo wheel for the new Python version or platformCheck the wheel tags; pin the Python minor version or move to a release that ships wheels
401 or 403 from the private indexEnvironment variable name does not match the index nameThe variable is derived from the index name, uppercased, with dashes turned into underscores
Internal package resolved from pypi.orgIndex not pinned; the name exists publiclyAdd an explicit index and a [tool.uv.sources] entry; treat as a security incident until you have verified what was installed
Hash mismatch on installA file changed on the index, or something in between altered itDo not bypass the check; find out why the bytes differ before re-locking
ImportError mentioning a shared library in the containerSomething was built on a different base image than the runtimeBuild and run on the same base; install runtime system libraries in the final stage

A hash mismatch deserves the most caution. It is occasionally benign — a registry that regenerated an artifact — but it is also exactly the signal a supply-chain compromise produces. Establish which file changed, compare it with the upstream artifact, and only re-lock once you understand the cause. If an internal package was ever resolved from a public index, identify every environment that installed it, rotate any credentials those environments could reach, and inspect the installed package before assuming it was harmless.

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.