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.
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.
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:
pyproject.toml.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.
[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.
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 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.
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:
| Command | Re-resolves? | If uv.lock is stale | Where to use it |
|---|---|---|---|
uv sync | Yes, when needed | Updates the lockfile silently | Local development |
uv sync --locked | Checks only | Fails with an error | CI test jobs: proves the committed lock is current |
uv sync --frozen | No | Installs the lock as-is, without checking | Docker 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.
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:
psycopg[binary] instead of building against system libpq, unless you have a reason.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:
| Part | Value | Meaning |
|---|---|---|
| Distribution, version | psycopg_binary-3.2.3 | Package name and release |
| Python tag | cp312 | CPython 3.12 only |
| ABI tag | cp312 | Compiled against the 3.12 C ABI; abi3 would mean the stable ABI, usable on later versions too |
| Platform tag | manylinux_2_17_x86_64 | Linux 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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
--locked fails because the lockfile needs updating | pyproject.toml changed without re-locking | Run uv lock locally and commit both files together |
| "No solution found when resolving dependencies" | Two packages declare incompatible ranges, often for a shared transitive dependency | Read the explanation uv prints; upgrade the package holding the cap, or relax your own range |
| Build suddenly compiles C code and takes minutes | No wheel for the new Python version or platform | Check the wheel tags; pin the Python minor version or move to a release that ships wheels |
| 401 or 403 from the private index | Environment variable name does not match the index name | The variable is derived from the index name, uppercased, with dashes turned into underscores |
| Internal package resolved from pypi.org | Index not pinned; the name exists publicly | Add an explicit index and a [tool.uv.sources] entry; treat as a security incident until you have verified what was installed |
| Hash mismatch on install | A file changed on the index, or something in between altered it | Do not bypass the check; find out why the bytes differ before re-locking |
ImportError mentioning a shared library in the container | Something was built on a different base image than the runtime | Build 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.
pyproject.toml with major-version bounds only.--frozen.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.